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
17 changes: 17 additions & 0 deletions docs/changes/unreleased/1385-waiting-message-survives-leaving.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
kind: fixed
title: a waiting message stays waiting when you leave the 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.
8 changes: 6 additions & 2 deletions internal/manual/chat/keys.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 18 additions & 6 deletions internal/manual/chat/screen.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`.
Expand Down
5 changes: 5 additions & 0 deletions internal/manual/chat_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 34 additions & 12 deletions internal/tui3/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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)
}
Expand Down Expand Up @@ -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.
Expand Down
83 changes: 49 additions & 34 deletions internal/tui3/attach.go
Original file line number Diff line number Diff line change
Expand Up @@ -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++
Expand Down Expand Up @@ -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
Expand All @@ -936,24 +952,23 @@ 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
// through, and the honest thing is to say so with the person's tray
// 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
Expand Down
5 changes: 4 additions & 1 deletion internal/tui3/attach_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
Loading
Loading