Skip to content

fix(native-chat): every chat send ends one of three ways, decided by the host's answer - #25207

Closed
brennanb2025 wants to merge 191 commits into
mainfrom
brennanb2025/chat-send-settles-once
Closed

brennanb2025 wants to merge 191 commits into
mainfrom
brennanb2025/chat-send-settles-once

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 89 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​7545 $\color{#cf222e}{\Huge{\mathbf{−}}}$​5130 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​2415
Prod 122 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​3848 $\color{#cf222e}{\Huge{\mathbf{−}}}$​2189 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1659

Stacked draft. Don't merge. #25158 (the host answers a resent message from its record) is on main. This branch also merges four PRs that haven't landed yet, and it relies on them: #24606 ("Sending…" on a message), #24905 (drafts survive a reload, keyed by conversation), #24918 (a message the host recorded stays in the chat) and #24917 (a new chat after a failed start; notes held while their send is on its way). Until they land, this diff includes their commits; this PR's own changes are the non-merge commits from 62ef8e78042 on. #24606 and #24918 conflict with each other; the merge commit e10d88d6e8d here has a resolution whichever lands second can reuse. This PR is marked ready only after all four are on main and main is merged back in.

ELI5

When you send a message in a native chat, Orca keeps a copy until the host answers. Today, if something goes wrong, the message can end up in a half-state: a red "not sent" line with a Retry button, a "delivery is unconfirmed" line, a message that silently never goes out, or a later message stuck behind it. Your text can also be lost.

Now every message you send ends in exactly one of three ways, decided only by what the host said:

  1. The host has it. The message is the host's from then on. The chat shows the host's copy to every window and the phone, including a muted "not sent" line if the agent then didn't take it.
  2. The host proved it doesn't have it. The message comes off the chat once, and its text goes back into the chat's message box, after anything you've typed. The reason is said once, on the line above the message box. To try again you just send it.
  3. No answer yet (the connection dropped, the app reloaded mid-send). Orca quietly sends the same message again under the same id until the host answers, and the message says "Sending…" meanwhile. The host keeps one record per id, so this can never deliver the message twice.

There is no Retry button any more.

The problem, as you saw it

  • A red "not sent" row with Retry. It showed when the host refused a message, when the request failed, or when the host recorded the message and then didn't deliver it. Retry sent the message again under a new id, so the chat could end up with two copies. The common pattern has no Retry on a message: you send again.
  • Sends during a connection outage failed for good. A message sent while a remote host was unreachable was marked "not sent" with Retry and never resent. The code decided this by matching words in the error text, so the outcome depended on how an error happened to be worded. For example, "Timed out waiting for the remote Orca runtime to respond." doesn't contain "timeout", so a send that may well have landed was labelled "not sent".
  • A stuck message blocked later ones. After a host restart, a message whose fate the host could no longer tell was parked, and every message you sent after it sat there as a plain bubble that never went out until you pressed Retry on the first one.
  • Stop left messages in limbo. A message on its way when you pressed Stop was marked with "delivery is unconfirmed" and Retry, and nothing ever settled it.
  • Text could be lost. Text handed back only went to the pane's composer, so an answer that arrived after you closed the tab dropped it.

What changes for you

Situation Before After
The host refuses a new message (for example it can't start the agent) Red "not sent" row + Retry; text stays on the row The message leaves the chat, its text goes back into the message box after what you typed, and the reason shows once above the box
The connection drops right after you send "Message delivery is unconfirmed." + Retry, or "not sent" + Retry, depending on the error's wording The message stays with "Sending…" and goes again under the same id until the host answers; it is never delivered twice
You send while a remote host is offline Marked "not sent" + Retry, never resent Held with "Sending…" and delivered when the host is back (while the chat is open; a closed chat resends when you open it again)
The host keeps refusing a resent message in a way Orca can't trust (an older host, or a host error) "not sent" + Retry The message stays "Sending…" and goes again quietly; the line above the message box says why once, followed by "Orca will keep trying to send it." It ends when the host answers, or after the host's two-day window, when the text comes back to the message box to check
The host recorded the message, then the agent didn't take it Red row + Retry in your window, hidden elsewhere Muted "not sent" line under the message, the same in every window (from #24918); no Retry, text not handed back
A host restart lost track of whether a message was delivered The message blocked every later message until you pressed Retry It stays in the chat with a muted "Not confirmed. Send it again if the agent didn't answer it." once nothing is running that could still deliver it; later messages go out normally
You press Stop while a message is on its way "Message delivery is unconfirmed." + Retry, forever Settled by the host: if the host has it, it stays in the chat; if not, its text comes back to the message box. It is never sent again after your Stop. If the Stop itself is refused, "The agent wasn't stopped." says so once
You press Stop before a queued message went out Text back in the message box Unchanged
The answer arrives after you closed the chat's tab Text could be lost Text lands in that chat's draft, which survives closing the tab and a reload
You close a chat's tab while messages are still waiting to go Every waiting message was deleted with the tab, and its text was gone Closing a chat's tab no longer deletes its unsent messages: a message that never went out goes back to that chat's draft; one already sent stays and settles when you open the chat again
You had messages waiting with Retry from an older Orca Red rows with Retry Once the chat loads: a message the host has stays in the chat; one it doesn't have comes back to the message box with "Orca couldn't confirm your message reached the agent. Check the chat, then send it again if needed." Never sent again on its own

The first message of a new chat (a launch prompt: "Send notes to > New agent", a source-control agent action) now goes through the same path as any other message, and its caller is told how it ended only once it has really ended. A launch whose message is still being resent waits instead of reporting "could not be sent" (whose Copy prompt could have sent it twice); notes leave the notes list only once the message is delivered.

Mechanism

  • One settlement. src/shared/structured-agent-session-send-settlement.ts maps every answer to one of the three ends. Its inputs are the result, a refusal, or a thrown error, and three facts: whether this is the id's first attempt, whether the host's refusals prove there is no record, and whether the loaded journal has a row for the id. It also maps what the journal alone settles, such as a row, a queued card, or a Stop's answer read through. structured-agent-session-outbox-dispatch.ts is the one sender, and the open chat's queue and launch prompts both use it.

  • When a refusal is proof. A host with A resent chat message gets its recorded answer, never an early refusal or a made-up record #25158 advertises agent-session.send-answers-proof.v1, which means it checks a resent id before anything else can refuse it. On such a host every refusal it returns proves there is no record. There are four exceptions:

    • "outcome unknown": the same id is sent again;
    • an expired id: the journal decides;
    • a chat closed on the host: the text comes back with "check the chat" words;
    • an id reused for other content: also back with "check the chat".

    On an older host only a first attempt's refusal is proof. A first attempt is checked against storage both before it goes and when its answer comes back, so one window's first attempt is never another window's resend (atomic within one window; two windows rely on the host's per-id record). Any other refusal there means the same id goes again, until the host's 48-hour replay window ends it. While it goes again, the line above the message box says the refusal's cause once, with no step that would invite a second copy.

  • A made-up record is not a record. When a host has the id in its ledger but no row in the chat, it answers with a made-up "unknown" submission. With no row in the loaded chat, that message comes back to the message box with the "check the chat" words instead of being dropped (an older host makes this record when its journal write failed, so the message was never written).

  • Errors by code, not text. A thrown error is never treated as an answer, because no error code on any route tells "never written" apart from "written, then the answer was lost". A thrown refusal can even come after the host wrote the row. Only a call the host turned away before running it is proof: a method it doesn't have, or parameters it rejects. The text regex is gone.

  • Stop. Messages that never went out are taken back into the message box, as before. Messages already on their way are stamped with the Stop's own operation id and never sent again.

    • The host runs one chat's sends and Stops one at a time, so a message it took before the Stop has its row by the time the Stop answers.
    • Once this window has read the journal up to the Stop's answer and there is still no row, the text comes back.
    • A Stop whose answer was lost is sent again under its own id. It isn't re-sent once you've sent a newer message, because it could then stop that message's turn instead.
  • Drafts by conversation. A structured chat's draft is keyed by its conversation (agent-session:<sessionId>), not the pane, and closing the tab no longer deletes it; removing the worktree does. Handing text back appends it after a blank line and skips text that's already there, so a repeat adds nothing. An accepted send now removes only the text it sent, so text typed meanwhile (here or in another window) stays.

  • Closing a chat's tab keeps what it was sending. Before, a tab close wiped the chat's whole outbox (a cancelled-launch clean-up that had spread to every close). Now only a cancelled launch's own unsent prompt is dropped; any other message that never went out goes to the chat's draft, and one already sent stays and settles when the chat is reopened.

  • Notes held by the message that carries them. "Send notes to" saves the notes' keys on the outbox message. A note counts as on its way while a message in this window's saved outbox carries it and the host can still deliver it, so a reload doesn't put it back on the shelf. Notes follow their text: delivered, or handed back to the message box, clears them; only a discarded message with nothing handed back returns them. Nothing is released because a tab, host sync or removal frame no longer shows a chat.

  • Only a live journal decides on its own. A hand-back the chat's journal alone decides (an older build's entry, a Stop's answer, a Stop that can't be answered) waits until this window is receiving that journal again; losing contact is never taken as "no row". A message past the host's window is sent again first, and the host's "expired" answer decides.

  • SSH images. A message handed back keeps each image's remote connection, so it still opens after a reload. That connection is saved with the message, never sent to the host.

  • Every entry can end. Nothing in the outbox waits on you any more, and nothing blocks the messages behind it: the resend timer always works on the message the queue is waiting for. A message in doubt that the host never answers, a send a Stop outran, and an entry an older build left are all ended by the host's replay window at the latest.

  • Hand-back rule. Returned text is skipped only when the draft already ends with it after a blank line (the common pattern's rule), so a repeat adds nothing and nothing typed is ever swallowed. Two identical returned messages come back as one.

Why this approach

The common pattern for a client that keeps a saved outbox is: the outbox entry owns the message's id, the client resends it under that id until the server answers, and the server dedupes by id. A refused message's text goes back to the composer, appended after a draft, and there is no Retry on a message. Orca's desktop already had the saved outbox and the host's per-id record. What was missing was the rule that only the host's answer ends a send, and a host whose answers can be trusted, which #25158 adds.

While the host is offline, messages are held and sent on reconnect, rather than disabling Send. That is the common pattern's shape for a client with a saved outbox. It needs no new gate: a send during an outage gets no answer, so it stays "Sending…" and goes out when the host is back.

Alternatives considered:

  • Keep Retry with fewer states. Retry is what made two copies and kept a message waiting on you.
  • Hand text back on any failure, as clients without an outbox do. If the answer was lost but the message landed, that can duplicate it.
  • Decide "never written" from transport error codes. The same code is raised before and after the write, so it can't.

Differences from the common pattern

  • Intended:
    • Resend window. The host dedupes resends for 48 hours, not forever. Past that it refuses the id for good and never runs it again, and the text comes back with "check the chat" words.
    • "Sending…" placement. It replaces the message's timestamp until the host has it (from fix(native-chat): show "Sending…" on a message until the host confirms it #24606), because Orca shows timestamps only on hover.
    • Reason placement. The reason a message came back is said on the line above the message box, not in a banner or toast. That line already exists, and your next send clears it.
    • Muted lines. "not sent" and "Not confirmed" are muted lines under a message, because Orca's host writes no separate error row for these.
    • Keep-trying line. While a refused message is being resent, the chat line says why with "Orca will keep trying to send it." The outbox client in the common pattern shows its connection error the same way while a message stays pending.
    • No "Not delivered yet" label. The approved design planned one after an unanswered resend; "Sending…" (from fix(native-chat): show "Sending…" on a message until the host confirms it #24606) already stays on the message the whole time, including on a send a Stop outran while its answer is awaited, so a second label would say the same thing twice.
  • Temporary:
    • Never-sent messages on Stop. These still come back to the message box, rather than staying as held host rows, until the host message queue ships (agent-session.queued-messages.v1).
    • Older hosts. A refusal of a resent message on an older host is not proof, so it keeps resending until the host's window ends. This goes away as hosts gain A resent chat message gets its recorded answer, never an early refusal or a made-up record #25158.
    • The phone. The phone has no outbox yet and keeps its own rules. Follow-up: the phone outbox (C3), which also shows "Not confirmed" there. The phone shows fix(native-chat): the phone keeps a message the host did not deliver, and a failed /compact is said once #24918's "not sent" line today.
    • Resends need the chat open. The resend timer, the Stop's own resend and the journal check run in the open chat (open tabs stay mounted; a closed tab's chat catches up when reopened). The common pattern's outbox client drives every thread from one app-level driver. Follow-up: move these into one per-chat driver outside the view, which the phone outbox (C3) can share.
    • No edit or remove on a held message. A message held while the host is offline can be taken back only with Stop. Follow-up with the phone outbox (C3).

Linked Issue

N/A (maintainer).

Visual Proof

Captured on a remote Mac in an isolated test build, before (the stacked PRs without this one) and after (this PR at ab1c713c381), with a stand-in agent. Notes behaviour from later commits is covered by tests; screenshots of it follow.

Messages an older Orca left with Retry. Before: each keeps a "not sent" line and a Retry. After: their text is back in the message box after the typed draft, with "Orca couldn't confirm…" above it. There is no Retry, and nothing was sent on its own.

Before After
Before: two old messages with "not sent" lines and Retry After: their text is in the message box, with the couldn't-confirm line above it

A stuck message no longer blocks the next one (60 s). Before: "Message delivery is unconfirmed." + Retry, and the message behind it waits "Sending…", never delivered. After: the stuck text comes back to the box, and the next message was delivered within about 0.35 s with no action.

Before After
Before: unconfirmed line + Retry, next message stuck "Sending…" After: next message delivered, stuck text in the box

The host recorded the message, then the agent failed to start. Both: a muted "Your message was not sent." under the message, and the start's own banner. Before has a Retry; after has none, and the text stays in the chat.

Before After
Before: muted not sent + Retry After: muted not sent, no Retry

Stop with messages that never went out (after; same as before). All three come back to the message box after the typed draft, and no "unconfirmed" line appears.

After: Stop returned three queued messages to the box

Round 2, after the review fixes (head c8be71091c6).

Closing a chat's tab while a message shows "Sending…", then reopening it. Before: the message is wiped and never delivered. After: it is kept, and once the chat reopens it shows "Sending…" for about 1 s and is delivered exactly once.

Before After
Before: after close and reopen, the sending message is gone After: after reopen, the message is delivered once

After: "Sending…" just before the tab was closed

Closing a chat while its agent is still starting. Both builds show "The chat closed before this message was sent." under each message the host had already accepted. Those messages stay in the chat; they aren't handed back. After also keeps what you had typed in the message box; before loses it.

Before After
Before: closed while starting After: closed while starting, the typed draft is kept

Messages an older Orca left with Retry: no flash. Checked every 4 ms for 20 s, "Sending…" never appears. The text lands in the message box after the draft, with the couldn't-confirm line, and nothing is sent on its own.

After: the old messages' text in the box with the couldn't-confirm line

Notes across a reload.

  • Sending notes and reloading 5 ms later: delivered once, the notes cleared and not offered again.
  • A notes message still on its way in a closed chat: after a reload, those notes stay out of the next send (shown as "Sending…") until the chat is reopened. They are then delivered exactly once and cleared.

Also checked:

  • Reopen mid-send: "Sending…" for about 1 s, then delivered once. Same as before; fix(native-chat): show "Sending…" on a message until the host confirms it #24606 already did it.
  • Normal send: "Sending…" showed for 12 ms; delivered once.
  • Closing the tab: the chat's saved draft survives it.
  • New UI: the couldn't-confirm line is the only new element that appeared. "Orca will keep trying to send it." and "Not confirmed…" did not come up in these cases. No toasts.

Not captured:

  • A notes send coming back on a first-attempt refusal: there was no way to make the stand-in refuse in the test build. Unit tests cover it.
  • Stop while a message is already on its way: the stand-in couldn't hold one in flight.
  • Reopening a closed chat from the history list: the test build lists no history.
  • A real remote-host outage, and the phone.

Testing

  • Two review rounds plus a narrow third; findings and fixes are in the review comment.

  • New transition tests:

    • structured-agent-session-send-settlement.test.ts covers every answer shape and the migration of saved entries.
    • use-structured-agent-session-outbox.settlement.test.tsx, …stop-stamp.test.tsx, use-structured-agent-session-conversation-stop.test.tsx and structured-agent-session-returned-send.test.ts cover:
      • a lost answer, then a resend, gives one host submission;
      • a refusal puts the text in the conversation's draft, once;
      • an answer after the tab closed still reaches the draft;
      • a recovered head followed by a later send: the later send goes out with no action from you;
      • an outage holds the message, then delivers it;
      • Stop with a lost answer never resends;
      • a refused Stop gives the "check the chat" words;
      • saved older entries are migrated;
      • a refused launch prompt comes back to the draft;
      • on an older host, a refused resend goes again.
  • Five ablations, each turning its tests red: a thrown error counted as an answer; a recovered row counted as no answer; stamped entries resent; no hand-back; an older host's refusal treated as proof.

  • 470 of 472 related root test files pass (5,227 tests). The 2 failing files are main's ripgrep tests, which need a dependency patch the shared local install predates; CI installs it. The related mobile tests pass.

  • Typecheck: web, node and mobile (except *.generated).

  • I manually tested these changes locally

  • Automated tests added/updated, or explained why not below

Notes

  • SSH: an SSH worktree's chat uses the same local route. Losing contact never settles a send: it stays case 3.
  • Folder workspaces: nothing here assumes git.
  • Wire: no params, frames or opcodes change. The client only reads the new host capability. Older clients keep their Retry against new hosts.
  • Mobile: shared code was touched only for a renamed module (structured-agent-session-send-failure-words). The phone's behaviour is unchanged.

…its own

A send whose answer was lost (for example after reopening the chat mid-send)
showed "Message delivery is unconfirmed." with a Retry for the moment before
the automatic resend under the same message id confirmed it. One rule now
decides both: the resend runs, and the notice stays quiet, only while the
send's answer is lost, the user never retried it, it was not still going out
when the user pressed Stop, and the host has no record of it yet. Once the
host has any record of it, the line and Retry are exactly as before.
The composer's draft text, its editor document and settled image refs lived
only in module memory, so a reload, quit or crash lost whatever was typed,
including text a Stop had just given back. They are now saved per pane scope
in localStorage: typing is debounced and flushed when the window hides or
closes, text given back to the composer is saved at once, an emptied or sent
draft is removed at once, and only the newest drafts within the composer
caches' existing bound are kept.
…it is not delivered

A send the host recorded and then settled as not delivered (its agent failed to
start, Orca restarted before handing it over) was hidden by the transcript
projection unless the sender's outbox entry still carried it. Another window,
the phone, or the sender after its entry was dropped saw the message vanish.

The projection now shows every recorded, non-withdrawn rejection from the
journal as a not-sent row at its journal position, and the delivery notices
word it from the host's fact (no Retry where this client holds nothing to
send). The phone row says "Message not sent". Not-sent rows take no rail tick,
so the host's outline is unchanged. One predicate now reads a recovered unknown,
including an older host's restart row without the marker.
…n the phone too

The phone's banner repeated what the recorded message's row now says. A recorded
rejection's words move to one shared function: the desktop row translates them,
the phone row shows them in English, and a start-failure row that already says
why leaves only "not sent". The phone's banner keeps only a Stop's withdrawal,
which has no row.
A chat whose start the host refused stayed registered under its agent and
workspace, so the + menu and new-tab search kept that agent disabled with a
spinner, and any later "new chat" for that agent (for example "Send notes to
> New agent") restarted the failed chat instead and dropped its own prompt.

Only launches in flight now hold the agent/workspace slot that later starts
join. The registry keeps every launch by session id and derives the holder,
so a failed chat keeps its own Retry while a new start makes a new chat.
The draft's text and its image chips were saved by two writers that each
rebuilt the stored record from what storage held, so a draft that once went
over the size cap, or one refused write, lost its images on the next save.

One store now holds each composer scope's whole draft in memory and writes
storage from it: typing is coalesced, returned text, chips and clears land at
once, a refused write stays pending for the next flush, and all drafts
together keep to a budget so the outbox can still save a send. Closing a tab
drops its panes' drafts.
… of this change

It is unrelated to recorded messages staying in the chat; it moves to its own follow-up.
…ss reloads

A message given back from the outbox could be larger than one draft was
allowed to store, so the saved draft was deleted and the returned text was
gone after a reload. One draft may now use the whole draft budget, which
holds the largest message a send accepts.

An adopted launch link is also parked in the agent's input line. Restoring
it after a reload, with no seed left to replace that copy, typed it twice,
so the untouched copy is shown but not saved.

A composer reused for another pane now shows that pane's images, so adding
one never saves the previous pane's over them.
…tbox entry for

The pane read the journal's rejection rows only while its own outbox held a
rejected entry, so another window, or the sender after its entry was gone,
drew the host's not-sent message as a plain bubble with no line under it.
It now reads them whenever a row is shown as not sent.
…re it already is

A rejected /compact drew as a not-sent "/compact" message while its reply or
its turn's "Compaction failed" row already said it failed, so the failure
showed twice. A rejected hand-off of a queued draft drew as a not-sent bubble
while the draft's card kept the same text. Both stay hidden as before: the
command entry is read from the journal's own item, the hand-off from its
queued-draft link.
…t message

A message shown as not sent has no rail tick, but the active-tick rule still
lit its own id, so the rail lit nothing while it was the row being read, for
example at the bottom of a chat ending in one. The tick before it stays lit.
…worktree

A pasted image lives in a temp file Orca wrote, and the permission to show
it is held in memory only, so after a restart a restored pasted chip showed
a generic icon, and once the temp file was cleaned it looked fine but the
send failed after the composer cleared. Pasted images now stay in the
draft for the current run only; images the user attached from their own
files are still saved.

Removing a worktree now deletes its terminal tabs' drafts, as closing the
tab does.

A paste that finished late in a composer that had already been replaced
wrote that composer's old images back over a newer send. Dropping a
placeholder chip no longer writes the saved draft.

The pane-change reload in the images hook is removed: the composer is
keyed by pane, so a pane change always mounts a new one.
…ping a copy

A composer that had been replaced, for example by a question prompt, could
still finish an attach or a send later and write its own old copy of the
draft over the store, so a sent message or image came back. The composer
now reads text and images from the draft store and changes the draft as it
is now: an added image or file reference joins what is there, and a send
that settles after a round trip, or after its composer was replaced, clears
the draft only if it is still what was sent. Only chips still being written
and an IME composition in progress stay local to the composer.

Closing a tab now deletes drafts by the exact tab id: a second chat for one
session has an id that extends the first one's, so a prefix match deleted
its drafts too.
…mposers

An image pasted while /compact or /clear was still on its way to the host
vanished when the command was accepted: the clear that followed also wiped
the chip still being saved. A command that settles now clears only the
draft it sent, and an image pasted meanwhile stays.

A composer on screen could lose its pasted images, and its unsaved launch
text, once 128 other drafts pushed its record out of memory, because those
are never stored. A draft that a composer is showing is no longer evicted.
A write to a pane while every other cached draft was on screen could evict
the record it had just written, losing it. Also adds the test that a send
settling after its composer unmounted clears the saved draft, through the
real send hook.
…fts-survive-reload

# Conflicts:
#	src/renderer/src/store/slices/tabs/tabs-close-actions.ts
…en sending, too

Hosts from before the recovered marker publish only the restart reason. The
working-state rule already read that reason as recovered; the send answer now
uses the same predicate, so both agree on those hosts.
…ected

The phone put a rejected message's text back in the composer even when the
host had recorded it, so the same text showed in the chat as not sent and in
the composer. The host's row now holds it, as on the desktop: the send reports
that the host holds the text, so the composer and attachments are not
restored. A message a Stop withdrew still goes back with its notice.
The line under a message that was not sent, on desktop and phone, used the
error color, as if the user had something to fix. It is a plain label now,
in the muted text color. A line that is still in doubt ("Message delivery is
unconfirmed.", or an expired id Orca can't confirm) keeps the error color,
as does the terminal chat's notice.
A new start for an agent joined a chat whose create answer was lost, or a
failed chat whose Retry was in flight, and dropped its own text: the
source-control "Fix with AI" action reported success with nothing sent,
"Send notes to > New agent" sent nothing, and after a reload the text became
an unsent draft in the old chat.

Only a new start's own first create now coalesces later starts (double click,
two callers at once). Each attempt records whether it is that first create or
a Retry/re-check of an existing chat; an unconfirmed or retried blank chat
keeps its own Retry and a new start opens a new chat with its text. A resume
still re-checks or joins its conversation's launch, since the host refuses a
second adoption.
… early refusal or a made-up record

The host now looks a resent send id up before preparing the session. A row
that settled refused answers with its refusal before the chat is opened. A
resend whose chat cannot be opened or made ready answers unknown instead of a
refusal. A /clear in flight refuses only ids the ledger does not hold.

A send row now records the journal epoch it was admitted into. An unsettled
row with nothing written in that same epoch runs for the first time; under a
later epoch the host answers unknown instead of reconstructing a submission
it never had. The host advertises agent-session.send-answers-proof.v1.
Main now carries #24917 as landed; C2 already holds that version, so the
files only main changed keep C2's resolution, and #24917's in-memory notes
hold (new-agent-prompt-outcome, the outbox entry watch) stays deleted: the
outbox-derived hold is the only one.

From #24905: the per-scope write confirm, data additions that report whether
they are durable (journaled at once), the load-pending guard on empty chats,
and its paste wording and chat-image test.

C2 moves onto them:
- A hand-back finishes at once when its addition is durable, and otherwise
  when the draft's own write is confirmed (nativeChatComposerDraftWriteSettled),
  re-checking a refused one on the draft's next landed save.
- It runs against the loaded drafts, waiting for the startup load, so the
  suffix rule and the image id check read what was saved (an addition made
  before the load is applied again to the loaded draft without them).
- The suffix rule stays C2's (the end, as its own paragraph); #24905's `once`
  matches any text ending, so it isn't used for it. A whitespace-only draft
  counts as empty.
A rejected message now stays at its place whatever is sent after it: a resend
of its text is a new message, and the row is the record of the failure (main's
text-match collapse goes). Its resend test is taken on C2's muted notices.
brennanb2025 added a commit that referenced this pull request Oct 6, 2026
* fix(native-chat): an unsent draft survives a reload or quit

The composer's draft text, its editor document and settled image refs lived
only in module memory, so a reload, quit or crash lost whatever was typed,
including text a Stop had just given back. They are now saved per pane scope
in localStorage: typing is debounced and flushed when the window hides or
closes, text given back to the composer is saved at once, an emptied or sent
draft is removed at once, and only the newest drafts within the composer
caches' existing bound are kept.

* fix(native-chat): save draft images outside the state updater

* fix(native-chat): one store owns each unsent draft

The draft's text and its image chips were saved by two writers that each
rebuilt the stored record from what storage held, so a draft that once went
over the size cap, or one refused write, lost its images on the next save.

One store now holds each composer scope's whole draft in memory and writes
storage from it: typing is coalesced, returned text, chips and clears land at
once, a refused write stays pending for the next flush, and all drafts
together keep to a budget so the outbox can still save a send. Closing a tab
drops its panes' drafts.

* test(native-chat): returned text is stored before its images arrive

* fix(native-chat): keep returned text and drop launch-seed copies across reloads

A message given back from the outbox could be larger than one draft was
allowed to store, so the saved draft was deleted and the returned text was
gone after a reload. One draft may now use the whole draft budget, which
holds the largest message a send accepts.

An adopted launch link is also parked in the agent's input line. Restoring
it after a reload, with no seed left to replace that copy, typed it twice,
so the untouched copy is shown but not saved.

A composer reused for another pane now shows that pane's images, so adding
one never saves the previous pane's over them.

* test(native-chat): a plain 260k given-back message stays saved

* fix(native-chat): saved drafts drop pasted images and die with their worktree

A pasted image lives in a temp file Orca wrote, and the permission to show
it is held in memory only, so after a restart a restored pasted chip showed
a generic icon, and once the temp file was cleaned it looked fine but the
send failed after the composer cleared. Pasted images now stay in the
draft for the current run only; images the user attached from their own
files are still saved.

Removing a worktree now deletes its terminal tabs' drafts, as closing the
tab does.

A paste that finished late in a composer that had already been replaced
wrote that composer's old images back over a newer send. Dropping a
placeholder chip no longer writes the saved draft.

The pane-change reload in the images hook is removed: the composer is
keyed by pane, so a pane change always mounts a new one.

* fix(native-chat): the composer renders the draft store instead of keeping a copy

A composer that had been replaced, for example by a question prompt, could
still finish an attach or a send later and write its own old copy of the
draft over the store, so a sent message or image came back. The composer
now reads text and images from the draft store and changes the draft as it
is now: an added image or file reference joins what is there, and a send
that settles after a round trip, or after its composer was replaced, clears
the draft only if it is still what was sent. Only chips still being written
and an IME composition in progress stay local to the composer.

Closing a tab now deletes drafts by the exact tab id: a second chat for one
session has an id that extends the first one's, so a prefix match deleted
its drafts too.

* fix(native-chat): keep images pasted during a command and on shown composers

An image pasted while /compact or /clear was still on its way to the host
vanished when the command was accepted: the clear that followed also wiped
the chip still being saved. A command that settles now clears only the
draft it sent, and an image pasted meanwhile stays.

A composer on screen could lose its pasted images, and its unsaved launch
text, once 128 other drafts pushed its record out of memory, because those
are never stored. A draft that a composer is showing is no longer evicted.

* fix(native-chat): a draft being written is never evicted from memory

A write to a pane while every other cached draft was on screen could evict
the record it had just written, losing it. Also adds the test that a send
settling after its composer unmounted clears the saved draft, through the
real send hook.

* fix(native-chat): restored images Orca couldn't keep come back to attach again

A pasted screenshot in an unsent draft vanished after a restart with no
notice, and a restored image whose file had been deleted looked fine until
the send failed. Both now come back as a placeholder chip that names the
image and says it wasn't saved with the draft. Send stays disabled, and
the send button says to attach the image again or remove it. Attaching a
file with the same name takes the placeholder's place.

Two browser tabs of the web client on one chat now follow each other: when
one sends or edits the draft, the other drops its copy and shows the new
one, unless it has an edit of its own not yet saved.

* test(native-chat): no send while an image waits to be attached again

* fix(native-chat): pasted images in a draft come back after a restart

A pasted screenshot lived in the OS temp folder with a read permission held
in memory, so a draft could only bring it back as a placeholder. Local
pastes are now written to Orca's own native-chat-pastes folder under the
app's user data, and a draft keeps them as real images. On restore the
composer asks the main process to re-grant each one; main grants a read
only when the file's real path, symlinks and junctions resolved, is inside
that folder, and reports anything else as not kept, which becomes the
placeholder. Pastes older than 30 days are deleted at startup, far past
the host's 24 hour window for a resent message; the sweep never follows a
link and never blocks startup. SSH pastes and older temp-folder pastes
still come back as placeholders.

* test(native-chat): the paste sweep test ages the link itself

* fix(native-chat): type the paste-folder path helper with PlatformPath

* fix(native-chat): type the paste-folder path helper from path.posix

* fix(native-chat): placeholder chips say what happened and only a user attach replaces one

A placeholder now shows the image's name and a short hint on the chip
itself, "Attach again" for a file and "Not kept" for a pasted image, with
the longer explanation in its tooltip. The copy no longer says the image
wasn't saved: it says it couldn't be brought back, and for a pasted image
it asks only for removal, since a new paste can't match it. The send
button now says to remove the image to send.

Only an image the user attaches (picking, dropping or pasting) takes the
place of a placeholder with its file name. An image Stop gives back is
added beside it, so a different file with the same name no longer hides
the reminder.

* fix(native-chat): only composer pastes use the paste folder, and restore grants only real paths

Every local clipboard image save had moved into the paste folder, whose
macOS path has a space, so a screenshot pasted into a terminal or sent to
a terminal-backed agent no longer attached. Only a native-chat composer
paste goes there now, through an optional field on the existing save
call; terminal, editor and phone pastes stay in the system temp folder as
before. An image path sent to an agent's input is escaped the way a
dropped image is.

The restore re-grant now grants only the file's real path, and only when
both the real path and the path as written are inside the paste folder,
which must not itself be a link. The sweep deletes only Orca's paste
files and skips a linked folder.

* fix(native-chat): a restored paste is readable by the path its draft stored

Restore granted only a paste's real path, but the composer reads the
preview by the path its draft stored, so with user data reached through a
link the restored chip showed a generic icon. The stored spelling is
granted too; it is already proven to sit inside the paste folder. A test
also pins that a composer paste asks for the paste folder.

* fix(native-chat): grant a paste's stored spelling only when it names the same file

A grant also covers its path's own real path, so a stored spelling that is
itself a link inside the paste folder could have granted an outside file
while the path as restored reached a real paste. The stored spelling is
now granted only when it resolves to the same file; otherwise only the real
path is, and the preview falls back to the generic icon.

* test(native-chat): check the outside file by its real path too

* fix(native-chat): a structured chat's draft belongs to its conversation

Drafts of a structured chat were saved under the pane (tab id plus a hash
of the session), so the follow-up that keys them by conversation would
have left every draft saved by this build invisible after an upgrade,
never shown and never deleted. They are now keyed by the conversation
(`agent-session:<sessionId>`) from the start: every composer showing the
conversation shares one draft, Stop and a queued card's Edit give text
back to it, closing the tab keeps it, and removing the worktree deletes
it. Terminal-backed chats keep the pane key and lose their draft when the
tab is closed.

A send now leaves whatever was added since it was sent, text typed or
composed and images attached meanwhile, and clears only what was sent;
a draft replaced in the meantime is left alone.

Lifted from #25207 (62ef8e7): the conversation key, the composer's
draftScopeKey, keep-on-close and delete-on-worktree-removal, and the
leave-what-was-added send settle.

* fix(native-chat): every path that drops a worktree's tabs deletes their drafts

A worktree's chat drafts were deleted only by the removal's own renderer
teardown, which looked them up from the worktree's tab lists after the
removal round trip. The host announces the change before it replies, and
the listing refresh that starts can purge those tab lists first, so the
teardown found no tabs and the drafts stayed. The bulk purge now deletes
the drafts of the tabs it drops, and the teardown deletes them before it
closes any tab.

* fix(native-chat): only a user's delete removes a workspace's chat drafts

The listing purge is reconciliation, not a delete: re-pairing a server under
the same id purges its worktrees' tabs, and the host then restores the same
chats under the same draft keys, so deleting drafts there erased them.

Removing a worktree, a project or a folder workspace now reads the draft
keys of the workspace's open chats before the host round trip, and deletes
them only once the host confirms. A listing refresh that drops the tabs
during the round trip no longer hides them, and a refused delete keeps them.

* feat(native-chat): drafts live in IndexedDB with no budget, and a refused save shows

Unsent drafts shared localStorage with the send outbox, so they kept to a
128-draft, 1M-character budget and the oldest quietly did not come back.
They now live in their own IndexedDB store, all loaded once at startup,
with no budget and no eviction.

- Startup waits up to 1.5 s for the load alongside the session read; a
  slower load still fills in every draft not edited meanwhile.
- Each write commits explicitly; on pagehide, beforeunload and hiding,
  anything storage has not confirmed is also journaled synchronously to
  localStorage and replayed by the next run.
- A BroadcastChannel replaces the storage event: another window's save is
  taken unless this window has a change of its own not yet saved.
- A refused save is retried on the next flush and shown: a warning icon
  by Send and on the draft's image chips, only after a refusal.
- Each draft records the workspace and host it was written in, so removing
  a worktree, project or folder workspace also deletes the drafts of chats
  whose tab was closed earlier.

* fix(native-chat): drafts set from the store are never saved back, and early appends merge

- The editor no longer saves a value the field sets from the store (a
  late-loaded or adopted draft): that echo made another window's draft a
  local change here, so a send in one web tab came back from the other.
  A whole draft set on the editor uses its saved document, keeping its
  skill chips. Equal documents no longer count as a change.
- Text or images given back, or attached, before the startup load lands
  are added to the loaded draft instead of replacing it; only typing wins.
- The unload journal leaves out drafts already refused, keeps to 256,000
  characters, and drops a draft's entry once any window confirms a change
  to it, so a replay never brings back a draft sent since.
- A failed load is retried three times with backoff, warned about once,
  then left.
- Adopting another window's write keeps images already checked, so their
  chips don't flash.
- A refused draft shows one icon, by Send, with plainer copy; the chip
  badge and its string are gone.
- A comment that said drafts' bounds retire orphans is corrected.

* test(native-chat): an early append the load already read is not added twice

* test(native-chat): the store-value editor test waits for the editor and checks no document is saved

* fix(native-chat): early appends are read-modify-writes, and the load can't be skipped

- Before the load lands, an append (text or images given back, a paste)
  reads the stored draft and writes it back with the append in one storage
  change, so a load that failed and was retried can never lose the saved
  draft. On landing, only appends that load could not have read (made after
  it began reading, or never written) are applied again, by in-run order
  rather than by comparing clocks.
- The editor saves a value set from the store when this window has a change
  of its own behind it, so a mention accepted next to a skill chip keeps the
  chip; another window's draft or a late load still isn't saved back.
- A refused draft is journaled again; the 256,000-character cap bounds it.
- The draft load starts before the startup chain, and the first write
  starts it in a window whose startup never did; startup still waits for it
  before the session's tabs mount.

* fix(native-chat): text given back is durable before its source is deleted

Draft writes reach IndexedDB asynchronously, but Stop's restore and a queued
card's Edit delete their source (the outbox entry, the card) right after
adding the text to the draft. A crash before the write committed lost it.

An addition (text or images given back or attached) is now journaled to
localStorage synchronously, as data, the moment it is made, and dropped
once storage confirms a change at least as new. The next run replays it onto
the stored draft only when storage doesn't already hold it, and only where
the draft doesn't already end with that text or hold that image, so a crash
either side of the commit gives the text back exactly once and resends
nothing. Only earlier runs' entries are replayed.

The append reports whether the addition is durable. Edit, when it isn't
(the journal full or refused), deletes the card only once the draft is
saved, and keeps it if storage refuses. Additions may grow the journal to
800,000 characters; whole drafts still stop at 256,000.

* test(native-chat): a kept paste is readable by the composer preview through chat-image access, with no grant

* fix(native-chat): a per-scope write confirm, and the paste rule as C2 has it

- nativeChatComposerDraftWriteSettled(scopeKey) settles once every write of
  that scope issued before the call has: true only after IndexedDB completed
  each transaction (an append made before the load landed included), false
  when one was refused or failed, and never on a timeout. A change still
  waiting for its deferred write is issued first.
- A queued card's Edit deletes the card only on true, and keeps it on false.
- A restored paste is kept when its real path is a file inside Orca's paste
  folder, as C2 resolved it. The tests keep every containment case and now
  check that neither an outside file nor the file a stored spelling names
  becomes readable through the preview's chat-image access, which reads only
  image files and needs no grant.

* test(native-chat): the per-scope confirm settles at the IndexedDB transaction's complete, and false on abort, for an append before the load

* fix(native-chat): a journaled removal always fits, and a load replays only what preceded it

- A removal is never capped in the journal (it is tiny, and it is what
  keeps a sent draft from coming back); only whole drafts count against
  their 256,000 characters, and additions keep their own 800,000.
- A load replays the journal as it stood when the load began reading, so an
  addition a live window journals later, whose own write is ordered after
  that read, isn't replayed over its newer typing.
- A lifecycle test no longer says the paste is re-granted.

* fix(native-chat): a chat with a draft is never reused or taken over

#24917's empty-chat check read the composer draft under the pane key, while
this branch keeps a structured chat's draft under its conversation key, so a
chat holding typed or given-back text, or only an image, read as empty: a new
chat could reuse it and another request's text could go into it. It reads
the conversation's draft now, as C2 adapted it (d4e469b), with the tests
moved to that key.

While the startup load of saved drafts is still running, no chat reads as
empty: one may hold a saved draft memory doesn't have yet. The cost is only
that a new chat opens instead of reusing one during that moment.

* fix(native-chat): a returned text counts as already there only as its own paragraph

Replaying an addition after a crash skipped it whenever the draft merely ended with its text, so
"go" given back onto "please go" was lost. It now counts only when the draft equals the text or
ends with it after a blank line. A whitespace-only draft counts as empty when text is added, and
an addition made before the startup load lands is made again on the loaded draft only where that
draft doesn't already hold it.

* fix(native-chat): a hand-back the draft already holds stays safe until that draft is saved

After merging main's shared returned-text rule, a hand-back the draft
already ends with changes nothing, so it wrote no backup entry yet
reported durable. If the draft holding that text was not saved yet, a
crash after the source copy was deleted lost it. Journal the addition
whenever the draft is not confirmed saved.

* fix(lint): import worktree removal names once in the delete failure toast

Main's #25668 imported from shared/worktree/removal twice in this file,
which fails the type-aware lint gate (no-duplicate-imports) for every
branch that merges current main.
…orded-outcomes-from-journal

# Conflicts:
#	mobile/src/session/MobileNativeChatMessage.tsx
#	mobile/src/session/use-mobile-structured-agent-session.ts
…wn module

Keeps MobileNativeChatView within its line limit after the merge with main.
Drafts take #24905's merged store. A hand-back's text follows the shared
returned-text rule (#25149) through returnNativeChatDraftText, which trims
only the end and now trusts the append's durable answer; a composer is told
of an append only when it changed the draft. The withdrawn-message restore
test stays deleted: its one change was the rule's dedupe, which C2's
returning tests already state. messagesUnsettled keeps C2's 'wait' in the
moved reason-words table.
…livery lines

The merge brought main's Spanish catalog sync for three NativeChatStructuredSession
strings that C2 removed (no Retry; the delivery line moved), so es.json held keys
en.json no longer has and the catalog check failed.
…n C2's settlement

Main's withdrawn-send test drives the outbox reconcile module C2 folded into
its settlement, so it could not load. Its two cases are ported as C2 states
them: a send the host withdrew at a Stop goes back silently, and one the host
left in doubt is its record (no Retry holds the queue); either way the next
send goes out.

Two Stop tests read the draft right after the Stop, but since the hand-back
starts the drafts' load itself and waits for it (R18), they now wait for it.
… own not-sent row

The image matcher read rows through a helper that dropped every not-sent row, so a
captioned image send the host accepted and then rejected kept its sent-looking
placeholder beside its own not-sent row. It now applies the send-time rule the text
matchers use: only a not-sent row already on screen when the send went out can't be
its echo. The helper is gone; each matcher states its not-sent rule itself.
A captioned image send's own not-sent row retires its phone placeholder;
each echo caller states its own not-sent rule.
…before its copy goes

A launch seed shown but never saved is stored as an empty draft. Text given
back that equals it changes nothing in memory, so the append reported it
durable while storage held nothing: the outbox entry left and a reload lost
the text. The no-op answer now also needs the draft's text to be saved, so
this case is journaled like any other addition.
With a not-sent message followed by its failed start's row at the bottom, the rail
lit no tick: the walk-back skipped only rows that were themselves not sent, and the
start's row resolved to no tick or to the not-sent message's id. The lit tick now
walks back to the nearest row whose tick exists, skipping rows in no turn and rows
whose turn a not-sent message names.
Three delivered messages with replies, a not-sent message, then the failed start's
row last, built through the renderer's own journal-to-slots path: the third
message's tick stays lit when the chat fits and is pinned to the bottom, and with
either row at the fold. With host-stated turns the start row is in no turn, which
is the layout that lit nothing before the walk-back fix.
…jected-command test

Main's chat appearance settings (#25657) replaced the message list's fontScale prop.
Brings main eaaae01 and #24918's rail-tick fix. Conflicts: the rejected-in-place
and delivery tests keep C2's cases on main's fake probe clock; the rejection-cause
test stays deleted (main changed only its timers); zh.json takes main's 智能体 wording
beside C2's keys. A C2-only test's font mock follows main's font-size rename.
…up load is in

Since 50ac8c9 a hand-back waits for the drafts' startup load, so the draft is not
written in the same tick as the Stop. The test read it synchronously and failed (also
red at 3bfc93e); it now waits, as the other Stop tests do.
@brennanb2025

Copy link
Copy Markdown
Contributor Author

Closing this in favour of #25959. Both PRs rewrote how a desktop chat message gets sent and what happens when sending fails. This one kept every outgoing message in a saved outbox (localStorage) and quietly resent it, even after a reload. #25959 keeps the message in memory only: one send per chat at a time, a same-id resend for up to 30 seconds when the connection is in doubt, then the text goes back to the chat's draft with the reason. Brennan chose the in-memory design for desktop. The common pattern keeps a pending desktop message in memory, and a saved outbox stays a phone-side concern.

What moved where:

Still open, for #25959 to decide: if Orca is killed roughly 5–50 ms after Enter, the cleared draft has already reached disk but the host hasn't recorded the message yet, so the message can be lost. Senders that keep the message in memory share this window. The alternative is an Orca-only guard: the write that clears the box also saves a "sending" copy, which is handed back after a crash.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant