Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs/changes/unreleased/1912-newtab-drop-tray.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
kind: fixed
title: Dropped files show as removable chips on the new-tab start page
pr: 1912
surface: [chat]
invalidates:
- "A file dropped on the new-tab start page used to attach invisibly and surface only once the chat opened. The start page now draws a removable attachment chip the moment the drop lands."
---

Dragging a file onto the start page (before any chat exists in the tab) landed
the attachment in the pending composer but drew nothing. The welcome unit now
renders the same removable chip row the conversation's composer uses, above the
draft, so a drop is visible immediately and can be undone with backspace on the
empty box or a click on the chip before the chat starts.
4 changes: 2 additions & 2 deletions internal/tui3/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -4858,7 +4858,7 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) {
// on the screen.
if a.welcome.open && !a.roomOpen() {
if mark, ok := a.chromeAt(msg.Mouse().Y); ok && mark.kind == chromeWelcome {
return a, a.welcomeRowPress(mark.index)
return a, a.welcomeRowPress(mark.index, msg.Mouse().X)
}
}
// A CLICK ON THE BOX PUTS THE CARET UNDER THE POINTER (draftclick.go).
Expand Down Expand Up @@ -7484,7 +7484,7 @@ func (a *app) press(x, y int) (cmd tea.Cmd) {
// dismissal means (welcome.go).
if a.welcome.open && !a.roomOpen() {
if mark, ok := a.chromeAt(y); ok && mark.kind == chromeWelcome {
return a.welcomeRowPress(mark.index)
return a.welcomeRowPress(mark.index, x)
}
a.dismissWelcome()
}
Expand Down
125 changes: 125 additions & 0 deletions internal/tui3/chatstart_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,14 @@ package tui3

import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"

tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi"
)

// ── THE NEW-CHAT START PAGE ─────────────────────────────────────────────────
Expand Down Expand Up @@ -556,3 +561,123 @@ func TestTheTaskPageAndItsOwnLineComeBackWhenTheStartPageIsCancelled(t *testing.
t.Fatalf("opening and cancelling the page over a task made %d conversations", made)
}
}

// ── THE TRAY ────────────────────────────────────────────────────────────────

// A FILE DROPPED ON THE START PAGE IS DRAWN ON IT. The drop lands in the
// person's tray through the same door as any other ([app.keyboardBox]), so
// what was missing was the START PAGE's half: the unit draws its own tray row
// above the box, and the page says the drop landed instead of keeping it a
// secret until the chat opens (1827).
func TestDroppingFilesOntoTheStartPageDrawsThemOnTheTray(t *testing.T) {
lab := newStartLab(t)
a := lab.app()
openStart(t, a)
shot := filepath.Join(t.TempDir(), "shot.png")
if err := os.WriteFile(shot, []byte("png"), 0o644); err != nil {
t.Fatal(err)
}
pasteText(t, a, shot)

if !a.startingChat() {
t.Fatal("a drop on the start page took the page down")
}
if want := []string{"shot.png"}; !equalStrings(chipNames(a), want) {
t.Fatalf("chips are %v, want %v", chipNames(a), want)
}
if frame := plain(mustFrame(a)); !strings.Contains(frame, "▣ #1 shot.png") {
t.Fatalf("the start page names no attachment:\n%s", frame)
}
}

