Repository navigation
fix(native-chat): every chat send ends one of three ways, decided by the host's answer - #25207
brennanb2025 wants to merge 191 commits into
Conversation
…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.
…t original in place
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
…2025/chat-new-launch-after-failed-start
…led-send-returns-to-composer
…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.
…fts-survive-reload
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.
* 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.
… load waits for it
…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.
…orded-outcomes-from-journal
…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.
…orded-outcomes-from-journal
…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.
|
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. |
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:
There is no Retry button any more.
The problem, as you saw it
What changes for you
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.tsmaps 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.tsis 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: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.
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:
Differences from the common pattern
agent-session.queued-messages.v1).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.
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.
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.
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.
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.
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.
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.
Notes across a reload.
Also checked:
Not captured:
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.tscovers 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.tsxandstructured-agent-session-returned-send.test.tscover: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
structured-agent-session-send-failure-words). The phone's behaviour is unchanged.