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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/changes/unreleased/1431-attachment-home.md
Original file line number Diff line number Diff line change
@@ -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."
---
9 changes: 9 additions & 0 deletions docs/design/icons/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
16 changes: 11 additions & 5 deletions internal/manual/chat/attaching-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
13 changes: 8 additions & 5 deletions internal/manual/chat/home.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 6 additions & 3 deletions internal/manual/chat/keys.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions internal/tui2/tokens/glyph.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "▾"
Expand Down Expand Up @@ -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},
Expand Down
2 changes: 2 additions & 0 deletions internal/tui2/tokens/glyphset.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions internal/tui2/tokens/glyphset_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}

Expand Down
6 changes: 6 additions & 0 deletions internal/tui2/tokens/nerdfont.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions internal/tui2/tokens/testdata/nerdfont_glyphnames.json
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,9 @@
"nf-fa-terminal": {
"code": "f120"
},
"nf-fa-times_circle": {
"code": "f057"
},
"nf-fa-times_circle_o": {
"code": "f05c"
},
Expand Down
17 changes: 14 additions & 3 deletions internal/tui3/attach.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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.
Expand All @@ -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 ""
}
Expand Down Expand Up @@ -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
}
Expand Down
5 changes: 3 additions & 2 deletions internal/tui3/attach_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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})
Expand Down
142 changes: 142 additions & 0 deletions internal/tui3/attachment_home_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
}
10 changes: 5 additions & 5 deletions internal/tui3/chatstart.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) != ""
Expand Down
Loading
Loading