// BACKSPACE ON THE START PAGE'S EMPTY BOX TAKES THE LAST CHIP OFF, the way it
// does on the conversation's: the page's box is the real draft, so the tray is
// already behind the caret there. A non-picture drop is what leaves the box
// empty enough to stand on it — dropping a picture writes its `[image #n]`
// into the sentence (imagepaste.go).
func TestBackspaceOnTheStartPagesEmptyBoxTakesTheLastChipOff(t *testing.T) {
lab := newStartLab(t)
a := lab.app()
openStart(t, a)
dir := t.TempDir()
one, two := filepath.Join(dir, "one.md"), filepath.Join(dir, "two.md")
if err := os.WriteFile(one, []byte("a"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(two, []byte("b"), 0o644); err != nil {
t.Fatal(err)
}
pasteText(t, a, one)
pasteText(t, a, two)

if got := a.input.String(); got != "" {
t.Fatalf("dropping files wrote %q into the box", got)
}
drive(t, a, key("backspace"))
if want := []string{"one.md"}; !equalStrings(chipNames(a), want) {
t.Fatalf("chips are %v, want %v after backspace", chipNames(a), want)
}
frame := plain(mustFrame(a))
if strings.Contains(frame, "two.md") {
t.Fatalf("the taken chip is still drawn on the page:\n%s", frame)
}
if !strings.Contains(frame, "one.md") {
t.Fatalf("the remaining chip vanished from the page:\n%s", frame)
}
}

// A CLICK ON THE START PAGE'S TRAY TAKES OFF THE CHIP IT LANDED ON, the way a
// click on the conversation's tray does — and the picture's token leaves the
// sentence with it. The row answers by column because the chips it drew came
// out of the unit's own lead ([app.welcomeUnitBox]), the same lead the
// conversation's tray is resolved from (attach.go's [app.chipAt]).
func TestAClickOnTheStartPagesTrayTakesThatChipOff(t *testing.T) {
lab := newStartLab(t)
a := lab.app()
openStart(t, a)
dir := t.TempDir()
one, two := filepath.Join(dir, "one.png"), filepath.Join(dir, "two.png")
if err := os.WriteFile(one, []byte("p"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(two, []byte("q"), 0o644); err != nil {
t.Fatal(err)
}
pasteText(t, a, one)
pasteText(t, a, two)
if want := []string{"one.png", "two.png"}; !equalStrings(chipNames(a), want) {
t.Fatalf("chips are %v, want %v", chipNames(a), want)
}

// The tray is the frame row that names both pictures; the second chip is
// clicked on its own name, in the column the unit drew it at. The column is
// measured as CELLS, not bytes: the chip mark and its remove glyph are
// multi-byte runes, and the pointer lands on the column where the name was
// painted ([app.welcomeUnitBox]'s lead is inside the row already).
lines := strings.Split(plain(mustFrame(a)), "\n")
y, x := -1, -1
for i, line := range lines {
if at := strings.Index(line, "two.png"); at >= 0 {
y, x = i, ansi.StringWidth(line[:at])+1
break
}
}
if y < 0 {
t.Fatalf("the start page drew no tray:\n%s", strings.Join(lines, "\n"))
}
drive(t, a, tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft})
drive(t, a, tea.MouseReleaseMsg{X: x, Y: y, Button: tea.MouseLeft})

if want := []string{"one.png"}; !equalStrings(chipNames(a), want) {
t.Fatalf("chips are %v, want the clicked one off", chipNames(a))
}
if !a.startingChat() {
t.Fatal("a press on the tray took the start page down")
}
if got := a.input.String(); strings.Contains(got, "#2") {
t.Fatalf("the removed picture's token stayed in the sentence: %q", got)
}
if frame := plain(mustFrame(a)); strings.Contains(frame, "two.png") {
t.Fatalf("the removed chip is still drawn on the page:\n%s", frame)
}
}
13 changes: 13 additions & 0 deletions internal/tui3/credits_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,13 @@ func TestLowCreditReadSwapsOnlyAnUntouchedImplicitConversation(t *testing.T) {
a := placeApp(t)
a.readCredits = func(context.Context) (credits.Reading, error) { return credits.Reading{Known: true, Low: true}, nil }
seedLowCredits(t, a)
// THE SWAP IS A FREE ROUTE. The low-credit move to the free model is gated
// on the crew's free routes being on (config.ChatDefaultAt), which a fresh
// test profile has off (#1905); a test of the swap says so and turns them
// on, or the gate makes the assertion inert.
if err := config.SetCrewFreeRoutes(a.profileDir, true); err != nil {
t.Fatal(err)
}
a.implicitTalk = true
a.model = config.DefaultModel
a.tookCredits(creditReadMsg{reading: credits.Reading{Known: true, Low: true}})
Expand All @@ -252,6 +259,12 @@ func TestLowCreditReadKeepsUsedAndChosenConversationModels(t *testing.T) {
a := placeApp(t)
a.model = config.DefaultModel
seedLowCredits(t, a)
// Same opt-in as the swap test above: with free routes off the
// low-credit path would not even try to move the model, and the
// "kept" assertion would pass without exercising it (#1905).
if err := config.SetCrewFreeRoutes(a.profileDir, true); err != nil {
t.Fatal(err)
}
a.implicitTalk = tc.implicit
if tc.used {
a.turn = 1
Expand Down
92 changes: 76 additions & 16 deletions internal/tui3/welcome.go
Original file line number Diff line number Diff line change
Expand Up @@ -573,15 +573,30 @@ func (a *app) openSession(chosen Session) (tea.Cmd, string) {
return cmd, ""
}

// welcomeRowPress is a click on one row of the unit: on a recent session it
// opens that session, on the message box it does nothing — the keyboard is
// already there, and a click that yanked the box to the foot of the frame would
// be the surface moving the thing a person just aimed at — and anywhere else it
// is the person reaching past the greeting, which dismisses it.
func (a *app) welcomeRowPress(row int) tea.Cmd {
// welcomeRowPress is a click on one row of the unit: on the attachment tray it
// takes the chip under the pointer off, on a recent session it opens that
// session, on the message box it does nothing — the keyboard is already there,
// and a click that yanked the box to the foot of the frame would be the surface
// moving the thing a person just aimed at — and anywhere else it is the person
// reaching past the greeting, which dismisses it.
func (a *app) welcomeRowPress(row, x int) tea.Cmd {
if !a.welcome.open {
return nil
}
// THE TRAY ANSWERS BY COLUMN as well as by row. The chips it drew came out
// of [app.welcomeUnitBox]'s lead, so the pointer's own column is the same
// one [app.chipPress] resolves a conversation's tray with — what is drawn
// and what a click hits are read from the same labels (attach.go's
// [app.chipAt]). The row is found through the unit's marks and not the foot
// chrome's geometry, because this tray has no foot chrome: it is a row of
// the unit [app.chipTrayTarget] deliberately refuses to see (attach.go).
if mark, ok := a.welcomeMarkAt(row); ok && mark.kind == welcomeRowTray {
_, lead := a.welcomeUnitBox(a.widthOr())
if i := chipAt(removableChipLabels(a.chips, a.pal), x-lead); i >= 0 {
a.removeChip(i)
}
return nil
}
if a.welcomeInputRow(row) {
return nil
}
Expand Down Expand Up @@ -866,6 +881,11 @@ const (
// welcomeRowStarter is one of the first conversation's starting points, and
// pressing it does what enter on it does: fills the box, sends nothing.
welcomeRowStarter
// welcomeRowTray is the attachment tray drawn above the start page's box
// while a drop is waiting on it ([app.welcomeUnit]). Pressing a chip's own
// column takes it off the way the conversation's tray does (attach.go's
// [app.chipPress]).
welcomeRowTray
)

// welcomeMark is one row's kind and, for a recent session, which one.
Expand Down Expand Up @@ -978,6 +998,21 @@ func (a *app) welcomeRows(width int) []string {
return rows
}

// welcomeUnitBox is the unit's width and the lead that centres it — the two
// numbers every row of the unit is drawn with. The draw and the pointer ask
// this one function, so a click can never land a column off from what was
// painted (chipLabels' own law, attach.go).
func (a *app) welcomeUnitBox(width int) (unit, lead int) {
unit = min(width-4, welcomeUnitWidth)
lead = (width - unit) / 2
// One cell further in while the unit is arriving: the whole slide is a
// single column, which is a movement a person notices without watching.
if a.welcome.step < welcomeSlide {
lead++
}
return unit, lead
}

// welcomeUnit is the unit as rows, one mark per row, and where the caret sits
// in it — a column from the frame's left edge, and a row from the unit's top —
// while the unit holds the message box.
Expand All @@ -994,13 +1029,7 @@ func (a *app) welcomeUnit(width int) ([]string, []welcomeMark, int, int) {
}
w := &a.welcome
pal := a.pal
unit := min(width-4, welcomeUnitWidth)
lead := (width - unit) / 2
// One cell further in while the unit is arriving: the whole slide is a
// single column, which is a movement a person notices without watching.
if w.step < welcomeSlide {
lead++
}
unit, lead := a.welcomeUnitBox(width)
pad := strings.Repeat(" ", lead)

rows := make([]string, 0, 16)
Expand Down Expand Up @@ -1044,12 +1073,35 @@ func (a *app) welcomeUnit(width int) ([]string, []welcomeMark, int, int) {

caretX, caretRow := 0, 0
if a.welcomeHolds() {
// THE TRAY IS PART OF THE BOX, AND THE START PAGE DRAWS ITS OWN — one
// row above the draft, the chips alone, dimmed the way the
// conversation's tray is (attach.go). A drop lands in the person's tray
// on this page through the same [app.keyboardBox] door it lands through
// anywhere else, and a page that accepted the file and painted nothing
// about it looked exactly like a page that had not (1827): the file only
// appeared once the chat opened and the composer's row finally drew it.
// It is the CHIPS ONLY and deliberately NOT [app.chipStrip]: this page
// has no chat yet, and the strip would draw the picked harness, folders
// and skills of the chat this page is about to replace over the box that
// belongs to a new one.
tray := ""
if labels := removableChipLabels(a.chips, pal); len(labels) > 0 {
painted := make([]string, len(labels))
for i, label := range labels {
painted[i] = pal.dim(label)
}
tray, _ = fitWidth(strings.Join(painted, chipGap), unit)
}
// THE DRAFT IS THE REAL DRAFT, laid out by the same function the foot of
// the frame uses, at the unit's width. It may not take the window: a
// restored draft six rows tall in a twelve-row window would push the
// wordmark off the top, so the box gets what is left after the rest of
// the unit and the status row have taken theirs.
// the unit and the status row have taken theirs — and the tray above it
// has taken a row of that same allowance.
box := max(1, min(draftRows, a.welcomeRowsLeft()-a.statusHeight(width)-8))
if tray != "" {
box = max(1, box-1)
}
// AND A SECRET IS MASKED WHEREVER THE BOX IS DRAWN. The greeting lifts
// the real draft into the middle of the frame, and a question asking for
// a credential is answered in exactly that box — so the one rule about
Expand All @@ -1060,9 +1112,17 @@ func (a *app) welcomeUnit(width int) ([]string, []welcomeMark, int, int) {
if block == nil {
block, x, row = draftBlockWithTags(&a.input, pal, unit, box, a.trafficHint(), a.roomLead(unit), a.input.demotedTags, a.draftInk())
}
if tray != "" {
block = append([]string{tray}, block...)
row++
}
caretX, caretRow = lead+x, len(rows)+row
for _, line := range block {
add(line, welcomeMark{kind: welcomeRowInput})
for at, line := range block {
kind := welcomeRowInput
if at == 0 && tray != "" {
kind = welcomeRowTray
}
add(line, welcomeMark{kind: kind})
}
}
// A DOOR THAT REFUSED SAYS SO HERE. While the start page is up the transcript
Expand Down
Loading