From c30ef72fe4be0b529c12c4c1e9b758d147e42cd6 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 22 Sep 2026 14:27:29 -0400 Subject: [PATCH 1/4] tui3: a waiting message stays with its conversation when you leave, and still goes when the answer ends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since #1071 esc opens Home, and Home stands the cursor on the conversation before this one, so esc then enter is a switch. A switch folded every message parked above the box into the draft (leaving.go's quitting assembly), dropped the pictures parked with it, and left nothing to send when the answer ended: the person came back to their follow-up sitting in the box as unsent text without its picture, and the turn's close sent nothing. Santosh read that as "esc cancels the running one and never sends the waiting message". The parked queue now travels on the aside as itself — words, pictures, pasted documents, standing mark, order — and comes back as the same waiting block. When the turn ends while the conversation is held behind the screen, its watcher's landing edge sends the oldest parked message through the HELD agent (the same submit, picture, standing and remote-file doors a front send uses, factored into parkedStart) and adopts the returned stream so the next close is the next edge. Session follow-ups still go first: Attach is asked before a parked send and a running answer is adopted instead. A turn already idle at the stow sends at once; a failed send stays parked with its attachments and notes the failure in its own conversation; a crossing send is marked so coming back mid-send cannot send it twice; the quiet sweep keeps a conversation with anything waiting. The shared-handle door still folds the words into the box, because its engine ends the conversation on the swap, but now brings the parked pictures and pastes back to the tray with them. Quitting still writes the words into the draft file as before. Co-Authored-By: Claude Fable 5.1 --- internal/tui3/app.go | 46 +++-- internal/tui3/attach.go | 83 ++++---- internal/tui3/attach_test.go | 5 +- internal/tui3/bargein.go | 13 +- internal/tui3/bargein_test.go | 6 +- internal/tui3/detach.go | 72 +++++-- internal/tui3/draftkeep.go | 7 +- internal/tui3/followup.go | 2 +- internal/tui3/keeper.go | 315 +++++++++++++++++++++++++++--- internal/tui3/keptsweep_test.go | 10 + internal/tui3/leaving.go | 17 +- internal/tui3/park.go | 42 +++- internal/tui3/parkkeep_test.go | 290 +++++++++++++++++++++++++++ internal/tui3/recipient.go | 7 +- internal/tui3/sharedagent_test.go | 38 ++++ internal/tui3/spellout.go | 6 +- internal/tui3/standmark.go | 9 +- internal/tui3/steer.go | 2 +- internal/tui3/switch_test.go | 38 +++- internal/tui3/tui3_test.go | 9 +- 20 files changed, 880 insertions(+), 137 deletions(-) create mode 100644 internal/tui3/parkkeep_test.go diff --git a/internal/tui3/app.go b/internal/tui3/app.go index 6bfa1e9356..a935d2855f 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -1815,9 +1815,13 @@ type app struct { follows []queued // parks are the messages typed with plain enter while an answer was still // coming: held HERE rather than handed to the session, so they can still be - // edited, taken back, or sent early with esc (park.go). Each one goes as an - // ordinary turn of its own, oldest first, one per finished turn. + // edited, taken back, or steered into the running turn (park.go). Each one + // goes as an ordinary turn of its own, oldest first, one per finished turn. parks []parked + // parkSending closes the one race between a background parked send and the + // person bringing that conversation forward. While it is true, a stream + // close cannot send the same head of the queue a second time (keeper.go). + parkSending bool // wakeLane is the standing subscription to turns the session started ON ITS // OWN, and wakeGen the generation it belongs to. It is a lane of STREAMS // rather than of events (followup.go's wake lane), and its generation is the @@ -3558,6 +3562,12 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { // the agent it already has a pointer to (keeper.go). return a, a.behindStir(msg) + case behindParkedMsg: + // One held conversation tried to start the message waiting behind its + // finished answer. The key, not the conversation in front, decides where + // the result and the returned stream belong (keeper.go). + return a, a.tookBehindParked(msg) + case startRecentsMsg: // This directory's earlier conversations, read off the loop for the // new-chat start page (chatstart.go). It never touches the box. @@ -5217,7 +5227,7 @@ func (a *app) applyEvent(ev session.Event, lump bool) tea.Cmd { // has to be waited on afterwards. var after tea.Cmd // THE STOP IS THE LAST THING THAT TURN WRITES ON THIS SCREEN. Between a - // person's esc and the stream closing the engine is still winding the turn + // person's ctrl+c and the stream closing the engine is still winding the turn // down ([app.windingDown]) and still speaking: the tail of a reply the // provider had already buffered, a call the model was half-way through // spelling out, a nudge about a request nobody is waiting for any more. Every @@ -6081,12 +6091,20 @@ func (a *app) setTitleEvent(title, _ string) { // a lock and possibly a provider, and the Update loop is not a place to wait. func (a *app) submit(text string) tea.Cmd { agent, ctx := a.agent, a.ctx - return a.submitting(text, func() (<-chan session.Event, error) { return agent.Submit(ctx, text) }) + return a.submitting(text, submitStart(agent, ctx, text)) } func (a *app) submitShown(text, shown string) tea.Cmd { agent, ctx := a.agent, a.ctx - return a.submittingShown(text, shown, func() (<-chan session.Event, error) { return agent.Submit(ctx, text) }) + return a.submittingShown(text, shown, submitStart(agent, ctx, text)) +} + +// submitStart is the one plain-message engine call used by both front and held +// sends. Keeping the call in one place means the two roads cannot drift in what +// counts as an accepted turn, and keeps the update-loop door budget from growing +// merely because a conversation can now send while it is behind the screen. +func submitStart(agent Agent, ctx context.Context, text string) func() (<-chan session.Event, error) { + return func() (<-chan session.Event, error) { return agent.Submit(ctx, text) } } // submitting is that body with the CALL left to the caller: everything a @@ -7524,7 +7542,9 @@ func (a *app) renewRefusing(say func(string)) (tea.Cmd, bool) { // own repair: a /new that failed used to leave the surface holding a closed // session with nothing to fall back on, and now a refusal costs nothing at // all. + leavingConv := a.front() leaving, side := a.agent, a.detachConversation() + var stowed tea.Cmd if replacing { // AND NOT ON A SHARED HANDLE, for [app.openSession]'s reason: that agent // is the same object the door just handed back, now naming the session @@ -7540,7 +7560,7 @@ func (a *app) renewRefusing(say func(string)) (tea.Cmd, bool) { // AND THE CONVERSATION GOES ON RUNNING, in the keeper (keeper.go). Its // draft file is written there; the sentence in the box goes with the // PERSON, which is what this door has always promised. - a.stow(a.front(), side) + stowed = a.stow(leavingConv, side) } if !whole { // The older seam hands back an agent alone, so the surface keeps every @@ -7551,14 +7571,16 @@ func (a *app) renewRefusing(say func(string)) (tea.Cmd, bool) { RecentSessions: a.recentSessions, SaveApproval: a.saveApproval, SaveBashApproval: a.saveBashApproval, ApplyApprovals: a.applyApprovals} } - cmd := a.attachConversation(conv, nil) + cmd := tea.Batch(stowed, a.attachConversation(conv, nil)) a.resumed = false // THE DRAFT GOES WITH THE PERSON AND NOT WITH THE CONVERSATION, which is what // this door has always promised in those words: /new starts something else, - // and the sentence in the box is the person's NEXT one. The messages that - // were parked behind a turn come with it, in the order they would have been - // sent — nobody is left to send them, and they are still what somebody typed - // (park.go, leaving.go's [app.leavingDraft]). + // and the sentence in the box is the person's NEXT one. + // + // PARKED MESSAGES DO NOT FOLLOW THE PERSON. When /new keeps the conversation + // they were typed in, its watcher keeps them with the answer they follow and + // sends them there when it ends (keeper.go). Only a shared handle folds them + // into [aside.draft], because its old conversation no longer exists. if side.draft != "" { a.input.setText(side.draft) } @@ -7878,7 +7900,7 @@ func (a *app) interruptTurn() { const stopGrace = 10 * time.Second // windingDown reports that the turn on screen was STOPPED BY HAND and its stream -// has not closed yet: the seconds between a person's esc and the engine letting +// has not closed yet: the seconds between a person's ctrl+c and the engine letting // go of the turn. // // IT IS A REAL WINDOW, THOUGH IT IS NO LONGER A LONG ONE FOR ORDINARY WORK. diff --git a/internal/tui3/attach.go b/internal/tui3/attach.go index aad3be7e66..1a2ef9fa03 100644 --- a/internal/tui3/attach.go +++ b/internal/tui3/attach.go @@ -862,32 +862,8 @@ func (a *app) submitImagesShown(text, shown string) tea.Cmd { agent, ctx := a.agent, a.ctx chips := append([]chip(nil), a.chips...) a.chips, a.sent = nil, chips - pictures, files := pictureChips(chips), fileChips(chips) - // EVERY PICTURE IS NAMED IN THE WORDS THAT GO WITH IT. A pasted one already - // carries its `[image #n]` where the person put it; one attached by /image or - // the @ completion has none, and gets its token appended here so that "image - // 2" means something whichever door the picture came in by (imagepaste.go). - // The transcript is drawn from the same string, so what the person reads and - // what the model reads are one sentence. - text = imageSentence(text, pictures) - shown = imageSentence(shown, pictures) - - // AND WHAT THE MODEL IS TOLD ABOUT A FILE IS A PATH, which is a sentence - // this surface writes only where the file is not going anywhere. On a local - // session the path already means something to the engine, so the words are - // composed here; over a connection the bytes travel and the ENGINE composes - // the same sentence about the paths it wrote them to, because those are the - // only paths that exist on the machine that owns the journal - // (internal/remote's file.go, whose [remote.AttachedSentence] both ends call - // so that a model never meets two phrasings of one fact). - // - // The transcript keeps the person's own line either way — the paths go to - // the model and the NAMES go on the screen ([chipMarkers]), because a - // scrollback full of absolute paths is a scrollback nobody reads. - hosted, spoken := a.hosted(), text - if len(files) > 0 && !hosted { - spoken = remote.AttachedSentence(text, chipPaths(files)) - } + _, shown, start := attachmentStart(agent, ctx, a.hosted(), text, shown, chips) + pictures := pictureChips(chips) if a.stream == nil { a.turn++ @@ -925,9 +901,49 @@ func (a *app) submitImagesShown(text, shown string) tea.Cmd { a.follow() a.touch() return tea.Batch(func() tea.Msg { + ch, err := start() + return submittedMsg{ch: ch, err: err, echo: mark} + }, a.wake()) +} + +// attachmentStart is the one body that starts a message carrying pictures or +// files, whether the conversation is on screen or held by the keeper. +// +// THE DISK AND WIRE WORK STAYS INSIDE THE RETURNED CLOSURE. The front calls it +// from its submit command and a held conversation calls it from its own command; +// neither makes the Bubble Tea update loop read a file or cross a connection. +func attachmentStart(agent Agent, ctx context.Context, hosted bool, text, shown string, chips []chip) (spoken, display string, start func() (<-chan session.Event, error)) { + pictures, files := pictureChips(chips), fileChips(chips) + // EVERY PICTURE IS NAMED IN THE WORDS THAT GO WITH IT. A pasted one already + // carries its `[image #n]` where the person put it; one attached by /image or + // the @ completion has none, and gets its token appended here so that "image + // 2" means something whichever door the picture came in by (imagepaste.go). + // The transcript is drawn from the same string, so what the person reads and + // what the model reads are one sentence. + text = imageSentence(text, pictures) + shown = imageSentence(shown, pictures) + + // AND WHAT THE MODEL IS TOLD ABOUT A FILE IS A PATH, which is a sentence + // this surface writes only where the file is not going anywhere. On a local + // session the path already means something to the engine, so the words are + // composed here; over a connection the bytes travel and the ENGINE composes + // the same sentence about the paths it wrote them to, because those are the + // only paths that exist on the machine that owns the journal + // (internal/remote's file.go, whose [remote.AttachedSentence] both ends call + // so that a model never meets two phrasings of one fact). + // + // The transcript keeps the person's own line either way — the paths go to + // the model and the NAMES go on the screen ([chipMarkers]), because a + // scrollback full of absolute paths is a scrollback nobody reads. + spoken = text + if len(files) > 0 && !hosted { + spoken = remote.AttachedSentence(text, chipPaths(files)) + } + display = shown + start = func() (<-chan session.Event, error) { images, err := readAttachments(pictures) if err != nil { - return submittedMsg{err: err, echo: mark} + return nil, err } // A MESSAGE WITH NO FILES AND A MESSAGE WHOSE FILES ARE ALREADY ON THE // ENGINE'S OWN DISK ARE THE SAME CALL. Locally nothing is copied and @@ -936,8 +952,7 @@ func (a *app) submitImagesShown(text, shown string) tea.Cmd { // words for a picture that arrives with a path and no bytes: a caller // naming a file on the engine's own disk, which the session reads itself. if len(files) == 0 || !hosted { - ch, err := agent.SubmitImage(ctx, spoken, images) - return submittedMsg{ch: ch, err: err, echo: mark} + return agent.SubmitImage(ctx, spoken, images) } // A CAPABILITY THAT CANNOT WORK IS ABSENT, NOT BROKEN. A door that // handed no file seam over is a connection this build cannot put a file @@ -945,15 +960,15 @@ func (a *app) submitImagesShown(text, shown string) tea.Cmd { // still in their hands rather than to send the words without the file. taker, ok := agent.(fileSubmitter) if !ok { - return submittedMsg{err: errors.New(attachRemoteWord), echo: mark} + return nil, errors.New(attachRemoteWord) } loaded, err := readFiles(files) if err != nil { - return submittedMsg{err: err, echo: mark} + return nil, err } - ch, err := taker.SubmitFiles(ctx, spoken, loaded, images) - return submittedMsg{ch: ch, err: err, echo: mark} - }, a.wake()) + return taker.SubmitFiles(ctx, spoken, loaded, images) + } + return spoken, display, start } // fileSubmitter is the optional seam "this session can be handed a file the diff --git a/internal/tui3/attach_test.go b/internal/tui3/attach_test.go index d327909743..80fd085a1c 100644 --- a/internal/tui3/attach_test.go +++ b/internal/tui3/attach_test.go @@ -18,7 +18,10 @@ import ( // SubmitImage answers the seam for every test that never attaches anything: a // message with no pictures is exactly Submit, which is what session.Agent // documents and what the surface relies on. -func (f *fakeAgent) SubmitImage(ctx context.Context, text string, _ []session.Image) (<-chan session.Event, error) { + +func (f *fakeAgent) SubmitImage(ctx context.Context, text string, images []session.Image) (<-chan session.Event, error) { + f.imageText = append(f.imageText, text) + f.images = append(f.images, append([]session.Image(nil), images...)) return f.Submit(ctx, text) } diff --git a/internal/tui3/bargein.go b/internal/tui3/bargein.go index 4a93401374..87a4ee8fe9 100644 --- a/internal/tui3/bargein.go +++ b/internal/tui3/bargein.go @@ -7,14 +7,11 @@ import ( // BARGE-IN: the way a person actually interrupts is by SPEAKING. // // THE GAP THIS FILE CLOSES. Two gestures already exist for a sentence typed -// over a running answer and neither is the one a hand reaches for. Plain enter -// PARKS the message and waits for the turn to end (park.go); esc STOPS the turn -// and, if something is already parked, sends it (input.go's esc case). So the -// person who is watching an answer go the wrong way and types "no — the OTHER -// file" has to press two keys in the right order to be heard now: enter, then -// esc. Everybody discovers the first one and almost nobody discovers that the -// second one is a send, because by the time they have pressed enter their -// sentence is out of the box and the urgency has gone out of the gesture. +// over a running answer and neither says "stop, then send these words". Plain +// enter PARKS the message and waits for the turn to end (park.go); ctrl+c STOPS +// the turn and drops everything waiting. So the person who is watching an +// answer go the wrong way and types "no — the OTHER file" needs one deliberate +// gesture that preserves the correction while ending the answer. // // So this is those two presses as ONE act. The draft is in the box, the answer // is streaming, and one chord means: stop this, and here is what I want instead. diff --git a/internal/tui3/bargein_test.go b/internal/tui3/bargein_test.go index 2d2376a848..c5e8ba4599 100644 --- a/internal/tui3/bargein_test.go +++ b/internal/tui3/bargein_test.go @@ -246,10 +246,10 @@ func TestTheHintTeachesBothMeaningsOnlyWhileThereIsSomethingToSend(t *testing.T) } // THE TWO STOP GESTURES NAME THEIR DIFFERENT QUEUE DECISIONS. ctrl+shift+enter -// preserves the sentence it just parked; esc clears everything waiting. -func TestTheChordSendsWhileEscDrops(t *testing.T) { +// preserves the sentence it just parked; ctrl+c clears everything waiting. +func TestTheChordSendsWhileCtrlCDrops(t *testing.T) { if strings.HasSuffix(parkedHint[1], bargeSendWord) { - t.Fatalf("the parked block claims esc sends: %q", parkedHint[1]) + t.Fatalf("the parked block claims ctrl+c sends: %q", parkedHint[1]) } a, _ := bargeable(t, "reading the tree. ") typeInto(t, a, "no, the other file") diff --git a/internal/tui3/detach.go b/internal/tui3/detach.go index 69b7a66c55..cb0882e8a6 100644 --- a/internal/tui3/detach.go +++ b/internal/tui3/detach.go @@ -46,16 +46,25 @@ import ( type aside struct { // openingPrompt survives a switch while the title and transcript arrive. openingPrompt string - // draft is the unsent sentence with every parked message folded in after it, - // and chips are the pictures attached to it. + // draft is the unsent sentence in the box, and chips are the pictures + // attached to it. parks are the messages waiting for this conversation's + // running answer, kept in their send order with their own attachments. // - // THE PARKS GO INTO THE BOX RATHER THAN BEING DROPPED. [app.dropParked] is - // what /new and /resume do — they say "2 waiting messages dropped" because - // the turn those messages were queued behind is about to stop existing. A - // switch is not a close: the turn is still running and the words are still - // the person's, so they go back where they can see them (leaving.go's - // [app.leavingDraft] assembles exactly this string for the same reason). + // THE PARKS STAY PARKED WHILE THIS PROCESS KEEPS THE CONVERSATION. Folding + // them into the box was correct only for quitting, where the draft file is + // the last place the words can survive. A switch leaves the turn alive, so + // its watcher can send these messages when that turn ends (keeper.go). draft string + parks []parked + // parkSending is the narrow crossing where the oldest parked message has + // left for the held agent but the answer to that submit has not reached the + // surface yet. It follows the sidecar so returning mid-send cannot send the + // same message again when the stream closes. + parkSending bool + // parkNotes are failures from sends attempted while this conversation was + // held. They land in this conversation when it comes forward rather than in + // whichever unrelated conversation happened to be on screen at the time. + parkNotes []string // draftCursor is optional for older sidecars assembled without a caret. draftCursor *int chips []chip @@ -255,15 +264,17 @@ func (a *app) front() Conversation { func (a *app) detachConversation() *aside { main := a.mainComposer() side := &aside{ - // The box and the parked messages, in the order they would have been - // sent (leaving.go's [app.leavingDraft] is the same assembly the door - // out of the program makes, and for the same reason). + // The box and the parked messages are separate while this process can + // keep the conversation alive. The shared-handle exception below folds + // them because its engine ends the conversation during the swap. // // THE THREE ARE READ THROUGH MAIN AND NOT OFF THE SCREEN (recipient.go). // A conversation can be put down while a task's page is in front, and the // box then holds that page's steering line — which is not this // conversation's unsent message and must not come back as one. - draft: a.leavingDraft(), + draft: a.mainDraftText(), + parks: a.parks, + parkSending: a.parkSending, chips: main.chips, pastes: main.pastes, sends: main.sends, @@ -273,8 +284,22 @@ func (a *app) detachConversation() *aside { title: a.title, openingPrompt: a.openingPrompt, } - // Appended parked messages are new text at the end; otherwise a switch - // restores the exact insertion point the person left in the main composer. + // A SHARED HANDLE CANNOT KEEP A WAITING TURN. Its engine interrupts and + // closes the old conversation during the swap, so the only honest fallback + // is quitting's one: put the waiting words back in the box and put every + // attachment back on its tray, after attachments already on the draft. + if a.shared { + side.draft = a.leavingDraft() + side.parks = nil + side.parkSending = false + for _, p := range a.parks { + side.chips = append(side.chips, p.chips...) + side.pastes = append(side.pastes, p.pastes...) + } + } + // Folded parked words are new text at the end only on the shared-handle + // fallback; otherwise every switch restores the exact insertion point the + // person left in the main composer. cursor := main.box.cursor if side.draft != main.box.String() { cursor = len([]rune(side.draft)) @@ -426,6 +451,7 @@ func (a *app) clearConversation() { a.pastes = nil a.forgetComposers() a.parks = nil + a.parkSending = false a.touch() } @@ -695,6 +721,12 @@ func (a *app) restoreAside(side *aside) tea.Cmd { a.chips = side.chips a.pastes = side.pastes a.sends = side.sends + a.parks, side.parks = side.parks, nil + a.parkSending = side.parkSending + for _, note := range side.parkNotes { + a.note(note) + } + side.parkNotes = nil a.offset, a.stick = side.offset, side.stick // THE COUNTDOWN IS HANDED BACK RATHER THAN RESTAMPED, and only to a question // THE ENGINE STILL HOLDS. It is consumed by [app.startAskClock] when the @@ -705,8 +737,16 @@ func (a *app) restoreAside(side *aside) tea.Cmd { if side.askLeft > 0 && a.enginePending() { a.askResume, a.askResumePaused = side.askLeft, side.askPaused } + var parked tea.Cmd + if _, canReport := a.agent.(attachable); !canReport && a.state == stateIdle && len(a.parks) > 0 { + // A scripted or older agent with no Attach door cannot tell the keeper + // when its turn ended. Coming forward is the first reliable idle edge it + // offers, so the oldest waiting message goes through the ordinary front + // door here rather than remaining stranded forever. + parked = a.sendParked() + } if side.room == 0 { - return nil + return parked } // A ROOM IS A PLACE RATHER THAN A MODE, which is why it is the one thing on // this list that is not a reading. It is cheap to reopen from the node id @@ -714,7 +754,7 @@ func (a *app) restoreAside(side *aside) tea.Cmd { // conversation was away opens as its finished page, which is what opening it // from the rail would do anyway (room.go). a.openRoom(side.room, "") - return a.takeRoomPump() + return tea.Batch(parked, a.takeRoomPump()) } // enginePending reports whether the agent is still holding an approval question. diff --git a/internal/tui3/draftkeep.go b/internal/tui3/draftkeep.go index 29257aa40c..0a7b04688d 100644 --- a/internal/tui3/draftkeep.go +++ b/internal/tui3/draftkeep.go @@ -1098,8 +1098,11 @@ func (a *app) stowDrafts(conv Conversation, side *aside) { keepPath: path, textPath: conv.DraftFile, keep: keep, - text: side.draft, - rev: claimDraftRev(path), + // The structured keep holds only the box. The compatibility draft is + // crash insurance for every word the person has typed, so waiting + // messages are folded into that text exactly as quitting folds them. + text: foldedParkedDraft(side.draft, side.parks), + rev: claimDraftRev(path), } if err := save.commit(); err != nil { a.noteDraftKeepFailed(err) diff --git a/internal/tui3/followup.go b/internal/tui3/followup.go index 63bd54cd44..2fdb0c4932 100644 --- a/internal/tui3/followup.go +++ b/internal/tui3/followup.go @@ -32,7 +32,7 @@ import ( // the turn to end. They differ in WHO IS HOLDING IT WHILE IT WAITS, and // everything a person can do about it follows from that: a parked message is // still theirs — it can be edited, taken back with ↑, or sent at once by -// stopping the turn it was typed over (`esc`, or `ctrl+shift+enter` as one gesture, +// stopping the turn it was typed over (`ctrl+shift+enter` as one gesture, // bargein.go) — while a follow-up is in the session with no take-backs, and is // DROPPED when the turn it was queued behind is interrupted, because a drain // never restarts a turn the person stopped ([app.dropFollows]). diff --git a/internal/tui3/keeper.go b/internal/tui3/keeper.go index 9311f23538..44c35f5890 100644 --- a/internal/tui3/keeper.go +++ b/internal/tui3/keeper.go @@ -78,8 +78,9 @@ type kept struct { // through [app.takeUp] on the way in, which is what stops a keystroke in one // conversation reaching a closure minted around another. conv Conversation - // side is what the person left: the box, the pictures on it, where they were - // reading, what was left of an approval countdown, the room they had open + // side is what the person left: the box, the messages waiting behind its + // running turn, their pictures and documents, where they were reading, what + // was left of an approval countdown, and the room they had open // (switcher.go's [aside]). side *aside // watch drains this agent's lanes and turns the two interesting edges into @@ -115,6 +116,28 @@ type behindStirMsg struct { quiet bool } +// behindParkedMsg is the answer to sending one held conversation's oldest +// waiting message off the update loop. The key is the generation law in this +// lane: if the conversation moved, closed or came forward while the call was +// crossing, the fold finds that fact by identity rather than touching whatever +// conversation happens to be in front. +type behindParkedMsg struct { + key string + park parked + shown string + ch <-chan session.Event + err error +} + +// behindTurn is one stream handed to a watcher after that watcher was already +// running. A session follow-up and a parked message both begin between the +// original turn's close and the next pass through the watcher's select, so the +// watcher needs a lane rather than a second goroutine reading beside it. +type behindTurn struct { + events <-chan session.Event + stop func() +} + // behindWatch is one conversation's stir watcher, and it exists for a reason // that must not be deleted by a future lane trying to save memory. // @@ -140,11 +163,12 @@ type behindStirMsg struct { // exists to avoid, and it needs the discard to be correct, which is a // conversation id on every message type. type behindWatch struct { - key string - agent Agent - out chan<- behindStirMsg - quit chan struct{} - once sync.Once + key string + agent Agent + out chan<- behindStirMsg + quit chan struct{} + adopts chan behindTurn + once sync.Once // armed is the "at most one outstanding" rule. A watcher that sent a stir // nobody has folded in yet sends no more of them: the surface reads the // agent when it wakes, so a second nudge to read the same pointer buys @@ -330,9 +354,43 @@ func (w *behindWatch) stop() { }) } +// adopt replaces the turn this watcher is draining. It is the held +// conversation's [app.adoptTurn]: a follow-up the session started itself and a +// parked message the keeper just submitted both become the one stream whose +// close supplies the next landing edge. +func (w *behindWatch) adopt(events <-chan session.Event, stop func()) { + if events == nil { + if stop != nil { + stop() + } + return + } + if w == nil || w.adopts == nil || w.quit == nil { + if stop != nil { + stop() + } + return + } + select { + case <-w.quit: + if stop != nil { + stop() + } + return + default: + } + select { + case w.adopts <- behindTurn{events: events, stop: stop}: + case <-w.quit: + if stop != nil { + stop() + } + } +} + // startBehindWatch subscribes to everything this agent has and drains it. func startBehindWatch(key string, agent Agent, out chan<- behindStirMsg) *behindWatch { - w := &behindWatch{key: key, agent: agent, out: out, quit: make(chan struct{})} + w := &behindWatch{key: key, agent: agent, out: out, quit: make(chan struct{}), adopts: make(chan behindTurn, 1)} go w.run() return w } @@ -413,7 +471,24 @@ func (w *behindWatch) run() { if turnStop != nil { turnStop() } + // A stop can win the select while an adoption is buffered. Give that + // subscription back too; otherwise the held agent retains a reader after + // the conversation has already left this watcher. + select { + case left := <-w.adopts: + if left.stop != nil { + left.stop() + } + default: + } }() + replaceTurn := func(next <-chan session.Event, stop func()) { + if turnStop != nil { + turnStop() + } + turn, turnStop = next, stop + w.turning.Store(next != nil) + } waiting := needsPerson(w.agent) w.waits.Store(waiting) @@ -421,6 +496,12 @@ func (w *behindWatch) run() { select { case <-w.quit: return + case next := <-w.adopts: + // THIS REPLACES RATHER THAN JOINS THE OLD STREAM. The adoption is + // offered only after an Attach says which turn is current or after a + // Submit starts the next one, so keeping both readers would count one + // turn twice and raise two landing edges. + replaceTurn(next.events, next.stop) case ev, ok := <-tasks: if !ok { tasks = nil @@ -471,12 +552,7 @@ func (w *behindWatch) run() { // and draining it is what keeps its pump from parking. A wake while // another turn is still being drained replaces it, which is the // session's own arrangement: a wake is handed over between turns. - if turnStop != nil { - turnStop() - turnStop = nil - } - turn = stream - w.turning.Store(true) + replaceTurn(stream, nil) case _, ok := <-turn: if !ok { turn = nil @@ -572,6 +648,15 @@ func (a *app) behindStir(note behindStirMsg) tea.Cmd { return tea.Batch(next, a.takeOverKept(note.key, held)) } landed := held.watch.took() + var parked tea.Cmd + continued := false + if landed && held.side != nil && len(held.side.parks) > 0 { + // FOLLOW-UPS GO FIRST. The session may have started one as the turn + // ended; Attach is the authoritative answer. The check runs off the + // update loop because this agent may be another process or machine. + parked = a.checkBehindParked(note.key, held.conv.Agent) + continued = parked != nil || held.side.parkSending + } // The count on the status line and home's own rows are both read from the // agent on the frame, so waking the frame is the whole of the refresh. a.touch() @@ -583,9 +668,14 @@ func (a *app) behindStir(note behindStirMsg) tea.Cmd { // home; this is what says it to the person sitting here, who is looking // at a different conversation in the same terminal. banner = a.notifyBehind(held, notifyAskWord) - case landed: + case landed && !continued: banner = a.notifyBehind(held, notifyDoneWord) } + // NO FINISHED BANNER FOR AN ANSWER THAT IMMEDIATELY CONTINUES. The finished + // counter still advances because the tab truthfully reports how many turns + // landed while the person was away, but a banner saying "finished" beside a + // conversation already answering their next message would be stale on the + // frame it appeared. // A CONVERSATION THAT HAS JUST STOPPED WORKING IS THE OTHER MOMENT THE SWEEP // CAN ACT, and without it a window left holding cold conversations only ever // collects one when somebody opens another (this surface has no idle ticker). @@ -594,9 +684,162 @@ func (a *app) behindStir(note behindStirMsg) tea.Cmd { // a turn landed in it, and [app.keptQuiet] refuses both. a.sweepKept() if a.homeAnimating() { - return tea.Batch(next, banner, a.wake()) + return tea.Batch(next, banner, parked, a.wake()) } - return tea.Batch(next, banner) + return tea.Batch(next, banner, parked) +} + +// checkBehindParked asks which turn is current without blocking the update +// loop, then either adopts the session's own continuation or starts the oldest +// parked message through the held agent. +// +// THE FOLD LOOKS THE CONVERSATION UP AGAIN. A person can bring it forward or +// close it while Attach is crossing a connection; using the captured sidecar +// afterwards would send into state this window no longer owns. +func (a *app) checkBehindParked(key string, agent Agent) tea.Cmd { + door, ok := agent.(attachable) + if !ok { + return nil + } + return a.offLoop(func() func(bool) tea.Cmd { + events, running, stop := door.Attach() + return func(bool) tea.Cmd { + held := a.behind[key] + if held == nil || held.side == nil || len(held.side.parks) == 0 { + stop() + // An attachable conversation can still have come forward after + // its idle reading. If it did, this fold is the only remaining + // edge that knows the parked queue is ready to go. + if !running && key == a.convKey(a.file) && len(a.parks) > 0 { + return a.sendParked() + } + return nil + } + if running { + held.watch.adopt(events, stop) + return nil + } + stop() + return a.sendBehindParked(key, held) + } + }) +} + +// sendBehindParked starts one held message and leaves it at the front of the +// queue until the call succeeds. That is what lets a read error or a closed +// agent return the complete message — pictures and paste bodies included — +// instead of turning a failed send into lost input. +func (a *app) sendBehindParked(key string, held *kept) tea.Cmd { + if held == nil || held.side == nil || held.side.parkSending || len(held.side.parks) == 0 { + return nil + } + held.side.parkSending = true + held.side.parks[0].sending = true + p := held.side.parks[0] + _, shown, start := parkedStart(held.conv.Agent, a.ctx, a.hosted(), p) + return func() tea.Msg { + ch, err := start() + return behindParkedMsg{key: key, park: p, shown: shown, ch: ch, err: err} + } +} + +// tookBehindParked folds the off-loop send into whichever place now owns the +// conversation. Success spends the marked head exactly once and hands its +// stream to that conversation's watcher; failure clears only the crossing mark +// and leaves the full message waiting. +func (a *app) tookBehindParked(msg behindParkedMsg) tea.Cmd { + failure := func(err error) string { return "submit failed: " + err.Error() } + if held := a.behind[msg.key]; held != nil && held.side != nil { + held.side.parkSending = false + if len(held.side.parks) > 0 && held.side.parks[0].sending { + if msg.err != nil { + held.side.parks[0].sending = false + held.side.parkNotes = append(held.side.parkNotes, failure(msg.err)) + a.stowDrafts(held.conv, held.side) + return nil + } + held.side.parks = held.side.parks[1:] + } + a.stowDrafts(held.conv, held.side) + held.watch.adopt(msg.ch, nil) + return nil + } + + // The conversation may have come forward while Submit was crossing. The + // sidecar moved its queue and crossing mark onto the app, so identity still + // decides whether this answer belongs here. + if msg.key == a.convKey(a.file) { + a.parkSending = false + if len(a.parks) > 0 && a.parks[0].sending { + if msg.err != nil { + a.parks[0].sending = false + } + if msg.err == nil { + a.parks = a.parks[1:] + } + } + if msg.err != nil { + a.note(failure(msg.err)) + return tea.Batch(a.edited(), a.settle()) + } + // If Attach already found this turn, its atomic replay supplied the user + // line and its stream is the one to keep. Otherwise the send crossed + // after that idle reading, so draw the ordinary line here and adopt the + // channel Submit returned. + if a.stream == nil { + a.drawBehindParked(msg.park, msg.shown) + return a.takeStream(msg.ch) + } + if msg.ch != nil { + // Attach already owns a separate subscription to this turn. Drain + // Submit's original subscription as well: dropping it would leave the + // agent's pump parked on a reader that came forward during the call. + return func() tea.Msg { + for range msg.ch { + } + return nil + } + } + return nil + } + + // The conversation was closed or moved while the call crossed. Nobody owns + // this returned stream now, but it still must be drained so the agent's event + // pump cannot park on a reader that disappeared. + if msg.ch != nil { + return func() tea.Msg { + for range msg.ch { + } + return nil + } + } + return nil +} + +// drawBehindParked is the front half of an already-started background send. It +// is reached only when the conversation came forward before Attach could see +// the new turn; ordinary background completion is drawn later from the journal. +func (a *app) drawBehindParked(p parked, shown string) { + a.turn++ + a.sel = -1 + if a.openingPrompt == "" { + a.openingPrompt = shown + } + line := shown + pictures := chipPaths(pictureChips(p.chips)) + if len(p.chips) > 0 { + line = userLine(shown, p.chips, a.pal) + } + a.said(entry{kind: entryUser, text: line, turn: a.turn, began: a.now(), context: a.turnContext(), pictures: pictures, picturesHere: len(pictures) > 0}) + for _, picture := range pictures { + a.learnPicture(picture, true) + } + a.state = stateWorking + a.lastDelta = time.Now() + a.awaited = time.Now() + a.startClock() + a.follow() + a.touch() } // ── holding a conversation, and taking one back ───────────────────────────── @@ -608,10 +851,10 @@ func (a *app) behindStir(note behindStirMsg) tea.Cmd { // branch where the conversation goes on existing: a machine that loses power // with three conversations open should give all three boxes back, and the // sidecar is memory (draft.go). -func (a *app) stow(conv Conversation, side *aside) { +func (a *app) stow(conv Conversation, side *aside) tea.Cmd { key := a.convKey(conv.SessionFile) if key == "" || conv.Agent == nil { - return + return nil } // A DOOR WHOSE CONVERSATIONS SHARE ONE HANDLE KEEPS NOTHING HERE, and this is // the one guard rather than a branch at each of the four callers — /new, the @@ -644,7 +887,7 @@ func (a *app) stow(conv Conversation, side *aside) { a.stowDrafts(conv, side) // Retain the outgoing navigation identity even though its agent ended. a.rememberOpen(key) - return + return nil } // AND IT IS THE WHOLE COMPOSER, not only the box: every page's own unsent line // goes down under THIS conversation's identity (draftkeep.go's @@ -654,17 +897,27 @@ func (a *app) stow(conv Conversation, side *aside) { if a.behind == nil { a.behind = map[string]*kept{} } - a.behind[key] = &kept{ + held := &kept{ conv: conv, side: side, watch: startBehindWatch(key, conv.Agent, a.stirs), } + a.behind[key] = held a.rememberOpen(key) + var parked tea.Cmd + if side != nil && len(side.parks) > 0 { + // The turn may have ended between the park and the watcher joining. Ask + // once after the watcher exists: an idle answer means there is no future + // close edge to wake it, so the oldest message goes now. The helper keeps + // this potentially remote door off the update loop. + parked = a.checkBehindParked(key, conv.Agent) + } // AND THE KEEPER GIVES BACK WHAT IT CAN, on the one keystroke that grew it. The // conversation just stowed is the youngest thing in there and can never be // what the sweep takes ([keptIdleGrace]), so this collects a conversation // somebody stopped thinking about rather than the one they just left. a.sweepKept() + return parked } // rememberOpen puts a key on top of the previous-stack, which is the order `tab` @@ -758,8 +1011,8 @@ func (a *app) bringForward(file string) (cmd tea.Cmd, owned bool) { delete(a.behind, key) held.watch.stop() leaving, side := a.front(), a.detachConversation() - a.stow(leaving, side) - cmd = a.attachConversation(held.conv, held.side) + parked := a.stow(leaving, side) + cmd = tea.Batch(parked, a.attachConversation(held.conv, held.side)) a.rememberOpen(key) return cmd, true } @@ -829,7 +1082,7 @@ func (a *app) takeBeside(conv Conversation) tea.Cmd { closed = a.place } leaving, side := a.front(), a.detachConversation() - a.stow(leaving, side) + parked := a.stow(leaving, side) if a.shared { // The legacy wire seam returns only an Agent. The local draft store // belongs to this window, with separate owner-scoped slots inside it. @@ -840,7 +1093,7 @@ func (a *app) takeBeside(conv Conversation) tea.Cmd { conv.History = leaving.History } } - cmd := a.attachConversation(conv, nil) + cmd := tea.Batch(parked, a.attachConversation(conv, nil)) if a.shared { a.restoreDraft() } @@ -1299,15 +1552,17 @@ func (a *app) keptQuiet(held *kept, now time.Time) bool { if w.landed.Load() || w.landedSince() > 0 { return false } - // AND THE WORDS IN ITS BOX ARE THE PERSON'S OWN. A draft, a picture on it, a - // document behind a paste tag, a message that has left the box and not - // settled, a line typed at one of its task pages: any of them and this - // conversation is somebody's unfinished sentence rather than a cost. + // AND ALL OF THE PERSON'S UNSENT WORDS ARE THEIR OWN. A draft, a waiting + // message, a picture on either, a document behind a paste tag, a message that + // has left the box and not settled, a line typed at one of its task pages: + // any of them and this conversation is somebody's unfinished sentence rather + // than a cost. Parks used to be folded into draft, so separating them without + // naming them here would make the sweep able to discard the new sidecar state. if held.side == nil { return false } side := held.side - if strings.TrimSpace(side.draft) != "" || len(side.chips) > 0 || len(side.pastes) > 0 || len(side.sends) > 0 || len(side.composers) > 0 { + if strings.TrimSpace(side.draft) != "" || len(side.parks) > 0 || side.parkSending || len(side.chips) > 0 || len(side.pastes) > 0 || len(side.sends) > 0 || len(side.composers) > 0 { return false } // And it has to have been left alone for long enough that letting go of it is diff --git a/internal/tui3/keptsweep_test.go b/internal/tui3/keptsweep_test.go index b62991eb5c..81f0ff4cb0 100644 --- a/internal/tui3/keptsweep_test.go +++ b/internal/tui3/keptsweep_test.go @@ -184,6 +184,16 @@ func TestTheSweepLeavesEveryConversationSomethingWouldBeLostFrom(t *testing.T) { held.side.draft = "and then check the migration" return held }()}, + {name: "a message still waiting for its answer", held: func() *kept { + held := coldKept(&switchAgent{fakeAgent: &fakeAgent{model: "m"}}, "a", cold) + held.side.parks = []parked{{text: "and then check the migration"}} + return held + }()}, + {name: "a waiting message whose submit is crossing", held: func() *kept { + held := coldKept(&switchAgent{fakeAgent: &fakeAgent{model: "m"}}, "a", cold) + held.side.parkSending = true + return held + }()}, {name: "a picture still on its box", held: func() *kept { held := coldKept(&switchAgent{fakeAgent: &fakeAgent{model: "m"}}, "a", cold) held.side.chips = []chip{{}} diff --git a/internal/tui3/leaving.go b/internal/tui3/leaving.go index 80b0792b5f..e08b255cdb 100644 --- a/internal/tui3/leaving.go +++ b/internal/tui3/leaving.go @@ -58,15 +58,24 @@ import ( // steering line as the conversation's unsent sentence and give it back at the // next launch in a box pointed at the model. func (a *app) leavingDraft() string { - text := a.mainDraftText() - if len(a.parks) == 0 { + return foldedParkedDraft(a.mainDraftText(), a.parks) +} + +// foldedParkedDraft is quitting's text-only insurance assembled from either +// the conversation in front or a held conversation's sidecar. +// +// STRUCTURED PARKS NEVER GO ON DISK. Pictures, paste bodies and standing marks +// remain process memory while a conversation is held; the plain draft file is +// the last-resort record that can promise only that nobody's typed words vanish. +func foldedParkedDraft(text string, parks []parked) string { + if len(parks) == 0 { return text } - lines := make([]string, 0, len(a.parks)+1) + lines := make([]string, 0, len(parks)+1) if strings.TrimSpace(text) != "" { lines = append(lines, text) } - for _, p := range a.parks { + for _, p := range parks { if strings.TrimSpace(p.text) != "" { lines = append(lines, p.text) } diff --git a/internal/tui3/park.go b/internal/tui3/park.go index eba629b129..14bd028d9c 100644 --- a/internal/tui3/park.go +++ b/internal/tui3/park.go @@ -1,10 +1,13 @@ package tui3 import ( + "context" "strings" tea "charm.land/bubbletea/v2" "github.com/charmbracelet/x/ansi" + + "github.com/Agent-Field/codeaf/internal/session" ) // THE PARKED MESSAGE: what plain enter does while an answer is still coming. @@ -23,10 +26,10 @@ import ( // and it goes when the answer is finished — as a turn of its own, which is a // turn the model always answers. Held on the surface rather than handed to the // session is what makes the other things possible: it can still be edited or -// taken back, and esc can drop it together with everything else waiting. +// taken back, and a conversation switch can keep it with the turn it follows. // // enter park it. The answer keeps streaming, the box is clear again. -// esc stop the answer and drop what is parked. +// ctrl+c stop the answer and drop what is parked. // ↑ with an empty box, pull the parked message back in to edit it. // click the same, on the block itself. // @@ -55,6 +58,10 @@ type parked struct { text string chips []chip pastes []pasteChip + // sending belongs only to the oldest message while a held conversation's + // submit is crossing back to the surface. It prevents a return during that + // crossing from treating the same message as unsent and sending it twice. + sending bool // standing says the person MARKED this one as something to keep true // (standmark.go). It travels with the words for the chips' own reason: the // gesture was made when the message was typed, and a queue that forgot it @@ -99,7 +106,7 @@ func (a *app) park(text string, standing bool) tea.Cmd { // jumped a queue the person filled first would be this surface reordering their // sentences. Whichever starts, the rest stay parked and go at the next close. func (a *app) sendParked() tea.Cmd { - if a.stream != nil || len(a.parks) == 0 { + if a.stream != nil || a.parkSending || len(a.parks) == 0 { return nil } next := a.parks[0] @@ -132,6 +139,21 @@ func (a *app) sendParked() tea.Cmd { return a.submitShown(spoken, shown) } +// parkedStart turns one waiting message into the same engine call a front send +// makes, without drawing anything on the surface that happens to be in front. +// The returned words let a caller that came forward during the call draw the +// ordinary user line if its earlier replay could not have seen it. +func parkedStart(agent Agent, ctx context.Context, hosted bool, p parked) (spoken, shown string, start func() (<-chan session.Event, error)) { + spoken, shown = p.spoken(), p.text + if len(p.chips) > 0 { + return attachmentStart(agent, ctx, hosted, spoken, shown, p.chips) + } + if p.standing { + return spoken, shown, standingStart(agent, ctx, spoken) + } + return spoken, shown, submitStart(agent, ctx, spoken) +} + // dropParked forgets everything parked and says so, because the person typed // those words. It is the one thing on this queue that loses a message, so it is // called only where the CONVERSATION IS REPLACED under it — /new (app.go's @@ -167,6 +189,9 @@ func (a *app) recallParked() bool { return false } last := a.parks[len(a.parks)-1] + if last.sending { + return false + } a.parks = a.parks[:len(a.parks)-1] a.input.setText(last.text) a.chips = append(a.chips, last.chips...) @@ -183,6 +208,9 @@ func (a *app) recallParkedAt(i int) bool { return false } one := a.parks[i] + if one.sending { + return false + } a.parks = append(a.parks[:i], a.parks[i+1:]...) a.input.setText(one.text) a.chips = append(a.chips, one.chips...) @@ -206,9 +234,9 @@ func (a *app) recallParkedAt(i int) bool { // down through on a narrow frame. Each piece is dropped from the right, because // what the message is DOING outranks what you can do about it. // AND THE MIDDLE PIECES ARE CONDITIONAL, which is what the `stops` and `steers` -// arguments below buy. There is a window — the seconds between a person's esc +// arguments below buy. There is a window — the seconds between a person's ctrl+c // and the engine letting go of the turn (render.go's [app.windingDown]) — in -// which a message is still parked and esc does NOTHING: [app.interrupt] returns +// which a message is still parked and ctrl+c does NOTHING: [app.interrupt] returns // at its first line outside [stateWorking], and [app.sendParked] stands down // while the stream is open. A line offering a key that is inert for three // seconds is the surface lying at the exact moment a person is pressing keys @@ -288,13 +316,13 @@ func (a *app) parkedRows(width int) []string { // only spelled when there is more than one message waiting — one message // counted is a number that says nothing the block above it does not. // -// stops says esc still has a turn to stop. When it does not — the turn was +// stops says ctrl+c still has a turn to stop. When it does not — the turn was // stopped a moment ago and is winding down — the middle pieces are dropped // rather than reworded: what is left is still exactly true (the message waits // for this answer, and it can still be edited), and there is no key to name, // which is the same silence [stoppingWord] keeps in the status line for the same // seconds. A turn with no boundary left to reach takes the arrow down with the -// esc, because a steer into it would be refused for the same reason the stop is +// stop, because a steer into it would be refused for the same reason the stop is // inert (steer.go). // // steers says the arrow's clause is true besides: this session has the verb at diff --git a/internal/tui3/parkkeep_test.go b/internal/tui3/parkkeep_test.go new file mode 100644 index 0000000000..9a27ceb878 --- /dev/null +++ b/internal/tui3/parkkeep_test.go @@ -0,0 +1,290 @@ +package tui3 + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/remote" + "github.com/Agent-Field/codeaf/internal/session" +) + +type keptFileAgent struct { + *fakeAgent + text string + files []remote.WireFile + images []session.Image +} + +func (a *keptFileAgent) SubmitFiles(ctx context.Context, text string, files []remote.WireFile, images []session.Image) (<-chan session.Event, error) { + a.text = text + a.files = append([]remote.WireFile(nil), files...) + a.images = append([]session.Image(nil), images...) + return a.fakeAgent.Submit(ctx, text) +} + +// parkedKeeperLab puts one running conversation with a waiting queue into the +// keeper and leaves an unrelated conversation in front. Tests can then deliver +// the landing edge directly, which is the same contentless nudge the watcher +// sends after it drains a real stream. +func parkedKeeperLab(t *testing.T, parks []parked) (*app, *switchAgent, *fakeAgent, string, *kept) { + t.Helper() + heldAgent := &switchAgent{fakeAgent: &fakeAgent{model: "m"}, running: true} + a := newTestApp(heldAgent) + a.file = "/tmp/lab/held.jsonl" + a.stirs = make(chan behindStirMsg, stirDepth) + a.state = stateWorking + a.parks = parks + + conv, side := a.front(), a.detachConversation() + drain(t, a, a.stow(conv, side)) + front := &fakeAgent{model: "m"} + drain(t, a, a.attachConversation(Conversation{Agent: front, SessionFile: "/tmp/lab/front.jsonl"}, nil)) + key := convKey("/tmp/lab/held.jsonl") + held := a.behind[key] + if held == nil { + t.Fatal("the conversation was not kept") + } + t.Cleanup(held.watch.stop) + return a, heldAgent, front, key, held +} + +// landHeld drives the surface half of the edge a watcher raises when its +// current stream closes. The agent's running answer is updated first because +// Attach is the authority behind follow-up priority. +func landHeld(t *testing.T, a *app, agent *switchAgent, key string, held *kept, running bool) { + t.Helper() + agent.running = running + held.watch.landed.Store(true) + held.watch.armed.Store(true) + drive(t, a, runCmd(a.behindStir(behindStirMsg{key: key}))...) +} + +func awaitWatch(t *testing.T, what string, yes func() bool) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for !yes() && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + if !yes() { + t.Fatal(what) + } +} + +// C2: the message is submitted to the held agent, with its picture bytes, and +// the stream returned by that send becomes the watcher's next turn. Nothing is +// cross-routed into the conversation in front. +func TestAWaitingPictureSendsInItsHeldConversationAndTheWatcherAdoptsItsTurn(t *testing.T) { + dir := t.TempDir() + picture := filepath.Join(dir, "red.png") + if err := os.WriteFile(picture, []byte("red square"), 0o600); err != nil { + t.Fatal(err) + } + p := parked{text: "what colour is the square", chips: []chip{{path: picture}}} + a, agent, front, key, held := parkedKeeperLab(t, []parked{p}) + + landHeld(t, a, agent, key, held, false) + + if len(agent.imageText) != 1 || !strings.Contains(agent.imageText[0], "what colour is the square") { + t.Fatalf("the held picture message was submitted as %q", agent.imageText) + } + if len(agent.images) != 1 || len(agent.images[0]) != 1 || string(agent.images[0][0].Bytes) != "red square" { + t.Fatalf("SubmitImage received %+v", agent.images) + } + if len(front.sent) != 0 { + t.Fatalf("the front conversation received %q", front.sent) + } + if len(held.side.parks) != 0 { + t.Fatalf("the successful message is still waiting: %+v", held.side.parks) + } + awaitWatch(t, "the watcher never adopted the submitted turn", held.watch.turning.Load) + + agent.finish() + awaitWatch(t, "closing the adopted stream produced no next landing edge", held.watch.landed.Load) +} + +// The factored start keeps every front-send door available behind the screen: +// standing messages keep their mark, paste chips unfold, and a hosted ordinary +// file crosses the same fileSubmitter seam as the attachment tray. +func TestHeldWaitingMessagesUseStandingPasteAndRemoteFileDoors(t *testing.T) { + standing := &fakeAgent{model: "m"} + _, _, start := parkedStart(standing, t.Context(), false, parked{ + text: "keep [paste 1 · 3 lines] true", + pastes: []pasteChip{{n: 1, text: "one\ntwo\nthree"}}, + standing: true, + }) + if _, err := start(); err != nil { + t.Fatal(err) + } + if len(standing.marked) != 1 || !strings.Contains(standing.marked[0], "one\ntwo\nthree") { + t.Fatalf("the marked paste went through as %q", standing.marked) + } + + document := filepath.Join(t.TempDir(), "notes.txt") + if err := os.WriteFile(document, []byte("remote document"), 0o600); err != nil { + t.Fatal(err) + } + far := &keptFileAgent{fakeAgent: &fakeAgent{model: "m"}} + _, _, start = parkedStart(far, t.Context(), true, parked{text: "read this", chips: []chip{{path: document, file: true}}}) + if _, err := start(); err != nil { + t.Fatal(err) + } + if len(far.files) != 1 || far.files[0].Name != "notes.txt" || string(far.files[0].Bytes) != "remote document" { + t.Fatalf("the hosted file road received %+v", far.files) + } + if len(far.images) != 0 || far.text != "read this" { + t.Fatalf("the hosted file road sent text=%q images=%+v", far.text, far.images) + } +} + +// C3: every landing spends one queue head. A session follow-up that is already +// running is adopted first, and an in-flight held submit prevents a front close +// from sending the same head again. +func TestHeldWaitingMessagesGoOnePerLandingAfterSessionFollowUpsAndNeverTwice(t *testing.T) { + a, agent, _, key, held := parkedKeeperLab(t, []parked{{text: "one"}, {text: "two"}}) + + // The first landing has already opened the session's ctrl+q follow-up. + landHeld(t, a, agent, key, held, true) + if len(agent.sent) != 0 || len(held.side.parks) != 2 { + t.Fatalf("a parked message jumped the follow-up: sent=%q parks=%+v", agent.sent, held.side.parks) + } + awaitWatch(t, "the watcher did not adopt the session follow-up", held.watch.turning.Load) + + // Its close offers exactly the oldest parked message. + landHeld(t, a, agent, key, held, false) + if got := strings.Join(agent.sent, ","); got != "one" { + t.Fatalf("the first landing sent %q", got) + } + if len(held.side.parks) != 1 || held.side.parks[0].text != "two" { + t.Fatalf("the first landing left %+v", held.side.parks) + } + awaitWatch(t, "the watcher did not adopt the first parked turn", held.watch.turning.Load) + + // The turn that message started closes, and only then does the second go. + agent.finish() + awaitWatch(t, "the first parked turn produced no landing edge", held.watch.landed.Load) + landHeld(t, a, agent, key, held, false) + if got := strings.Join(agent.sent, ","); got != "one,two" { + t.Fatalf("two landing edges sent %q", got) + } + if len(held.side.parks) != 0 { + t.Fatalf("the queue still holds %+v", held.side.parks) + } + + // Returning during the crossing carries the marker with the queue. The + // ordinary front close must stand down until that one result spends it. + a.parks = []parked{{text: "do not duplicate", sending: true}} + a.parkSending = true + if cmd := a.sendParked(); cmd != nil { + t.Fatal("the front tried to send a held submit a second time") + } +} + +func TestComingBackDuringAHeldSendNeverSendsItTwice(t *testing.T) { + a, agent, _, key, held := parkedKeeperLab(t, []parked{{text: "only once"}}) + agent.running = false + cmd := a.sendBehindParked(key, held) + if cmd == nil || !held.side.parkSending || !held.side.parks[0].sending { + t.Fatal("the held send did not mark its queue head before crossing") + } + + // Come forward before the off-loop Submit returns. The marker travels with + // the sidecar, so neither a close nor an explicit send can spend it again. + forward, ours := a.bringForward("/tmp/lab/held.jsonl") + if !ours { + t.Fatal("the sending conversation could not come forward") + } + drain(t, a, forward) + if !a.parkSending || len(a.parks) != 1 || !a.parks[0].sending { + t.Fatalf("the crossing came forward as sending=%v parks=%+v", a.parkSending, a.parks) + } + if again := a.sendParked(); again != nil { + t.Fatal("the front started the crossing message again") + } + + drive(t, a, cmd()) + if got := strings.Join(agent.sent, ","); got != "only once" { + t.Fatalf("the message was submitted as %q", got) + } + if a.parkSending || len(a.parks) != 0 { + t.Fatalf("the successful crossing left sending=%v parks=%+v", a.parkSending, a.parks) + } +} + +// C4: if the answer has already ended by the time stow asks, there is no future +// close edge to wait for. The command returned by stow starts the queue head at +// once. +func TestAnAlreadyIdleTurnSendsItsWaitingMessageAtStow(t *testing.T) { + agent := &switchAgent{fakeAgent: &fakeAgent{model: "m"}} + a := newTestApp(agent) + a.file = "/tmp/lab/idle.jsonl" + a.stirs = make(chan behindStirMsg, stirDepth) + a.parks = []parked{{text: "send me now"}} + conv, side := a.front(), a.detachConversation() + cmd := a.stow(conv, side) + if cmd == nil { + t.Fatal("stow stranded a queue whose turn was already idle") + } + drive(t, a, cmd()) + if got := strings.Join(agent.sent, ","); got != "send me now" { + t.Fatalf("the idle stow sent %q", got) + } + if held := a.behind[convKey(conv.SessionFile)]; held == nil || len(held.side.parks) != 0 { + t.Fatalf("the idle stow left %+v", held) + } else { + t.Cleanup(held.watch.stop) + } +} + +// C6: held conversations still write every waiting word into the plain crash +// draft, while the structured keep contains only the actual box. +func TestStowDraftsFoldsWaitingWordsIntoCrashInsurance(t *testing.T) { + dir := t.TempDir() + draft := filepath.Join(dir, "draft.txt") + a := newTestApp(&fakeAgent{model: "m"}) + side := &aside{draft: "half typed", parks: []parked{{text: "first waiting"}, {text: "second waiting"}}} + a.stowDrafts(Conversation{DraftFile: draft, Workspace: dir, SessionFile: filepath.Join(dir, "chat.jsonl")}, side) + if got := readDraft(draft); got != "half typed\nfirst waiting\nsecond waiting" { + t.Fatalf("the held crash draft is %q", got) + } +} + +// C8: the queue head is not spent until Submit succeeds. A refusal keeps all +// of its structured data and its note waits for the conversation it belongs to. +func TestAFailedHeldSendStaysWaitingWithItsAttachmentsAndNotesTheFailure(t *testing.T) { + picture := filepath.Join(t.TempDir(), "still-here.png") + if err := os.WriteFile(picture, []byte("picture"), 0o600); err != nil { + t.Fatal(err) + } + p := parked{ + text: "try this file", + chips: []chip{{path: picture}}, + pastes: []pasteChip{{n: 1, text: "one\ntwo\nthree"}}, + } + a, agent, _, key, held := parkedKeeperLab(t, []parked{p}) + agent.failing = errors.New("agent closed") + landHeld(t, a, agent, key, held, false) + + if len(held.side.parks) != 1 || held.side.parks[0].text != p.text || len(held.side.parks[0].chips) != 1 || len(held.side.parks[0].pastes) != 1 || held.side.parks[0].sending { + t.Fatalf("the failed message changed to %+v", held.side.parks) + } + if len(held.side.parkNotes) != 1 || held.side.parkNotes[0] != "submit failed: agent closed" { + t.Fatalf("the held failure notes are %q", held.side.parkNotes) + } + + cmd, ours := a.bringForward("/tmp/lab/held.jsonl") + if !ours { + t.Fatal("the failed conversation could not come forward") + } + drain(t, a, cmd) + if len(a.parks) != 1 || len(a.parks[0].chips) != 1 || len(a.parks[0].pastes) != 1 { + t.Fatalf("the failed queue came forward as %+v", a.parks) + } + if !strings.Contains(plain(lastNote(t, a)), "submit failed: agent closed") { + t.Fatalf("the conversation did not receive its submit failure: %q", plain(lastNote(t, a))) + } +} diff --git a/internal/tui3/recipient.go b/internal/tui3/recipient.go index 19a2317d43..44f410e0e3 100644 --- a/internal/tui3/recipient.go +++ b/internal/tui3/recipient.go @@ -604,9 +604,10 @@ func (a *app) mainDraftText() string { // the stash plus the live box when a page is the one holding it. // // MAIN IS DELIBERATELY NOT IN IT. The conversation's own sentence travels as -// [aside.draft], which is that string plus everything still parked behind a turn -// (leaving.go's [app.leavingDraft]), and having it in two places on one aside -// would be two answers to what the person was typing. +// [aside.draft], while messages still waiting behind a turn travel separately +// as [aside.parks]. Having main in this map too would still be two answers to +// what the person was typing. Only quitting folds both text lists together +// (leaving.go's [app.leavingDraft]). func (a *app) composersAside() map[recipient]composerState { out := map[recipient]composerState{} for who, state := range a.everyComposer() { diff --git a/internal/tui3/sharedagent_test.go b/internal/tui3/sharedagent_test.go index 26b8e60819..eafa97ab89 100644 --- a/internal/tui3/sharedagent_test.go +++ b/internal/tui3/sharedagent_test.go @@ -308,3 +308,41 @@ func TestSharedChatRoundTripRestoresEachDraftAndCaret(t *testing.T) { t.Fatalf("B lost its composer: %q at %d", a.input.String(), a.input.cursor) } } + +// C5: a shared handle cannot keep the old turn alive, so its waiting messages +// return to the composer. The fallback keeps every attachment too, with the +// draft's tray first and each waiting message following in queue order. +func TestASharedHandleFoldsWaitingWordsPicturesAndPastesBackIntoTheComposer(t *testing.T) { + a, _, _ := sharedSurface(t) + a.input.setText("draft words") + a.chips = []chip{{path: "/tmp/lab/draft.png"}} + a.pastes = []pasteChip{{n: 1, text: "draft\npaste\nbody"}} + a.parks = []parked{ + {text: "first waiting", chips: []chip{{path: "/tmp/lab/first.png"}}, pastes: []pasteChip{{n: 2, text: "first\npaste\nbody"}}}, + {text: "second waiting", chips: []chip{{path: "/tmp/lab/second.png"}}, pastes: []pasteChip{{n: 3, text: "second\npaste\nbody"}}}, + } + + side := a.detachConversation() + if side.draft != "draft words\nfirst waiting\nsecond waiting" { + t.Fatalf("the shared fallback folded %q", side.draft) + } + if len(side.parks) != 0 { + t.Fatalf("the ended conversation retained structured parks: %+v", side.parks) + } + wantChips := []string{"draft.png", "first.png", "second.png"} + gotChips := make([]string, 0, len(side.chips)) + for _, held := range side.chips { + gotChips = append(gotChips, held.name()) + } + if strings.Join(gotChips, ",") != strings.Join(wantChips, ",") { + t.Fatalf("the shared tray is %v, want %v", gotChips, wantChips) + } + if len(side.pastes) != 3 || side.pastes[0].n != 1 || side.pastes[1].n != 2 || side.pastes[2].n != 3 { + t.Fatalf("the shared pastes are %+v", side.pastes) + } + + a.restoreAside(side) + if a.input.String() != "draft words\nfirst waiting\nsecond waiting" || len(a.chips) != 3 || len(a.pastes) != 3 { + t.Fatalf("the shared composer came back as %q, chips=%v pastes=%+v", a.input.String(), chipNames(a), a.pastes) + } +} diff --git a/internal/tui3/spellout.go b/internal/tui3/spellout.go index e067862791..acd92d7330 100644 --- a/internal/tui3/spellout.go +++ b/internal/tui3/spellout.go @@ -292,9 +292,9 @@ func (a *app) spellKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { } return a.spellAdd(), true case "esc": - // THE INTERRUPT IS NOT FOR SALE (input.go says the same about the rewind - // arm): while a turn is running esc stops it, whatever else is on the - // frame. The block cannot ordinarily be up then — sending empties the box + // NAVIGATION IS NOT FOR SALE. While a turn is running, esc keeps its + // surface-wide meaning and opens Home rather than being swallowed by this + // block. The block cannot ordinarily be up then — sending empties the box // and the block goes with the draft — and where it somehow is, the key // keeps its more important meaning. if !a.spellShowing() || a.state == stateWorking { diff --git a/internal/tui3/standmark.go b/internal/tui3/standmark.go index 8ac3cddbc0..e0d8d9a910 100644 --- a/internal/tui3/standmark.go +++ b/internal/tui3/standmark.go @@ -1,6 +1,7 @@ package tui3 import ( + "context" "strings" "unicode" @@ -244,5 +245,11 @@ func (a *app) submitStanding(text string) tea.Cmd { func (a *app) submitStandingShown(text, shown string) tea.Cmd { agent, ctx := a.agent, a.ctx - return a.submittingShown(text, shown, func() (<-chan session.Event, error) { return agent.SubmitStanding(ctx, text) }) + return a.submittingShown(text, shown, standingStart(agent, ctx, text)) +} + +// standingStart is the marked-message call shared by the front and keeper. +// The mark is a property of the message, not of which conversation is drawn. +func standingStart(agent Agent, ctx context.Context, text string) func() (<-chan session.Event, error) { + return func() (<-chan session.Event, error) { return agent.SubmitStanding(ctx, text) } } diff --git a/internal/tui3/steer.go b/internal/tui3/steer.go index e3d50568d1..3e02018d19 100644 --- a/internal/tui3/steer.go +++ b/internal/tui3/steer.go @@ -143,7 +143,7 @@ func (a *app) steerable() bool { // dropped it would be the sentence quietly becoming ordinary work, which is the // one ending that gesture exists to rule out (standmark.go). func (p parked) steerable() bool { - return p.text != "" && len(p.chips) == 0 && !p.standing + return p.text != "" && len(p.chips) == 0 && !p.standing && !p.sending } // spoken is this message as the model reads it: its own chips unfolded into diff --git a/internal/tui3/switch_test.go b/internal/tui3/switch_test.go index 86f856075d..e8255ded6d 100644 --- a/internal/tui3/switch_test.go +++ b/internal/tui3/switch_test.go @@ -1,6 +1,7 @@ package tui3 import ( + "strings" "sync" "testing" "time" @@ -157,23 +158,42 @@ func TestASwitchDoesNotBringBackAQuestionTheEngineHasResolved(t *testing.T) { } } -// The parked messages come back IN THE BOX rather than being dropped with a -// note, because the turn they were queued behind is still running. -func TestASwitchPutsParkedMessagesBackInTheBox(t *testing.T) { +// A switch keeps the box and the waiting queue as two different things. The +// turn is still running, so folding the queue into a draft would throw away its +// pictures, paste bodies and standing mark and leave nothing to send at close. +func TestASwitchKeepsParkedMessagesStructuredBesideTheDraft(t *testing.T) { agent := &switchAgent{fakeAgent: &fakeAgent{model: "m"}} a := newTestApp(agent) - a.parks = []parked{{text: "and check the tests"}, {text: "then push"}} + a.state = stateWorking + shot := chip{path: "/tmp/lab/shot.png"} + paste := pasteChip{n: 1, text: "one\ntwo\nthree"} + a.parks = []parked{ + {text: "and check the tests", chips: []chip{shot}, pastes: []pasteChip{paste}}, + {text: "then push", standing: true}, + } + agent.running = true typeChars(t, a, "one more thing") conv, side := a.front(), a.detachConversation() + if side.draft != "one more thing" { + t.Fatalf("the box went into the sidecar as %q", side.draft) + } + if len(side.parks) != 2 || side.parks[0].text != "and check the tests" || len(side.parks[0].chips) != 1 || len(side.parks[0].pastes) != 1 || !side.parks[1].standing { + t.Fatalf("the structured queue went into the sidecar as %+v", side.parks) + } drain(t, a, a.attachConversation(conv, side)) - want := "one more thing\nand check the tests\nthen push" - if a.input.String() != want { - t.Fatalf("the box came back as %q, want %q", a.input.String(), want) + if a.input.String() != "one more thing" { + t.Fatalf("the box came back as %q", a.input.String()) } - if len(a.parks) != 0 { - t.Fatalf("%d messages are still parked against a conversation nobody is drawing", len(a.parks)) + if len(a.parks) != 2 || len(a.parks[0].chips) != 1 || len(a.parks[0].pastes) != 1 || !a.parks[1].standing { + t.Fatalf("the structured queue came back as %+v", a.parks) + } + drawn := plain(strings.Join(a.parkedRows(120), "\n")) + for _, want := range []string{"and check the tests", "shot.png", "then push", "wait for this answer", "ctrl+c stops and drops"} { + if !strings.Contains(drawn, want) { + t.Fatalf("the waiting block is missing %q:\n%s", want, drawn) + } } } diff --git a/internal/tui3/tui3_test.go b/internal/tui3/tui3_test.go index 4a7283d493..b07ec1b432 100644 --- a/internal/tui3/tui3_test.go +++ b/internal/tui3/tui3_test.go @@ -169,6 +169,11 @@ type fakeAgent struct { // rather than folded into it because which door a message took is the whole // question those tests ask. marked []string + // imageText and images are every message that went through the picture + // door. They live on the common fake for the keeper tests, where the point is + // that a held send reaches this agent rather than the conversation in front. + imageText []string + images [][]session.Image // steered is every sentence sent INTO a running turn (steer.go), and it is // kept apart from `sent` for `marked`'s reason exactly: which door a message // took is the whole question those tests ask, and a steer that showed up in @@ -1841,7 +1846,7 @@ func TestCtrlCInterruptsThenCloses(t *testing.T) { drive(t, a, key("ctrl+c")) if agent.stops != 1 { - t.Fatalf("esc did not interrupt (%d)", agent.stops) + t.Fatalf("ctrl+c did not interrupt (%d)", agent.stops) } // The stream has not closed, so the word is the wind-down's own // (render.go's [stoppingWord]); `interrupted` arrives behind it at the close. @@ -1852,7 +1857,7 @@ func TestCtrlCInterruptsThenCloses(t *testing.T) { } // AND THE DOOR ANSWERS ON THE PRESS THAT LANDS (leaving.go). The turn was - // stopped by the esc above, so this key is read at rest and it leaves. + // stopped by the ctrl+c above, so this key is read at rest and it leaves. _, cmd := a.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}) if cmd == nil { t.Fatal("ctrl+c returned no command") From b2510f6f000e9d9df1eeffb9a41659ce01fec5ad Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 22 Sep 2026 14:27:29 -0400 Subject: [PATCH 2/4] manual: what happens to a waiting message when you leave for Home or another chat The waiting-block page said esc removed the block and that /new dropped what was waiting; neither has been true since esc became Home. It now says the message keeps waiting with its pictures, goes when its answer ends even from Home or another conversation, that /new keeps it with the conversation it was typed in, and that a connection holding one conversation at a time is the exception. The Escape section says the same in one sentence, and two probes in a person's words reach the page. Co-Authored-By: Claude Fable 5.1 --- internal/manual/chat/keys.md | 8 ++++++-- internal/manual/chat/screen.md | 24 ++++++++++++++++++------ internal/manual/chat_test.go | 5 +++++ 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index 96ccbad231..ed9d6e11fd 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -340,8 +340,12 @@ are dropped — press `enter` again to send it. Press `esc` to go back one layer: close a picker, leave an editor or room, or put a question aside. With no layer left, Escape opens Home. Further presses stay on Home. Message drafts, running turns and queued messages are preserved. Filters may clear first. -Escape never starts rewind or stops a turn. `ctrl+c` interrupts a running turn and quits -when idle; `/rewind` opens the rewind timeline. +**A message waiting above the box is preserved too, with its pictures, and still goes when +that answer ends even while Home or another chat is in front.** The exception is a +connection that holds one conversation at a time: its waiting words and pictures return +to the box and tray because the old conversation has ended. Escape never starts rewind or +stops a turn. `ctrl+c` interrupts a running turn and quits when idle; `/rewind` opens the +rewind timeline. The double-space binding has been removed. Spaces type normally in message boxes. `/home` and `alt+1` (`opt+1` on a Mac) also open Home. Open a conversation row or use diff --git a/internal/manual/chat/screen.md b/internal/manual/chat/screen.md index 5739693842..e161e1cac4 100644 --- a/internal/manual/chat/screen.md +++ b/internal/manual/chat/screen.md @@ -1749,16 +1749,26 @@ exactly one it is not counted at all. `→ steers it in` is there only while the message can go into the running answer: a turn still running, and a message of words alone. A waiting message that carries pictures, or one marked with `ctrl+enter`, cannot be sent in and the clause is absent for it. Pressing -`esc` removes the waiting block at once; `→ steers it in` is absent while a stopped turn +`esc` opens Home and leaves the block with this conversation. `ctrl+c` stops the answer +and removes the waiting block at once; `→ steers it in` is absent while a stopped turn is winding down because that turn has no boundary left to take the words. -## What happens to a message waiting above the box +## What happens to a message waiting above the box when I leave, go Home, switch chats, or lose its picture What happens to it: - **When the answer finishes**, it sends itself as an ordinary new turn and appears in the conversation as a normal message of yours. Several waiting messages go **one per finished turn**, oldest first, in the order you typed them. +- **If you leave for Home or another conversation**, it stays waiting in this + conversation, with its pictures, pasted documents and standing mark. It still sends + when this answer finishes even while you are somewhere else. Come back before then and + the same waiting block and hint are above the box; the box contains only the separate + draft you had not sent. +- **One-conversation connections are the exception.** If a connection says + `a connection holds one conversation at a time`, switching ends the old conversation, + so nothing can keep waiting on its answer. The waiting words return to the box and + their pictures and pasted documents return to the tray after anything already there. - **`ctrl+c`** stops the answer and drops every parked message and queued follow-up. None starts a turn when the interrupted stream closes. - **`→` over an empty box**, or a **click on the words `→ steers it in`**, sends it @@ -1768,10 +1778,12 @@ What happens to it: - **`↑` over an empty box**, or a **click on the block**, takes it back into the box to be edited. `cmd+enter` then holds the edited sentence again. - The box is cleared the moment you press `cmd+enter`, so you can keep typing. Attachments - in the tray go with the held message and come back on the tray if you take it back. -- If the conversation is replaced under it — `/new`, opening a session from the welcome - box — the waiting messages are dropped and codeaf says so: `1 waiting message dropped` - or `N waiting messages dropped`. + in the tray go with the held message, stay with it across Home and chat switches, and + come back on the tray if you take it back. +- `/new` keeps the old conversation running behind you, so its waiting messages stay + with it and go when its answer ends, exactly as a switch keeps them. Opening a session + from the welcome box replaces the conversation instead, and its waiting messages are + dropped and codeaf says so: `1 waiting message dropped` or `N waiting messages dropped`. While something is waiting, the keys row under the box ends with `ctrl+c stops and drops` instead of `ctrl+c interrupt`. diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 3e809f42db..6100a9d142 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -572,6 +572,11 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"what does cmd+enter do", "keys"}, {"what does steers it in mean", "keys"}, {"my message went in too late", "keys"}, + // A waiting message survives navigation as structured input, asked both + // before somebody trusts the switch and after the old defect returned its + // words as a draft without the picture. + {"I pressed esc and went home while my message was waiting, will it still be sent", "screen"}, + {"my waiting message turned into a draft and lost its picture when I switched chats", "screen"}, // THE SCOPED THINKING CHORD, asked the five ways people meet it: reaching // for paste and finding it bound, wanting one task to think harder, // wanting the machine's own default moved, wanting one reminder raised From bf235da6cb24f5525740f709584cf83770157ec8 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 22 Sep 2026 14:35:18 -0400 Subject: [PATCH 3/4] docs: the waiting-message change entry names its pull request Co-Authored-By: Claude Fable 5.1 --- .../1385-waiting-message-survives-leaving.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 docs/changes/unreleased/1385-waiting-message-survives-leaving.md diff --git a/docs/changes/unreleased/1385-waiting-message-survives-leaving.md b/docs/changes/unreleased/1385-waiting-message-survives-leaving.md new file mode 100644 index 0000000000..9723fac8a3 --- /dev/null +++ b/docs/changes/unreleased/1385-waiting-message-survives-leaving.md @@ -0,0 +1,17 @@ +--- +kind: fixed +title: a message waiting above the box stays waiting when you leave for Home or another chat, and still goes when its answer ends +pr: 1385 +surface: [chat] +invalidates: + - "A conversation switch used to fold every message parked above the box into the draft and drop the pictures parked with it, so a follow-up parked before esc, enter came back as unsent text without its picture and was never sent. The parked queue now travels with the conversation as itself — words, pictures, pasted documents, standing mark, order — and comes back as the same waiting block." + - "A parked message used to go only when its conversation was in front at the turn's close. It now also goes when the turn ends while the conversation is held behind the screen: the keeper sends the oldest one through that conversation's own agent, one per finished turn, after any ctrl+q follow-up the session already holds." + - "/new no longer drops the messages waiting in the conversation it leaves; they stay with that conversation and go when its answer ends. Opening a session from the welcome box still drops them and says so." + - "On a connection that holds one conversation at a time the waiting words still fold back into the box, and their pictures and pasted documents now come back onto the tray with them instead of being lost." +--- + +Since #1071 esc opens Home, and Home stands the cursor on the conversation before +this one, so the natural esc, enter is a switch. Santosh met it as "esc when a +message is waiting does not seem to send it, it seems to just cancel the running +one". The switch is what lost the message, and a switch is not a quit: the turn +keeps running, so the message can keep waiting and go when it ends. From 4e4799cd5a48922b07d84e52beff10411bc041a1 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 22 Sep 2026 14:35:34 -0400 Subject: [PATCH 4/4] docs: the change entry's title fits one line Co-Authored-By: Claude Fable 5.1 --- .../changes/unreleased/1385-waiting-message-survives-leaving.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changes/unreleased/1385-waiting-message-survives-leaving.md b/docs/changes/unreleased/1385-waiting-message-survives-leaving.md index 9723fac8a3..5fd1d60ec1 100644 --- a/docs/changes/unreleased/1385-waiting-message-survives-leaving.md +++ b/docs/changes/unreleased/1385-waiting-message-survives-leaving.md @@ -1,6 +1,6 @@ --- kind: fixed -title: a message waiting above the box stays waiting when you leave for Home or another chat, and still goes when its answer ends +title: a waiting message stays waiting when you leave the chat, and still goes when its answer ends pr: 1385 surface: [chat] invalidates: