From 2e1d7248bb80dc2ada851071c4e8a5e8ae803ed6 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:59:03 -0400 Subject: [PATCH 1/6] chat: show attachment removal and detach unsent cargo on Home --- docs/design/icons/DESIGN.md | 9 ++++ internal/manual/chat/attaching-files.md | 11 +++-- internal/manual/chat/keys.md | 8 ++-- internal/tui2/tokens/glyph.go | 3 ++ internal/tui2/tokens/glyphset.go | 2 + internal/tui2/tokens/nerdfont.go | 5 +++ internal/tui3/attach.go | 14 +++++- internal/tui3/attach_test.go | 2 +- internal/tui3/attachment_home_test.go | 60 +++++++++++++++++++++++++ internal/tui3/home.go | 6 +++ internal/tui3/hoverpressable_test.go | 2 +- 11 files changed, 111 insertions(+), 11 deletions(-) create mode 100644 internal/tui3/attachment_home_test.go diff --git a/docs/design/icons/DESIGN.md b/docs/design/icons/DESIGN.md index 4e84c7aab6..cb1082b822 100644 --- a/docs/design/icons/DESIGN.md +++ b/docs/design/icons/DESIGN.md @@ -118,6 +118,15 @@ The gutter says what family a step is and NEVER how it went: `test` draws a flask and never a checkmark, because the family is the act of checking and not its verdict. +### Attachment action + +| Meaning | Slot | Plain | Nerd font | ASCII | +| --- | --- | --- | --- | --- | +| remove an unsent attachment | `GRemove` | `×` | nf-fa-times_circle | `x` | + +The dim action mark is distinct from the failed-work mark. It is drawn only where +a click can remove the attachment, and is resolved explicitly through the vocabulary. + ### File kinds (chips, and the gutter beside a call that made or opened one) | Kind | Slot | Plain | Nerd font | ASCII | diff --git a/internal/manual/chat/attaching-files.md b/internal/manual/chat/attaching-files.md index 9debcbf942..f9fbd1d964 100644 --- a/internal/manual/chat/attaching-files.md +++ b/internal/manual/chat/attaching-files.md @@ -452,9 +452,11 @@ here` — rather than opening the browser. `/folder` is the browser on local hom aimed at which folder the next conversation opens in (see "Choosing a folder"). Over `--host`, `/folder` says why this machine's folder cannot be that far conversation's folder. -**The tray survives the walk.** Attach a file on home, go into a conversation, come back: it -is still there. Home's tray row cannot be clicked; a chip comes off on a conversation's own -tray, where the `✕` is. +**Returning to Home detaches unsent conversation attachments.** Their picture tokens +come out of the conversation draft too; the files themselves are untouched. Queued +messages keep their own attachments. Attach on Home to include a file in a new +conversation. Home's tray row cannot be clicked; on a conversation's tray, click the +chip or its remove mark (`×`, or `x` in ASCII mode). A few refusals come from the far machine instead and arrive with `engine:` in front of them — the file arrived with no usable name, or with a name that was really a path: @@ -512,7 +514,8 @@ and were told the file is not there, that is what happened. Three ways, all the same as for a picture: - **`backspace` over an empty message box** removes the last thing on the tray. -- **Click a chip** and it comes off. +- **Click a chip or its remove mark** on a conversation's tray and it comes off. + Its mark is `×` in plain mode, a circled cross with a rich font, and `x` in ASCII mode. - **Send the message** — the tray empties into it. Removing a picture also takes its `[image #n]` token out of your sentence and counts the diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index ed9d6e11fd..77107cdd59 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -1383,12 +1383,14 @@ answers every picture question here, whether you attached the file or not. Attaching is for a picture you are handing over as part of what you are saying. **The tray.** Attached pictures sit in a one-row tray directly above the message box, -one dim chip each, drawn as `▣ #1 name.png` — `*` in place of the square on an ASCII -terminal. The number is the picture's place in the message and the number `[image #1]` +one dim chip each, drawn as `▣ #1 name.png ×` — `*` in place of the square and +`x` for removal on an ASCII terminal. The number is the picture's place in the message and the number `[image #1]` in your sentence refers to. The message box stays the sentence. `backspace` over an empty box drops the last chip, and clicking a chip removes that one — and takes its `[image #n]` out of your sentence, counting the ones behind it down so the numbers -stay true. +stay true. Returning to Home detaches unsent attachments and removes their picture +tokens; attach on Home to include them in a new conversation. Queued messages keep +their own attachments. ## Dragging or pasting a screenshot in diff --git a/internal/tui2/tokens/glyph.go b/internal/tui2/tokens/glyph.go index 3721985848..090f109bc1 100644 --- a/internal/tui2/tokens/glyph.go +++ b/internal/tui2/tokens/glyph.go @@ -69,6 +69,9 @@ const ( // the distinction the one-glyph-one-meaning gate exists to keep. GlyphAssumed = "≈" + // GlyphRemove offers to detach an unsent attachment; it reports no failure. + GlyphRemove = "×" + // Disclosure and navigation. GlyphCollapsed = "▸" GlyphExpanded = "▾" diff --git a/internal/tui2/tokens/glyphset.go b/internal/tui2/tokens/glyphset.go index dd5d822c7e..f155d6808a 100644 --- a/internal/tui2/tokens/glyphset.go +++ b/internal/tui2/tokens/glyphset.go @@ -189,6 +189,8 @@ const ( // was sent: the ring of them behind /drafts and the ↑ walk's dim end // (internal/tui3's draftring.go, draftpage.go). GDraftUnsent + // GRemove is an action on an attachment, not a work state. + GRemove glyphIDCount ) diff --git a/internal/tui2/tokens/nerdfont.go b/internal/tui2/tokens/nerdfont.go index e43e5c9a5b..eca027b958 100644 --- a/internal/tui2/tokens/nerdfont.go +++ b/internal/tui2/tokens/nerdfont.go @@ -69,6 +69,11 @@ var vocabulary = []GlyphBinding{ ASCII: "x", UsualTint: Coral, NFAmbiguous: true, AutoUpgrade: true, }, + { + ID: GRemove, Name: "Remove", Meaning: "detach an unsent attachment", + Plain: GlyphRemove, NerdFont: "\uF057", NFName: "nf-fa-times_circle", + ASCII: "x", UsualTint: TextTertiary, PlainAmbiguous: true, NFAmbiguous: true, AutoUpgrade: false, + }, { ID: GStopped, Name: "Stopped", Meaning: "stopped by the person", // THE ICON IS THE TRANSPORT STOP and the plain side is the filled diff --git a/internal/tui3/attach.go b/internal/tui3/attach.go index 1a2ef9fa03..2338ab18e4 100644 --- a/internal/tui3/attach.go +++ b/internal/tui3/attach.go @@ -592,6 +592,16 @@ func chipLabels(chips []chip, pal palette) []string { return out } +// removableChipLabels adds the action only where a press can remove a chip. +// Drawing and hit-testing share these labels so the remove mark is clickable. +func removableChipLabels(chips []chip, pal palette) []string { + labels := chipLabels(chips, pal) + for i := range labels { + labels[i] += " " + pal.glyph(tokens.GRemove) + } + return labels +} + // chipStrip is the tray as one row, dim: the attachments are a fact about the // message being written, not a thing being said, and the surface says what it // is doing in the same voice it says everything else about itself. @@ -612,7 +622,7 @@ func (a *app) chipStrip(width int) string { // something every next message carries besides its words — and this is the // one place on the surface those are kept. places := a.placeTrayCells() - labels := chipLabels(a.chips, a.pal) + labels := removableChipLabels(a.chips, a.pal) if len(cells) == 0 && len(places) == 0 && len(labels) == 0 { return "" } @@ -785,7 +795,7 @@ func (a *app) chipTrayTarget(x, y int) (int, bool) { } column -= placeTrayWidth(places) } - i := chipAt(chipLabels(a.chips, a.pal), column) + i := chipAt(removableChipLabels(a.chips, a.pal), column) if i < 0 { return 0, false } diff --git a/internal/tui3/attach_test.go b/internal/tui3/attach_test.go index 80fd085a1c..896dfbd3a6 100644 --- a/internal/tui3/attach_test.go +++ b/internal/tui3/attach_test.go @@ -195,7 +195,7 @@ func TestAClickOnAChipTakesThatChipOff(t *testing.T) { // (attach.go's [app.chipTrayTarget] says the whole of it). y := trayRow(a) // The second chip starts after the first label and the gap between them. - x := len(inputPad) + ansi.StringWidth(chipLabels(a.chips, a.pal)[0]) + len(chipGap) + 1 + x := len(inputPad) + ansi.StringWidth(removableChipLabels(a.chips, a.pal)[0]) + len(chipGap) + 1 drive(t, a, tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft}) drive(t, a, tea.MouseReleaseMsg{X: x, Y: y, Button: tea.MouseLeft}) diff --git a/internal/tui3/attachment_home_test.go b/internal/tui3/attachment_home_test.go new file mode 100644 index 0000000000..ecb722625f --- /dev/null +++ b/internal/tui3/attachment_home_test.go @@ -0,0 +1,60 @@ +package tui3 + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" +) + +func TestReturningHomeDetachesUnsentConversationAttachments(t *testing.T) { + for _, door := range []string{"escape", "home", "other place"} { + t.Run(door, func(t *testing.T) { + a := backApp(t) + a.chips = []chip{{path: "one.png"}, {path: "notes.txt", file: true}, {path: "two.png"}} + a.input.setText("compare [image #1] with [image #2]") + a.parks = []parked{{text: "already queued", chips: []chip{{path: "queued.png"}}}} + switch door { + case "escape": + drive(t, a, key("esc")) + case "home": + a.openHome() + case "other place": + a.showPage(pageTasks) + a.openHome() + } + if !a.at(pageHome) || len(a.chips) != 0 { + t.Fatalf("Home inherited attachments: %+v", a.chips) + } + if got := a.input.String(); got != "compare with" { + t.Fatalf("conversation draft = %q", got) + } + if len(a.parks) != 1 || len(a.parks[0].chips) != 1 { + t.Fatal("navigation changed a queued message") + } + a.chips = []chip{{path: "attached-on-home.png"}} + drive(t, a, key("esc")) + if len(a.chips) != 1 { + t.Fatal("Escape while already Home detached its own attachment") + } + }) + } +} + +func TestAttachmentRemoveMarkIsVisibleAndClickable(t *testing.T) { + a, _, dir := attachLab(t, map[string]int{"one.png": 12, "two.png": 12}) + a.attach(dir + "/one.png") + a.attach(dir + "/two.png") + a.input.setText("compare [image #1] with [image #2]") + labels := removableChipLabels(a.chips, a.pal) + if !strings.HasSuffix(labels[0], " ×") { + t.Fatalf("missing remove mark: %q", labels[0]) + } + x := len(inputPad) + ansi.StringWidth(labels[0]) - 1 + y := trayRow(a) + drive(t, a, tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft}, tea.MouseReleaseMsg{X: x, Y: y, Button: tea.MouseLeft}) + if len(a.chips) != 1 || a.chips[0].name() != "two.png" || a.input.String() != "compare with [image #1]" { + t.Fatalf("remove did not update the tray and draft: %+v %q", a.chips, a.input.String()) + } +} diff --git a/internal/tui3/home.go b/internal/tui3/home.go index de8200e627..ccd7dd346a 100644 --- a/internal/tui3/home.go +++ b/internal/tui3/home.go @@ -891,6 +891,12 @@ func (a *app) openHome() tea.Cmd { return a.showPage(pageHome) } // their conversation is actually running on, and the head says which machine // that is ([app.placeHostWord]). func (a *app) raiseHome() tea.Cmd { + // HOME STARTS A DIFFERENT MESSAGE. Detach unsent cargo at the shared + // navigation door, including its picture tokens in the conversation draft. + // Parked messages already own their attachments and are left alone. + for a.dropChip() { + } + a.closeLists() a.dismissWelcome() world, known := a.readWorldKnown() diff --git a/internal/tui3/hoverpressable_test.go b/internal/tui3/hoverpressable_test.go index 798794e0f4..906c7e102c 100644 --- a/internal/tui3/hoverpressable_test.go +++ b/internal/tui3/hoverpressable_test.go @@ -218,7 +218,7 @@ func TestATrayChipLightsOnItsOwnCells(t *testing.T) { // moved under the box on 2026-09-09 and a count would be a row out // (attach.go's [app.chipTrayTarget] says the whole of it). y := trayRow(a) - labels := chipLabels(a.chips, a.pal) + labels := removableChipLabels(a.chips, a.pal) x := len(inputPad) + ansi.StringWidth(labels[0]) + len(chipGap) + 1 drive(t, a, motionTo(x, y)) From cbaae0fe2d560e3ae1ab66bac1101a4f97b3bcf6 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:02:39 -0400 Subject: [PATCH 2/6] chat: complete remove icon registry and provenance --- internal/tui2/tokens/glyph.go | 1 + internal/tui2/tokens/glyphset_test.go | 1 + internal/tui2/tokens/nerdfont.go | 1 + internal/tui2/tokens/testdata/nerdfont_glyphnames.json | 3 +++ 4 files changed, 6 insertions(+) diff --git a/internal/tui2/tokens/glyph.go b/internal/tui2/tokens/glyph.go index 090f109bc1..542ff55dec 100644 --- a/internal/tui2/tokens/glyph.go +++ b/internal/tui2/tokens/glyph.go @@ -438,6 +438,7 @@ func Glyphs() []GlyphInfo { {"Working", GlyphWorking, '◐', true}, {"Settled", GlyphSettled, '✓', false}, {"Failed", GlyphFailed, '✕', false}, + {"Remove", GlyphRemove, '×', true}, {"Stopped", GlyphStopped, '■', true}, {"Paused", GlyphPaused, '=', false}, {"NeedsHuman", GlyphNeedsHuman, '?', false}, diff --git a/internal/tui2/tokens/glyphset_test.go b/internal/tui2/tokens/glyphset_test.go index 7ab4684a73..afcc4d0025 100644 --- a/internal/tui2/tokens/glyphset_test.go +++ b/internal/tui2/tokens/glyphset_test.go @@ -140,6 +140,7 @@ func TestVocabularyIsCompleteAndFallsBackToFiveSeventeen(t *testing.T) { // because a painted cell that is exactly one of these is plausible CONTENT and // a mechanism that rewrote it would be a mechanism that can lie (12.7 D.3). var typedPlainSides = map[string]string{ + GlyphRemove: "the multiplication sign, which is ordinary mathematical content", GlyphActionCommunicate: "the guillemet, which is a quotation mark in half of Europe", } diff --git a/internal/tui2/tokens/nerdfont.go b/internal/tui2/tokens/nerdfont.go index eca027b958..97f0a61457 100644 --- a/internal/tui2/tokens/nerdfont.go +++ b/internal/tui2/tokens/nerdfont.go @@ -72,6 +72,7 @@ var vocabulary = []GlyphBinding{ { ID: GRemove, Name: "Remove", Meaning: "detach an unsent attachment", Plain: GlyphRemove, NerdFont: "\uF057", NFName: "nf-fa-times_circle", + // Multiplication signs in prose must remain text; the tray resolves this slot explicitly. ASCII: "x", UsualTint: TextTertiary, PlainAmbiguous: true, NFAmbiguous: true, AutoUpgrade: false, }, { diff --git a/internal/tui2/tokens/testdata/nerdfont_glyphnames.json b/internal/tui2/tokens/testdata/nerdfont_glyphnames.json index 55613ecef2..500951e4a4 100644 --- a/internal/tui2/tokens/testdata/nerdfont_glyphnames.json +++ b/internal/tui2/tokens/testdata/nerdfont_glyphnames.json @@ -143,6 +143,9 @@ "nf-fa-terminal": { "code": "f120" }, + "nf-fa-times_circle": { + "code": "f057" + }, "nf-fa-times_circle_o": { "code": "f05c" }, From 4c4d37b0809cf2167fcb197d927a1da6fb5e5259 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:08:00 -0400 Subject: [PATCH 3/6] docs: record attachment removal and Home reset --- docs/changes/unreleased/1431-attachment-home.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 docs/changes/unreleased/1431-attachment-home.md diff --git a/docs/changes/unreleased/1431-attachment-home.md b/docs/changes/unreleased/1431-attachment-home.md new file mode 100644 index 0000000000..4ad9d1d78c --- /dev/null +++ b/docs/changes/unreleased/1431-attachment-home.md @@ -0,0 +1,9 @@ +--- +kind: fixed +title: Conversation attachments offer removal and no longer follow you Home +pr: 1431 +surface: [chat, docs] +invalidates: + - "Unsent conversation attachments remained on the shared tray when Home opened. Returning Home now detaches them and removes their picture tokens; files and queued messages are untouched." + - "Conversation chips could be clicked to remove them but showed no action mark. They now show a remove mark through the shared glyph vocabulary." +--- From 88f16e76fb5ef81a2859329bcc0149690382fe66 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Mon, 28 Sep 2026 09:48:10 -0400 Subject: [PATCH 4/6] Adapt attachment regression to current Home navigation --- internal/tui3/attachment_home_test.go | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/internal/tui3/attachment_home_test.go b/internal/tui3/attachment_home_test.go index ecb722625f..ee3d17ab9a 100644 --- a/internal/tui3/attachment_home_test.go +++ b/internal/tui3/attachment_home_test.go @@ -3,21 +3,28 @@ package tui3 import ( "strings" "testing" + "time" tea "charm.land/bubbletea/v2" "github.com/charmbracelet/x/ansi" ) func TestReturningHomeDetachesUnsentConversationAttachments(t *testing.T) { - for _, door := range []string{"escape", "home", "other place"} { + for _, door := range []string{"double space", "home", "other place"} { t.Run(door, func(t *testing.T) { - a := backApp(t) + lab := newHomeLab(t) + now := lab.pin(time.Date(2026, time.September, 28, 12, 0, 0, 0, time.UTC)) + mine := lab.session("-alpha", "aaaa000000000001", "here", lab.workspace("alpha"), now) + a := lab.app(mine) a.chips = []chip{{path: "one.png"}, {path: "notes.txt", file: true}, {path: "two.png"}} a.input.setText("compare [image #1] with [image #2]") a.parks = []parked{{text: "already queued", chips: []chip{{path: "queued.png"}}}} + wantDraft := "compare with" switch door { - case "escape": - drive(t, a, key("esc")) + case "double space": + a.input.setText("") + wantDraft = "" + drive(t, a, key(" "), key(" ")) case "home": a.openHome() case "other place": @@ -27,7 +34,7 @@ func TestReturningHomeDetachesUnsentConversationAttachments(t *testing.T) { if !a.at(pageHome) || len(a.chips) != 0 { t.Fatalf("Home inherited attachments: %+v", a.chips) } - if got := a.input.String(); got != "compare with" { + if got := a.input.String(); got != wantDraft { t.Fatalf("conversation draft = %q", got) } if len(a.parks) != 1 || len(a.parks[0].chips) != 1 { @@ -36,7 +43,7 @@ func TestReturningHomeDetachesUnsentConversationAttachments(t *testing.T) { a.chips = []chip{{path: "attached-on-home.png"}} drive(t, a, key("esc")) if len(a.chips) != 1 { - t.Fatal("Escape while already Home detached its own attachment") + t.Fatal("Leaving Home detached its own attachment") } }) } From 10c44e0fa898bf3224e670cd7c0fa216c1a04277 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:28:54 -0400 Subject: [PATCH 5/6] chat: keep drafts and attachments with their conversation --- .../unreleased/1431-attachment-home.md | 4 +- internal/manual/chat/attaching-files.md | 11 ++- internal/manual/chat/keys.md | 5 +- internal/tui3/attach.go | 3 +- internal/tui3/attach_test.go | 3 +- internal/tui3/attachment_home_test.go | 97 ++++++++++++++++--- internal/tui3/chatstart.go | 10 +- internal/tui3/home.go | 84 ++++++---------- internal/tui3/homeat.go | 2 +- internal/tui3/homeat_test.go | 4 +- internal/tui3/homeexchange.go | 2 +- internal/tui3/homefate_test.go | 17 ++-- internal/tui3/homeslash.go | 14 ++- internal/tui3/imagepaste.go | 8 +- internal/tui3/placebodies.go | 2 +- 15 files changed, 168 insertions(+), 98 deletions(-) diff --git a/docs/changes/unreleased/1431-attachment-home.md b/docs/changes/unreleased/1431-attachment-home.md index 4ad9d1d78c..2984d17678 100644 --- a/docs/changes/unreleased/1431-attachment-home.md +++ b/docs/changes/unreleased/1431-attachment-home.md @@ -1,9 +1,9 @@ --- kind: fixed -title: Conversation attachments offer removal and no longer follow you Home +title: Conversation drafts and attachments stay with their conversation pr: 1431 surface: [chat, docs] invalidates: - - "Unsent conversation attachments remained on the shared tray when Home opened. Returning Home now detaches them and removes their picture tokens; files and queued messages are untouched." + - "Unsent conversation attachments remained on the shared tray when Home opened. Home now owns a separate tray and opens with a clean slate. The conversation keeps its unsent prompt, cursor and attachments when you leave and return; starting a conversation from Home carries only what was added there. Files and queued messages are untouched." - "Conversation chips could be clicked to remove them but showed no action mark. They now show a remove mark through the shared glyph vocabulary." --- diff --git a/internal/manual/chat/attaching-files.md b/internal/manual/chat/attaching-files.md index 8662cae979..8a0c97b5ab 100644 --- a/internal/manual/chat/attaching-files.md +++ b/internal/manual/chat/attaching-files.md @@ -458,10 +458,13 @@ browser on local home, and it is aimed at which folder the next conversation ope "Choosing a folder"); `/folder` on home opens a conversation first and browses there. Over `--host`, both say why this machine's folder cannot be that far conversation's folder. -**Returning to Home detaches unsent conversation attachments.** Their picture tokens -come out of the conversation draft too; the files themselves are untouched. Queued -messages keep their own attachments. Attach on Home to include a file in a new -conversation. Home's tray row cannot be clicked; on a conversation's tray, click the +**Returning to Home keeps your unsent prompt and attachments in their conversation.** +Reopen that conversation to find the same words, cursor position and picture tokens. +They never appear on Home or in another conversation. Home starts with an empty box +and tray; only words and files you add there go into the new conversation. Queued +messages keep their own attachments. If the current draft has no conversation identity +to save it under, starting another says `finish or clear the draft in the current chat before starting another`. +Home's tray row cannot be clicked; on a conversation's tray, click the chip or its remove mark (`×`, or `x` in ASCII mode). A few refusals come from the far machine instead and arrive with `engine:` in front of diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index 480cc502f9..b1d5b49a36 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -1405,8 +1405,9 @@ one dim chip each, drawn as `▣ #1 name.png ×` — `*` in place of the square in your sentence refers to. The message box stays the sentence. `backspace` over an empty box drops the last chip, and clicking a chip removes that one — and takes its `[image #n]` out of your sentence, counting the ones behind it down so the numbers -stay true. Returning to Home detaches unsent attachments and removes their picture -tokens; attach on Home to include them in a new conversation. Queued messages keep +stay true. Returning to Home preserves your unsent prompt, cursor and attachments in +that conversation. Reopening it restores them. Home starts with an empty box and tray; +a new conversation receives only what you type or attach there. Queued messages keep their own attachments. ## Dragging or pasting a screenshot in diff --git a/internal/tui3/attach.go b/internal/tui3/attach.go index be631698b4..88bf97843f 100644 --- a/internal/tui3/attach.go +++ b/internal/tui3/attach.go @@ -216,7 +216,8 @@ func (a *app) attachFile(path string) bool { } func (a *app) attachChip(held chip) bool { - if !attachChipTo(&a.chips, held) { + _, tray := a.keyboardBox() + if !attachChipTo(tray, held) { return false } a.touch() diff --git a/internal/tui3/attach_test.go b/internal/tui3/attach_test.go index 74d96fe86d..89f5d5380a 100644 --- a/internal/tui3/attach_test.go +++ b/internal/tui3/attach_test.go @@ -87,7 +87,8 @@ func tab() tea.KeyPressMsg { return tea.KeyPressMsg{Code: tea.KeyTab} } func chipNames(a *app) []string { out := make([]string, 0, len(a.chips)) - for _, c := range a.chips { + _, tray := a.keyboardBox() + for _, c := range *tray { out = append(out, c.name()) } return out diff --git a/internal/tui3/attachment_home_test.go b/internal/tui3/attachment_home_test.go index ee3d17ab9a..ac5cb03925 100644 --- a/internal/tui3/attachment_home_test.go +++ b/internal/tui3/attachment_home_test.go @@ -1,6 +1,8 @@ package tui3 import ( + "errors" + "fmt" "strings" "testing" "time" @@ -9,7 +11,7 @@ import ( "github.com/charmbracelet/x/ansi" ) -func TestReturningHomeDetachesUnsentConversationAttachments(t *testing.T) { +func TestReturningHomeKeepsUnsentConversationAttachments(t *testing.T) { for _, door := range []string{"double space", "home", "other place"} { t.Run(door, func(t *testing.T) { lab := newHomeLab(t) @@ -18,12 +20,13 @@ func TestReturningHomeDetachesUnsentConversationAttachments(t *testing.T) { a := lab.app(mine) a.chips = []chip{{path: "one.png"}, {path: "notes.txt", file: true}, {path: "two.png"}} a.input.setText("compare [image #1] with [image #2]") + a.input.cursor = 4 a.parks = []parked{{text: "already queued", chips: []chip{{path: "queued.png"}}}} - wantDraft := "compare with" + wantDraft, wantCursor := a.input.String(), a.input.cursor switch door { case "double space": a.input.setText("") - wantDraft = "" + wantDraft, wantCursor = "", 0 drive(t, a, key(" "), key(" ")) case "home": a.openHome() @@ -31,24 +34,96 @@ func TestReturningHomeDetachesUnsentConversationAttachments(t *testing.T) { a.showPage(pageTasks) a.openHome() } - if !a.at(pageHome) || len(a.chips) != 0 { - t.Fatalf("Home inherited attachments: %+v", a.chips) + if !a.at(pageHome) || len(a.home.chips) != 0 || a.home.box.String() != "" || a.home.carrying { + t.Fatal("Home inherited a conversation draft") } - if got := a.input.String(); got != wantDraft { - t.Fatalf("conversation draft = %q", got) + if strings.Contains(homeText(a), "one.png") { + t.Fatal("Home drew the conversation's tray") + } + a.home.chips = []chip{{path: "attached-on-home.png"}} + a.home.box.setText("a different request") + drive(t, a, key("esc")) + if a.input.String() != wantDraft || a.input.cursor != wantCursor || len(a.chips) != 3 || a.chips[0].path != "one.png" { + t.Fatalf("conversation draft changed: %q, cursor %d, chips %+v", a.input.String(), a.input.cursor, a.chips) } if len(a.parks) != 1 || len(a.parks[0].chips) != 1 { t.Fatal("navigation changed a queued message") } - a.chips = []chip{{path: "attached-on-home.png"}} - drive(t, a, key("esc")) - if len(a.chips) != 1 { - t.Fatal("Leaving Home detached its own attachment") + a.openHome() + if a.home.box.String() != "" || len(a.home.chips) != 0 { + t.Fatal("returning Home did not start clean") } }) } } +func TestStartingFromHomeKeepsThePreviousConversationsDraft(t *testing.T) { + for _, fresh := range []bool{false, true} { + for _, target := range []string{"same project", "other project", "project row"} { + t.Run(fmt.Sprintf("fresh=%t/%s", fresh, target), func(t *testing.T) { + lab := newStartLab(t) + a := lab.app() + a.open = func(string, string) (Conversation, error) { + t.Fatal("a held conversation was reopened instead of restored") + return Conversation{}, nil + } + if fresh { + a.turn = 0 + } + old := a.file + a.input.setText("unfinished [image #1]") + a.input.cursor = 3 + a.chips = []chip{{path: "old.png"}, {path: "old.txt", file: true}} + a.openHome() + a.home.chips = []chip{{path: "new.txt", file: true}} + if target == "project row" { + a.homeStartInProject("/tmp/another-project") + } else { + if target == "other project" { + a.target.where = "/tmp/another-project" + } + if _, ok := a.homeOpenAtTarget(); !ok { + t.Fatal("Home did not open the new conversation") + } + } + if a.input.String() != "" || len(a.chips) != 1 || a.chips[0].path != "new.txt" { + t.Fatalf("new conversation inherited the old draft: %q %+v", a.input.String(), a.chips) + } + a.input.setText("second conversation's draft") + a.openHome() + if _, refusal := a.openSession(Session{File: old}); refusal != "" { + t.Fatal(refusal) + } + a.closeHome() + if a.input.String() != "unfinished [image #1]" || a.input.cursor != 3 || len(a.chips) != 2 || a.chips[0].path != "old.png" { + t.Fatalf("return lost the old draft: %q, cursor %d, chips %+v", a.input.String(), a.input.cursor, a.chips) + } + }) + } + } +} + +func TestHomeCreationRefusalKeepsBothDrafts(t *testing.T) { + lab := newStartLab(t) + a := lab.app() + a.input.setText("unsent conversation prompt") + a.chips = []chip{{path: "old.png"}} + a.openHome() + a.home.box.setText("new conversation prompt") + a.home.chips = []chip{{path: "new.png"}} + lab.refuse = errors.New("cannot start") + if _, started := a.homeOpenAtTarget(); started { + t.Fatal("creation unexpectedly succeeded") + } + if !a.at(pageHome) || a.home.box.String() != "new conversation prompt" || len(a.home.chips) != 1 { + t.Fatal("refusal lost Home's draft") + } + a.closeHome() + if a.input.String() != "unsent conversation prompt" || len(a.chips) != 1 || a.chips[0].path != "old.png" { + t.Fatal("refusal lost the conversation's draft") + } +} + func TestAttachmentRemoveMarkIsVisibleAndClickable(t *testing.T) { a, _, dir := attachLab(t, map[string]int{"one.png": 12, "two.png": 12}) a.attach(dir + "/one.png") diff --git a/internal/tui3/chatstart.go b/internal/tui3/chatstart.go index e55f449026..a7667d1454 100644 --- a/internal/tui3/chatstart.go +++ b/internal/tui3/chatstart.go @@ -62,7 +62,7 @@ import ( // creation before detaching it when doing so would strand its unsent words. const startDraftUnownedWord = "finish or clear the draft in the current chat before starting another" -// startKeepsLeaving is `+`'s one amendment to what a create may CLOSE. +// startKeepsLeaving keeps the draft behind either new-conversation surface. // // A FRESH CONVERSATION HOLDING AN UNSENT SENTENCE IS KEPT. /new's exception ends // a fresh and empty conversation because nothing is in it and the draft goes with @@ -74,11 +74,11 @@ const startDraftUnownedWord = "finish or clear the draft in the current chat bef // exists to prevent. Kept, the words stay in their own conversation and come back // by pressing its tab. // -// IT IS ASKED OF THE START PAGE ONLY. Every other caller of the door is standing -// in the conversation it is about to leave, where carrying the box forward is the -// promise rather than the leak. +// HOME MAKES THE SAME PROMISE. Its new conversation starts with its own box and +// tray, while the conversation behind it keeps the draft somebody left there. +// /new inside a conversation still carries the draft forward. func (a *app) startKeepsLeaving() bool { - if !a.startingChat() || a.mainComposer().empty() { + if (!a.startingChat() && !a.at(pageHome)) || a.mainComposer().empty() { return false } return a.agent != nil && a.convKey(a.file) != "" diff --git a/internal/tui3/home.go b/internal/tui3/home.go index 2bea4bbae9..3aa886012c 100644 --- a/internal/tui3/home.go +++ b/internal/tui3/home.go @@ -636,9 +636,11 @@ type homeView struct { // mode and nothing to switch: a person's fingers should not have to choose // what a word is for before they have finished typing it. box editor + // The tray belongs to Home's next message, never to the conversation behind it. + chips []chip // carrying says the tray this box's next message would take with it is - // holding something ([app.chips], attach.go). It is a COPY of a fact that - // lives on the app, kept the way [homeView.exchanges] is and for the same + // holding something ([homeView.chips], attach.go). It is a COPY of a fact that + // lives on this view, kept the way [homeView.exchanges] is and for the same // reason: every question this screen asks about "is anything typed" is asked // from a method on the view, and a file dropped on home leaves NOTHING in the // box — an ordinary file rides the tray and writes no token — so a screen @@ -935,12 +937,6 @@ func (a *app) openHome() tea.Cmd { return a.showPage(pageHome) } // their conversation is actually running on, and the head says which machine // that is ([app.placeHostWord]). func (a *app) raiseHome() tea.Cmd { - // HOME STARTS A DIFFERENT MESSAGE. Detach unsent cargo at the shared - // navigation door, including its picture tokens in the conversation draft. - // Parked messages already own their attachments and are left alone. - for a.dropChip() { - } - a.closeLists() a.dismissWelcome() world, known := a.readWorldKnown() @@ -1309,12 +1305,6 @@ func (a *app) newHomeView(world session.World, known bool) homeView { // with its row, its tail and its pane exactly as they were left // (homeexchange.go). exchanges: a.exchanges, - // AND WHAT THE NEXT MESSAGE IS ALREADY CARRYING. The tray belongs to the - // person rather than to the screen (attach.go), so a picture attached in - // the conversation is a picture home's box is holding the moment it opens - // — and it is the reason this screen can be "typed into" with nothing - // typed at all ([homeView.carrying]). - carrying: len(a.chips) > 0, } } @@ -2977,7 +2967,7 @@ func (a *app) homeKey(msg tea.KeyPressMsg) tea.Cmd { // run left standing here is never spent into the draft behind this // screen (dropkeys.go). Ordinary typing pays two integer comparisons // for it: no clock, no syscall, no extra frame. - return a.dropWatch(&h.box, &a.chips, at, text) + return a.dropWatch(&h.box, &h.chips, at, text) } return nil } @@ -3570,37 +3560,27 @@ func (a *app) homeStartInProject(project string) tea.Cmd { } func (a *app) homeStartWithProject(text, place string) tea.Cmd { - if (strings.TrimSpace(text) != "" || place != "" || len(a.chips) > 0) && a.updateStopsTurn() { + if (strings.TrimSpace(text) != "" || place != "" || len(a.home.chips) > 0) && a.updateStopsTurn() { return nil } if !a.canStart() { a.home.say(newUnavailableWord, "") return nil } + if !a.mainComposer().empty() && (a.agent == nil || a.convKey(a.file) == "") { + a.home.say(startDraftUnownedWord, "") + return nil + } if place != "" { - // THE TRAY GOES WITH THE PERSON HERE TOO, and carrying it means taking - // it OUT of the conversation being stepped aside from before the aside - // is stowed. [app.detachConversation] hands the draft and the chips to - // the aside together, which is right for a switch — both belong to the - // conversation being left — and wrong for this one: these files were - // dropped on HOME, for the conversation home is about to open, and - // leaving them behind is the surface losing something somebody dropped. - // It is the law [app.renew] already applies on the other branch of this - // same door (`a.chips = side.chips`). - // - // THE DRAFT IS A DIFFERENT MATTER AND IS LEFT ALONE. The stepped-aside - // conversation's own unsent sentence is its own and comes back with it; - // home's box is not that sentence, and what was typed in it was a PLACE, - // which this branch has just spent. - carried := a.chips - a.chips = nil + // Home's cargo is handed over only after the old conversation is kept. + carried := a.home.chips cmd, refusal := a.startBeside(place) - a.chips = carried if refusal != "" { a.home.say(refusal, "") return nil } a.closeHome() + a.chips = carried // AND THE PINNED MODEL COMES WITH IT. A path typed into the box is still // a conversation started from home, and the rule above the box said what // it would answer on (homedraft.go). @@ -3652,38 +3632,38 @@ func (a *app) homeStartWithProject(text, place string) tea.Cmd { // TWO ROADS, AND WHICH ONE IS THE TARGET'S OWN ANSWER. A target somewhere other // than this window's workspace is [app.startBeside] — a fresh conversation // THERE, with the one in front stepped aside into the keeper. The window's own -// workspace is [app.renew], which additionally TAKES THE PLACE of the -// conversation behind home when that one is fresh and empty, and that is the -// behaviour a person has had since before home had a target. +// workspace is [app.renew], which replaces a fresh conversation only when it +// has no draft to keep. Home's composer replaces anything /new carried forward. // // THE FOLDER PIN IS SPENT HERE AND THE MODEL PIN IS NOT (homedraft.go's owner // ruling). It is spent on the way OUT rather than on the way in, so a door that // refused leaves the pin a person set exactly where they set it. func (a *app) homeOpenAtTarget() (tea.Cmd, bool) { + // A draft with no conversation identity cannot be put in the keeper. + if !a.mainComposer().empty() && (a.agent == nil || a.convKey(a.file) == "") { + a.home.say(startDraftUnownedWord, "") + return nil, false + } + carried := a.home.chips where := strings.TrimSpace(a.targetWhere()) if where != "" && where != strings.TrimSpace(a.workspace) { - // THE TRAY GOES WITH THE PERSON, and carrying it means taking it OUT of - // the conversation being stepped aside from before the aside is stowed — - // the law the typed-path branch above states in full. - carried := a.chips - a.chips = nil cmd, refusal := a.startBeside(where) - a.chips = carried if refusal != "" { a.home.say(refusal, "") return nil, false } a.closeHome() + a.putComposer(composerState{chips: carried}) return tea.Batch(cmd, a.applyTargetPins()), true } - a.closeHome() - // THE TRAY COMES TOO, and it comes through [app.renew] rather than around it: - // the conversation being left hands its chips to the aside and the renew hands - // them back, on the law that the draft goes with the PERSON (detach.go). - renewed, started := a.renew() + // Keep Home visible until creation succeeds, so a refusal keeps both drafts. + renewed, started := a.renewRefusing(func(word string) { a.home.say(word, "") }) if !started { return nil, false } + a.closeHome() + // /new carries the old draft by design. Home starts its own message instead. + a.putComposer(composerState{chips: carried}) return tea.Batch(renewed, a.applyTargetPins()), true } @@ -3733,16 +3713,16 @@ func (a *app) homeDroppedLine(line string) bool { // `/image ` followed by a dropped file is somebody using the command exactly // as documented — and a dropped path puts its own `/` at the front of this // box. Taking it out first is what tells the two apart. - held := len(a.chips) + held := len(h.chips) h.box.reset() - took := a.droppedLineInto(&h.box, &a.chips, line) - if len(a.chips) == held { + took := a.droppedLineInto(&h.box, &h.chips, line) + if len(h.chips) == held { // Nothing was taken — a folder, a file over the ceiling, a name that is // not on this machine — and every one of those has already said so. The // words go back exactly where they were typed. h.box.setText(line) } - h.carrying = len(a.chips) > 0 + h.carrying = len(h.chips) > 0 h.build() a.touch() return took @@ -4381,7 +4361,7 @@ func (a *app) homeFrame(width, height int) ([]string, []int, int, int) { // THE TRAY IS READ WHERE THE FRAME IS BUILT, so what this screen believes it // is holding and what the row above the box draws can never disagree // ([homeView.carrying]). It is a length and a comparison. - a.home.carrying = len(a.chips) > 0 + a.home.carrying = len(a.home.chips) > 0 // HOME IS A PLACE, SO IT PAINTS FROM THE PLACE LADDER (styles.go's // [palette.onPlaces] — the conversation's inks, with the three roles THE // ONE-ACCENT LAW retires re-pointed). The swap is made here as well as in diff --git a/internal/tui3/homeat.go b/internal/tui3/homeat.go index 1e0e9679b6..e94276c7b3 100644 --- a/internal/tui3/homeat.go +++ b/internal/tui3/homeat.go @@ -145,7 +145,7 @@ func (a *app) homeCompleteFile(line homeLine) tea.Cmd { if a.attach(full) { h.say(folderAttachedWord+filepath.Base(path)+homeRidesWord, "") } - h.carrying = len(a.chips) > 0 + h.carrying = len(a.home.chips) > 0 c.done = "" c.close() h.build() diff --git a/internal/tui3/homeat_test.go b/internal/tui3/homeat_test.go index 5f0288781b..4c5ac779dc 100644 --- a/internal/tui3/homeat_test.go +++ b/internal/tui3/homeat_test.go @@ -80,8 +80,8 @@ func TestAPictureChosenFromTheListGoesOnHomesTray(t *testing.T) { if got := h.box.String(); got != "" { t.Fatalf("the token was left in the box: %q", got) } - if len(a.chips) != 1 || a.chips[0].path != filepath.Join(root, "shot.png") { - t.Fatalf("the picture did not reach the tray: %+v", a.chips) + if len(a.home.chips) != 1 || a.home.chips[0].path != filepath.Join(root, "shot.png") { + t.Fatalf("the picture did not reach the tray: %+v", a.home.chips) } if !h.carrying { t.Fatal("home is not carrying the tray") diff --git a/internal/tui3/homeexchange.go b/internal/tui3/homeexchange.go index db8fdb5bb9..e56b9dd2c0 100644 --- a/internal/tui3/homeexchange.go +++ b/internal/tui3/homeexchange.go @@ -1023,7 +1023,7 @@ func (a *app) askHereWith(text string, orders ErrandOrders) tea.Cmd { // rather than being copied: two trays holding one file would be two answers // to what the next message carries, which is the law attach.go states about // there being one tray and not two. - ex.chips, a.chips = a.chips, nil + ex.chips, a.home.chips = a.home.chips, nil a.home.carrying = false text = errandSentence(text, ex.chips) ex.rows = append(ex.rows, exchangeRow{kind: exchangeSaid, text: text}) diff --git a/internal/tui3/homefate_test.go b/internal/tui3/homefate_test.go index 1f09bf5d82..14b6c1a97a 100644 --- a/internal/tui3/homefate_test.go +++ b/internal/tui3/homefate_test.go @@ -327,8 +327,8 @@ func TestAttachAtHomeLandsOnHomesTrayAndSaysSo(t *testing.T) { if !a.at(pageHome) { t.Fatal("/attach at home opened a conversation") } - if len(a.chips) != 1 || a.chips[0].name() != "notes.md" { - t.Fatalf("the file did not reach home's tray: %+v", a.chips) + if len(a.home.chips) != 1 || a.home.chips[0].name() != "notes.md" { + t.Fatalf("the file did not reach home's tray: %+v", a.home.chips) } if want := folderAttachedWord + "notes.md" + homeRidesWord; a.home.msg != want { t.Fatalf("home said %q, want %q", a.home.msg, want) @@ -343,8 +343,8 @@ func TestAttachAtHomeLandsOnHomesTrayAndSaysSo(t *testing.T) { if !a.at(pageHome) { t.Fatal("/attach at home opened a conversation") } - if len(a.chips) != 2 || a.chips[1].name() != "shot.png" { - t.Fatalf("the picture did not reach home's tray: %+v", a.chips) + if len(a.home.chips) != 2 || a.home.chips[1].name() != "shot.png" { + t.Fatalf("the picture did not reach home's tray: %+v", a.home.chips) } if want := folderAttachedWord + "shot.png" + homeRidesWord; a.home.msg != want { t.Fatalf("home said %q, want %q", a.home.msg, want) @@ -381,8 +381,8 @@ func TestAttachAFolderAtHomePinsTheTarget(t *testing.T) { if a.target.where != inner { t.Fatalf("a folder after /attach pinned %q, want %q", a.target.where, inner) } - if len(a.chips) != 0 { - t.Fatalf("a folder reached the tray: %+v", a.chips) + if len(a.home.chips) != 0 { + t.Fatalf("a folder reached the tray: %+v", a.home.chips) } if a.home.msg != "" { t.Fatalf("the project selection added a footer message: %q", a.home.msg) @@ -399,6 +399,8 @@ func TestNewAtHomeSaysItWasTheConversationBehindHome(t *testing.T) { lab := newHomeLab(t) mine := lab.session("-tmp-alpha", "aaaa000000000001", "porting the resume picker", "/tmp/alpha", time.Now()) a := lab.app(mine) + a.input.setText("old unsent prompt") + a.chips = []chip{{path: "old.png"}} runCmd(a.openHome()) next := &fakeAgent{model: "m"} @@ -408,6 +410,9 @@ func TestNewAtHomeSaysItWasTheConversationBehindHome(t *testing.T) { typeHome(a, "/new") runCmd(a.key(key("enter"))) + if a.input.String() != "" || len(a.chips) != 0 { + t.Fatal("/new on Home carried the old conversation draft") + } if !a.at(pageHome) { t.Fatal("/new at home left the screen") } diff --git a/internal/tui3/homeslash.go b/internal/tui3/homeslash.go index 597678a93e..5384592653 100644 --- a/internal/tui3/homeslash.go +++ b/internal/tui3/homeslash.go @@ -323,8 +323,14 @@ func (a *app) homeSlash(line string) tea.Cmd { // the conversation `enter` is going to open. So the line under the box // says which conversation actually changed, and the refusals travel here // through the seam [app.renewRefusing] exists for. + if !a.mainComposer().empty() && (a.agent == nil || a.convKey(a.file) == "") { + h.say(startDraftUnownedWord, "") + return nil + } renewed, started := a.renewRefusing(func(text string) { h.say(text, "") }) if started { + // The old conversation keeps its draft even when /new is asked on Home. + a.putComposer(composerState{}) h.say(homeFreshBehindWord, "") } return renewed @@ -393,17 +399,17 @@ func (a *app) homeTrayCommand(word, rest string) tea.Cmd { } } } - held := len(a.chips) + held := len(a.home.chips) a.echoHome = true cmd := a.slash("/" + word + " " + rest) a.echoHome = false // A REFUSAL — no such file, not a picture, over the ceiling, already on the // tray — has already put its own sentence on this line through the echo, and // it is the truer one. - if len(a.chips) > held { - a.home.say(folderAttachedWord+a.chips[len(a.chips)-1].name()+homeRidesWord, "") + if len(a.home.chips) > held { + a.home.say(folderAttachedWord+a.home.chips[len(a.home.chips)-1].name()+homeRidesWord, "") } - a.home.carrying = len(a.chips) > 0 + a.home.carrying = len(a.home.chips) > 0 a.home.build() a.touch() return cmd diff --git a/internal/tui3/imagepaste.go b/internal/tui3/imagepaste.go index e84f255c91..bb12457b23 100644 --- a/internal/tui3/imagepaste.go +++ b/internal/tui3/imagepaste.go @@ -172,10 +172,8 @@ func (a *app) keyboardBox() (*editor, *[]chip) { // conversation with its own next message (homeexchange.go). return &ex.box, &ex.chips } - // HOME'S TRAY IS THE CONVERSATION'S TRAY, because what home's box starts - // IS a conversation: [app.renew] hands the chips to the one it opens, on - // the law that the draft goes with the person (detach.go). - return &a.home.box, &a.chips + // Home starts its own message. The conversation behind it keeps its tray. + return &a.home.box, &a.home.chips } return &a.input, &a.chips } @@ -194,7 +192,7 @@ func (a *app) dropLanded(box *editor) { if box != &a.home.box { return } - a.home.carrying = len(a.chips) > 0 + a.home.carrying = len(a.home.chips) > 0 a.home.build() } diff --git a/internal/tui3/placebodies.go b/internal/tui3/placebodies.go index cc1df907cf..e354680b85 100644 --- a/internal/tui3/placebodies.go +++ b/internal/tui3/placebodies.go @@ -134,7 +134,7 @@ func (a *app) placeTray(width int) []string { if !a.at(pageHome) || a.home.phone { return nil } - chips := a.chips + chips := a.home.chips if ex := a.paneExchange(); ex != nil && ex.focused { chips = ex.chips } From 269c83f0f6963a07925dc3fa432951663d257d1a Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 28 Sep 2026 13:37:02 -0400 Subject: [PATCH 6/6] manual: home's tray no longer survives a trip away from home MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1431 gave home a tray of its own that is emptied every time home closes, and left the conversation's draft and attachments with the conversation. home.md still said the tray "survives walking into a conversation and back out to home" and that a chip comes off "where the `x` is", and attaching-files.md still justified carrying pictures into the new conversation by the tray belonging to the person rather than the screen. Both now say what the code does: home's tray belongs to the conversation home starts next, nothing from another conversation's draft rides along, home opens clean on every visit, and the remove mark is `×` (`x` in ASCII mode). Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/manual/chat/attaching-files.md | 2 +- internal/manual/chat/home.md | 13 ++++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/internal/manual/chat/attaching-files.md b/internal/manual/chat/attaching-files.md index 8a0c97b5ab..c62c2edb86 100644 --- a/internal/manual/chat/attaching-files.md +++ b/internal/manual/chat/attaching-files.md @@ -197,7 +197,7 @@ at all. **The tray lights `start` even with nothing typed.** A file on the tray *is* a message, so `enter` starts a new conversation and sends it — and the pictures go with you into that -conversation, because the tray belongs to you rather than to the screen. The same is true +conversation, because home's tray is that conversation's first message. The same is true of `ctrl+enter`: an errand carries what was dropped into it. **The list underneath keeps working.** Home's box is a search over every project on the diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index d1fdeb7e22..6eff1ae854 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -1374,11 +1374,14 @@ lands on home's tray. Choosing `/attach` on the `/` list with `enter` opens it a conversation's folder — the same decision `/project` makes — and updates the project path at the right end of the keys row. -**The tray belongs to you, not to a conversation.** It survives walking into a conversation -and back out to home, and the chips you put on it here are the chips the next conversation -starts with. Home's tray is a reading and not a target: a chip comes off on the row above a -conversation's own box, where the `x` is. At phone width home draws no tray row at all; the -files are still there, and the conversation you open shows them. +**Home's tray belongs to the conversation home starts next.** The chips you put on it here +are the chips that conversation starts with, and nothing else rides along: an unsent draft +or attachment in another conversation stays there and comes back when you reopen it. Leave +home without starting anything and its tray is emptied — home opens clean on every visit. +Home's tray is a reading and not a target: a chip comes off on the row above a +conversation's own box, at its remove mark (`×`, or `x` in ASCII mode). At phone width +home draws no tray row at all; the files are still there, and the conversation you start +from home carries them. ## Change the model before starting — /model on home, the seam above the box