From 89a878dd734eb28c69a2b162fab757dd2b1f3a53 Mon Sep 17 00:00:00 2001 From: zeropoint95 Date: Fri, 23 Oct 2026 11:52:14 -0400 Subject: [PATCH 1/2] new tab: files dropped onto the new-tab surface never show, though they attach once chat opens Assisted-by: CodeAF Co-Authored-By: CodeAF <267109073+agentfield-bot@users.noreply.github.com> --- internal/tui3/app.go | 4 +- internal/tui3/chatstart_test.go | 125 ++++++++++++++++++++++++++++++++ internal/tui3/credits_test.go | 13 ++++ internal/tui3/welcome.go | 92 +++++++++++++++++++---- 4 files changed, 216 insertions(+), 18 deletions(-) diff --git a/internal/tui3/app.go b/internal/tui3/app.go index be0863a8dd..0a246df930 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -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). @@ -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() } diff --git a/internal/tui3/chatstart_test.go b/internal/tui3/chatstart_test.go index d789362d25..2852866a79 100644 --- a/internal/tui3/chatstart_test.go +++ b/internal/tui3/chatstart_test.go @@ -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 ───────────────────────────────────────────────── @@ -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) + } +} diff --git a/internal/tui3/credits_test.go b/internal/tui3/credits_test.go index 420e6bf456..f32490ce7e 100644 --- a/internal/tui3/credits_test.go +++ b/internal/tui3/credits_test.go @@ -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}}) @@ -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 diff --git a/internal/tui3/welcome.go b/internal/tui3/welcome.go index c3107d8362..6705af9138 100644 --- a/internal/tui3/welcome.go +++ b/internal/tui3/welcome.go @@ -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 } @@ -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. @@ -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. @@ -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) @@ -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 @@ -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 From ef2933a0689b33714213740c1f126ef5161ab142 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:57:07 -0400 Subject: [PATCH 2/2] docs: change entry for the new-tab dropped-file tray Every PR into dev carries an entry in docs/changes/unreleased; PR 1912 had none, which fails the check job's changelog gate. Assisted-by: CodeAF (deepseek-v4-flash-latest) Co-Authored-By: CodeAF <267109073+agentfield-bot@users.noreply.github.com> --- docs/changes/unreleased/1912-newtab-drop-tray.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 docs/changes/unreleased/1912-newtab-drop-tray.md diff --git a/docs/changes/unreleased/1912-newtab-drop-tray.md b/docs/changes/unreleased/1912-newtab-drop-tray.md new file mode 100644 index 0000000000..9e8552c299 --- /dev/null +++ b/docs/changes/unreleased/1912-newtab-drop-tray.md @@ -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.