diff --git a/docs/changes/unreleased/1431-attachment-home.md b/docs/changes/unreleased/1431-attachment-home.md new file mode 100644 index 0000000000..2984d17678 --- /dev/null +++ b/docs/changes/unreleased/1431-attachment-home.md @@ -0,0 +1,9 @@ +--- +kind: fixed +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. 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/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 7320478ebf..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 @@ -458,9 +458,14 @@ 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. -**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 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 them — the file arrived with no usable name, or with a name that was really a path: @@ -509,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/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 diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index 5d5c72e591..749c72214d 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -1400,12 +1400,15 @@ 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 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/tui2/tokens/glyph.go b/internal/tui2/tokens/glyph.go index 2d755e4bf9..2c1d9b7b0d 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 = "▾" @@ -442,6 +445,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.go b/internal/tui2/tokens/glyphset.go index 83f88bf13e..94e8fce497 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 // GPinned marks a crew seat a person pinned, which the router does not move // (internal/tui3's crew.go). GPinned 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 cbe2081298..a2ca4ab5b7 100644 --- a/internal/tui2/tokens/nerdfont.go +++ b/internal/tui2/tokens/nerdfont.go @@ -69,6 +69,12 @@ 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", + // Multiplication signs in prose must remain text; the tray resolves this slot explicitly. + 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/tui2/tokens/testdata/nerdfont_glyphnames.json b/internal/tui2/tokens/testdata/nerdfont_glyphnames.json index 946a052867..b82a3dd6dc 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" }, diff --git a/internal/tui3/attach.go b/internal/tui3/attach.go index e04f8a4dd2..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() @@ -598,6 +599,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. @@ -619,7 +630,7 @@ func (a *app) chipStrip(width int) string { // one place on the surface those are kept. places := a.placeTrayCells() skills := a.skillTrayCells() - labels := chipLabels(a.chips, a.pal) + labels := removableChipLabels(a.chips, a.pal) if len(cells) == 0 && len(places) == 0 && len(skills) == 0 && len(labels) == 0 { return "" } @@ -821,7 +832,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 2834c70500..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 @@ -195,7 +196,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..ac5cb03925 --- /dev/null +++ b/internal/tui3/attachment_home_test.go @@ -0,0 +1,142 @@ +package tui3 + +import ( + "errors" + "fmt" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" +) + +func TestReturningHomeKeepsUnsentConversationAttachments(t *testing.T) { + for _, door := range []string{"double space", "home", "other place"} { + t.Run(door, func(t *testing.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.input.cursor = 4 + a.parks = []parked{{text: "already queued", chips: []chip{{path: "queued.png"}}}} + wantDraft, wantCursor := a.input.String(), a.input.cursor + switch door { + case "double space": + a.input.setText("") + wantDraft, wantCursor = "", 0 + drive(t, a, key(" "), key(" ")) + case "home": + a.openHome() + case "other place": + a.showPage(pageTasks) + a.openHome() + } + if !a.at(pageHome) || len(a.home.chips) != 0 || a.home.box.String() != "" || a.home.carrying { + t.Fatal("Home inherited a conversation draft") + } + 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.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") + 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/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 1a14c97772..9f6292d901 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 @@ -1303,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, } } @@ -2971,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 } @@ -3564,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). @@ -3646,9 +3632,8 @@ 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 @@ -3661,30 +3646,31 @@ func (a *app) homeOpenAtTarget() (tea.Cmd, bool) { // Consuming a slash draft rebuilds Home's rows and can move its cursor to a // different project; that new selection must not redirect the submitted work. func (a *app) homeOpenAt(target string) (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(target) 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 } @@ -3734,16 +3720,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 @@ -4382,7 +4368,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 b997d0369b..87b5ebb660 100644 --- a/internal/tui3/homeslash.go +++ b/internal/tui3/homeslash.go @@ -326,8 +326,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 @@ -396,17 +402,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/hoverpressable_test.go b/internal/tui3/hoverpressable_test.go index 89725518da..8831eb9426 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)) 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 }