Skip to content

fix(native-chat): an unsent draft survives a reload or quit - #24905

Merged
brennanb2025 merged 48 commits into
mainfrom
brennanb2025/chat-drafts-survive-reload
Oct 6, 2026
Merged

brennanb2025 merged 48 commits into
mainfrom
brennanb2025/chat-drafts-survive-reload

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 30 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​3106 $\color{#cf222e}{\Huge{\mathbf{−}}}$​68 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​3038
Prod 57 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​2675 $\color{#cf222e}{\Huge{\mathbf{−}}}$​366 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​2309

ELI5

If you type a message in a chat and don't send it, Orca now keeps it when the window reloads or Orca quits. The same goes for a message that Stop or a queued-message Edit puts back in the box. Before, that text was gone.

What Changed

The problem. The chat composer kept your unsent draft (text, its skill and file chips, and attached images) only in memory, so a reload, quit, update restart or crash threw it away.

That also hit text Orca gives back to the composer: Stop returns not-yet-sent messages there, and editing a queued message moves its text there. Its outbox entry or queued card is deleted right after, so a reload before you re-sent it lost the message for good.

Before / after, as you see it.

  • Half-typed message, then a reload or restart. Before, the composer is empty. After, the same text and chips come back in the same chat pane, along with images attached from your own files.
  • Stop gives a message back, then Orca restarts. Before, the message is gone. After, it is still in the composer.
  • Sending or clearing removes the saved copy at once, so a sent message never comes back.
  • Closing a terminal-backed chat's tab deletes its saved drafts. A structured chat's draft belongs to its conversation: closing its tab keeps it, so reopening the conversation brings it back. Removing a worktree, a project or a folder workspace in Orca deletes its chats' drafts, including a conversation whose tab you closed earlier (see "Differences" for what that misses).
  • Pasted images now come back after a restart as the real image, with their preview, and can be sent. An image Orca can't bring back (a paste over SSH, from before this change or older than 30 days, or a file deleted since) comes back as a placeholder chip showing its name and a short hint ("Attach again" for a file, "Not kept" for a pasted image). Its tooltip says " couldn't be brought back with this draft. Attach it again or remove it.", or for a pasted image, "This pasted image couldn't be brought back with this draft. Remove it, and paste it again if you still need it." Send stays disabled, and the send button says "An image couldn't be brought back. Remove it to send." (see "Differences").
  • Every draft comes back, however many there are and however long; drafts have no size or count limit.
  • A draft Orca couldn't save (for example on a full disk) shows one small warning icon by Send, with the tooltip "This draft couldn't be saved yet. Orca keeps trying." It appears only after a save was refused and goes once a later save lands.
  • Untouched launch text that Orca put in the composer is not brought back after a reload either.
    • The same text also waits in the agent's input line, and only the current run knows to clear it before sending.
    • A send clears one line of that input, so a second copy of multi-line launch text would be typed twice.

The mechanism.

  • One owner per draft. A new store (native-chat-composer-draft-store.ts) holds each draft whole in memory: text, editor document and image references. A structured chat's draft is keyed by its conversation, so every composer showing that conversation shares it; a terminal-backed chat's is keyed by its pane.
    • Every change goes through it: typing, editor updates, images added or removed, text given back by Stop or Edit, and the clear after a send.
    • The composer renders from it and keeps no copy of its own; only chips still being written, and an IME composition in progress, stay local. The existing text and image caches are thin views of it, so their other callers don't change.
  • Saving. Drafts live in their own IndexedDB database, one record per draft keyed by its scope, always written whole from memory. The scope is agent-session:<sessionId> for a structured chat and the pane for a terminal-backed chat.
    • Why IndexedDB: the send outbox, and in the web client the whole workspace session, live in localStorage, and a send is refused when its outbox entry can't be saved. Drafts used to share that quota, so they kept to a budget; in their own store they need none, and nothing is pushed out.
    • Typing is saved within 250 ms. Saved at once: given-back text, image changes, and clears.
    • Text or images given back (by Stop, or a queued card's Edit) are also written to the journal the moment they are added, as data, because their source (the outbox entry, the card) is deleted right after and a crash fires no unload event. The next start replays such an addition only when storage doesn't already hold it, and only where the draft doesn't already end with that text, so a crash on either side of the commit gives the text back once and resends nothing. A queued card's Edit deletes the card only once storage confirms the draft holds the text (a per-scope confirm that settles when IndexedDB completes every write to that draft issued before it), and keeps the card if storage refuses. An emptied draft deletes its record at once; a change that alters nothing writes nothing.
    • Given-back text follows the same rule the desktop and phone composers share: it goes after what you've typed, a blank line apart; a draft of only spaces counts as empty; and it isn't added again when the draft already ends with it as its own paragraph.
    • Each write commits explicitly. When the window hides, reloads or closes, anything storage has not confirmed yet is also written synchronously to a small localStorage journal, which the next load replays. A crash can still lose the last moments of typing.
    • The journal never threatens a send's own save: whole drafts together keep to 256,000 characters, and given-back additions (at most one message each, with their source deleted right after) to their own 800,000. A removal always fits: it is tiny, and it is what keeps a sent draft from coming back.
    • A load replays the journal as it stood when the load began reading storage. An entry journaled later belongs to a window still open, whose own write is ordered after that read, so replaying it would overwrite that window's newer typing. A draft storage refused is journaled too, so it comes back after a quit. A draft's entry is dropped as soon as any window confirms a change to it, so a replay never brings back a draft sent or replaced since.
    • The editor doesn't save a value the composer sets on it from the store unless this window has a change of its own behind it. A draft loaded late or taken from another window is already saved; saving it back would make it a change of this window, which would then win over the other window's newer save or send. A change this window just made (for example, a mention accepted next to a skill chip) keeps its document. A whole draft set this way uses its saved document, so its skill chips come back. A document equal to the saved one is not a change.
    • Each change applies to the draft as it is now: an added image or file reference joins what is there, so a slow attach (for example an SSH upload) finishing in a composer that was replaced since cannot bring back what was sent meanwhile.
    • A send that settles after a round trip, after its composer was replaced (for example by a question prompt while a /goal command is accepted), or while another composer of the same conversation was in use, removes only what was sent. Text typed or composed meanwhile and images attached meanwhile stay; a draft replaced or edited inside the sent text is left as it is. A goal still clears only a draft that is exactly as sent.
  • Loading. Every draft is loaded into memory once, starting before any startup step (so a startup that fails part-way still loads them), or at the first save in a window whose startup never started it. Startup waits for it at most 1.5 s, before any tab mounts, so a composer shows its draft from its first frame; a slower load still fills in every draft. A draft typed into before it lands keeps what was typed. Text or images given back or attached before it lands are written onto the stored draft, read and written in one storage change, so the saved draft is never replaced; when the load lands, only the additions it could not have read are made again, by in-run order rather than by comparing clocks. A load that fails is retried three times, after 1, 5 and 30 seconds, with one warning in the log; after that the drafts stay in memory, and a save that is refused shows as one.
  • Other windows. When another window of the app (two web-client tabs) saves or clears a draft, it says so on a broadcast channel, and this window takes the new copy unless it has a change of its own not saved yet. Images this window already checked stay checked, so their chips don't flash. The desktop app has one window.
  • When storage refuses a write. The draft stays in memory, is retried on the next save, and shows the warning icon until a save lands.
  • Never saved: images still being written, preview thumbnails, the file of a pasted image that isn't in Orca's paste folder (only its name, so it can come back as a placeholder), and an untouched copy of launch text.
  • Old records. This branch's earlier localStorage draft records are deleted once at startup; no released build ever wrote them.
  • Pasted images are kept. An image pasted into a native-chat composer on this machine is written to a native-chat-pastes folder in Orca's own user data instead of the system temp folder, and the draft saves it as a real image. Terminal, editor and phone pastes still go to the system temp folder, as before. On restore, the composer asks the main process whether each paste is still kept: only when its real path (symlinks and junctions resolved) is a file inside that folder, which must not itself be a link. Anything else becomes the placeholder. Nothing is granted: the preview reads the file with main's chat-image access (fix(editor): restored tabs for files outside your projects no longer fail with Access denied #24489), which reads only image files and refuses devices and network shares, so the restore can't make any other file readable. Until main answers, the restored chip shows as still loading, so a paste that turns out not kept never flashes.
  • Expiry. Pasted files older than 30 days are deleted at startup. Only Orca's own paste files are deleted; the cleanup never follows a link or a linked folder, and a failure is logged and never holds up startup.
  • Sending to a terminal-backed chat. The paste folder's path can contain a space ("Application Support" on macOS), so an image path sent to an agent's input is escaped the way a dropped image is, which agents undo before checking the file.
  • Restored images are checked. Other restored images are checked through the existing file check (the workspace's own read rules locally, the host over SSH). One that is gone becomes the same placeholder. An image Orca may not read without a fresh permission (outside the workspace) can't be checked, and stays as it was.
  • Re-attaching a file with the same name, by picking, dropping or pasting it, puts it in the placeholder's place, as the common pattern's re-pick does. An image Stop gives back is only added, never put in a placeholder's place. Orca keeps no file size, so the match is on the name alone. A new paste never matches a pasted placeholder, which is why that placeholder's copy asks only for removal.
  • Deleted when you close a terminal-backed chat's tab, or when you remove a worktree, project or folder workspace in Orca. A structured chat's draft is not deleted when its tab closes.
    • Each draft records the workspace and host it was written in, resolved the same way a removal resolves its host, so removing the workspace also finds the drafts of conversations whose tab was closed. Recording it is safe because a conversation never moves to another workspace. The open tabs' keys are also taken, for a draft written before its workspace could be named.
    • The delete reads which drafts are the workspace's before it asks the host, and removes them only once the host confirms, so a refused delete keeps them, as the common pattern clears a draft only after its delete succeeds.
    • Why read first: the host announces a removal before it replies, and the listing refresh that starts can drop the workspace's tabs before the removal's own cleanup runs. Earlier that cleanup looked the drafts up afterwards, found no tabs, and the drafts stayed. Found in manual QA.
    • A listing refresh never deletes drafts. Re-pairing a server under the same id purges its worktrees' tabs too, and the host then restores the same chats, which find their drafts again.

Why these keys.

  • A structured chat's draft is keyed by its conversation, agent-session:<sessionId>. The session id outlives the tab, so a closed and reopened conversation finds its draft, and two panes showing one conversation share it, as the common pattern keys a draft by its conversation.
  • A terminal-backed chat has no conversation id Orca can see, so its draft is keyed by its pane: the tab id plus the pane's stable id. Tab ids are restored with the same id, so the key survives a restart. Reopening a closed tab (Cmd/Ctrl+Shift+T) makes a new tab id, so a deleted draft can't belong to a reopened tab.

Why

Keeping an unsent draft across a reload is the common pattern, and this matches its mechanism: one owner writes the whole draft, typing is saved after a short wait and flushed when the page hides or unloads, app-originated changes save at once, and an empty draft is removed.

Alternatives considered:

  • A separate record for returned messages: it would protect Stop's text but still lose ordinary typing, and it would be a second owner.
  • Saving image bytes: they would compete with the outbox for the same storage quota.
  • Re-granting any stored path at startup: that would trust a stored path through a call that checks nothing. Instead the main process grants only files whose real path is inside Orca's paste folder.

Differences from the common pattern

In short: the core works the way the common pattern does. Each conversation's unsent draft is saved, and comes back after a reload or restart with its text, formatting and images. Sending clears it, and it is deleted along with what owns it. The list below is long because it spells out honest edge cases, not because the design differs much. Three differences matter:

  • Separate storage. Drafts live in their own browser database, not in the storage the send outbox uses, so a draft can never stop a message from being sent.
  • Paste expiry. Pasted images are deleted after 30 days, not when their draft is sent.
  • Terminal chats. A terminal-backed chat's draft belongs to its pane.

Orca-only needs

  • Intended: a terminal-backed chat's draft belongs to its pane, not a conversation. Orca has no conversation id for an agent running in a terminal, so the pane is the only stable owner. Closing that tab deletes the draft. A structured chat's draft belongs to its conversation, as in the common pattern.
  • Intended: launch text Orca puts in the composer is shown but not saved until you edit it. Orca also types that text into the agent's input line, and only the current run knows to replace it when you send. Untouched launch text isn't there after a reload; the agent's input line still holds it.

Storage

  • Intended: drafts are kept in their own browser database (IndexedDB), not in localStorage. Orca's send outbox lives in localStorage, and a full localStorage would make sends fail. So there is no draft limit and nothing is dropped to make room. The common pattern uses localStorage because its drafts never compete with sends.
  • Intended (Orca-only): a draft that can't be saved shows a small warning by Send: "This draft couldn't be saved yet. Orca keeps trying." The draft stays open and is retried on every save. It is also kept in a small backup written when the window closes. Only a failed save shows it, so it never flashes. The common pattern marks only images it couldn't save; Orca marks the draft so no typed work is lost without notice.
  • Intended (Orca-only): a small backup in localStorage covers the moment of quitting. The browser database saves asynchronously, so changes not yet confirmed when the window closes are also written there, capped at 256,000 characters, and replayed on the next start. The common pattern saves synchronously and needs no backup.
  • Intended: drafts load at startup, before chats appear. Startup waits at most 1.5 seconds for them. A slower load still fills drafts in, and anything you type first is kept. The common pattern reads drafts synchronously, so it has no wait.
  • Intended: the save timer isn't restarted on every keystroke. Continuous typing is still saved every 250 ms.

Cleanup

  • Intended: a structured chat's draft survives closing its tab. It is deleted when you remove its worktree, project or folder workspace in Orca, including conversations whose tabs were closed earlier. Each draft records which workspace owns it. Deletion happens only after the removal succeeds, as the common pattern clears a draft only after its delete succeeds. A listing refresh never deletes drafts, because the same refresh also runs when a server re-pairs, and deleting then would erase live drafts.

  • Intended: drafts have no expiry, so a few can be left behind for good. Examples:

    • a closed split pane;
    • a terminal tab closed by its process exiting;
    • a worktree removed from another device or outside Orca;
    • a draft whose workspace couldn't be resolved when it was written.

    One common design never deletes drafts at all; another deletes them only with their conversation. Expiring drafts would mean losing someone's text without notice. Leftovers are small (text and file references) and are loaded at each start.

  • Temporary: the owner check uses the worktree's current host route. A draft can be left behind for a same-id worktree on two hosts, an SSH repo reached through a paired server, or an older folder workspace without a recorded host. It never deletes another workspace's draft. Follow-up: record the tab's own host; not yet filed.

Images

  • Intended: pasted images are deleted after 30 days, not when their draft is sent. The same file is what the chat history shows for that sent message, and what a queued or retried send reads later. Deleting it at send would break both. Consequences:
    • after 30 days that history image shows as missing;
    • an older unsent draft shows its paste as an "Attach again" placeholder;
    • the folder holds at most the last 30 days of composer pastes.
  • Temporary: a pasted image in an SSH workspace comes back as a placeholder, not the image. Its file lives in the remote host's temp folder. Follow-up: an Orca-owned paste folder on the remote host; not yet filed.
  • Temporary: an image attached from outside the workspace (for example, Desktop) shows a generic icon after a restart. Orca's permission to read it lasts only for the run. Sending still works while the file exists. Follow-up: re-grant on a fresh attach; not yet filed.
  • Temporary: a dragged macOS screenshot thumbnail is a 7-day copy. After about a week its restored chip points at a deleted file, and sending it fails with "An attachment on this message couldn't be read". Follow-up: store that copy in the paste folder too; not yet filed.

Rare edge cases

  • Temporary: edited multi-line launch text can be typed twice after a reload. After a reload a send clears only one line of the agent's input. Follow-up: the launch rework in fix(native-chat): a new chat starts at rest; its first message starts the agent #24450.
  • Temporary: with one conversation open in two panes, a send that finishes while the other pane is mid-IME composition can bring the sent text back. Traced in code, not run. Follow-up: the send-settlement PR, which owns that code.
  • Temporary: in the web client, a pasted image just added in one browser tab can turn into a placeholder when another tab saves the same conversation's draft. Follow-up: keep this-run-only parts when taking another tab's save; not yet filed.
  • Temporary: a message given back onto an empty draft and then replayed after a crash isn't deleted when its workspace is removed; it stays until it is sent or cleared. The replayed draft doesn't know its workspace. Follow-up: carry the workspace in the journal entry; not yet filed.
  • Temporary: text Stop gives back can be lost in one narrow case. It is written to the small quit backup the moment it is given back, before its queued copy is removed. Only if that backup is refused (for example, storage completely full) and Orca crashes before the draft is saved is the text gone. Follow-up: the send-settlement PR keeps the queued copy until the save is confirmed.

Not this PR: returned text is added after what you've typed (already on main). The common pattern restores it only into an empty composer.

Linked Issue

N/A (maintainer). This is the draft-durability step of the native-chat failed-send follow-up to #24606.

Visual Proof

Captured on Windows in hidden, isolated app instances with a stand-in agent. Before is the base commit (cc73c8e); after is this PR at 9a7f9c9 for the table below and at the final head 4b9f91a for the images table; the conversation-keying and removal changes are shown below at their own heads.

Step Before After
Three-line draft typed
Window reloaded: the draft is gone before, back after
App quit and relaunched
Text plus an image dragged from the file explorer
Reloaded: text and image back after
A sent message, then reload: it doesn't come back
Stop gives a pending follow-up back to the composer
Reloaded: the given-back text is gone before, kept after
Chat tab closed, new chat opened: the new chat starts empty (shot at 9a7f9c9; a structured chat now keeps its draft when its tab closes, see below)

Images after a restart (final head 4b9f91a, Windows; before this PR every row here was simply gone):

Step After
Screenshot pasted into a draft
Quit and relaunch: text and pasted image back with their preview
Sent: the agent received the same image
Attached file deleted on disk, relaunch: "Attach again" chip, Send disabled ("An image couldn't be brought back. Remove it to send.")
Chip removed: Send works again
Same file attached again: it replaces the placeholder
A terminal pane paste still writes to the temp folder, not the draft folder

Drafts keyed by conversation, deleted with the workspace (M4Air, macOS):

Step After
Typed draft, saved under its conversation (shot at edd2436; same keying as the final head)
Reloaded: back
Quit and relaunched: back
Pasted screenshot, relaunched: back with its preview
A workspace with an open chat holding a draft (506804c)
Deleted through the Delete Workspace dialog
After: its saved draft is gone; the other workspace's draft stays

Manual QA first caught that removing a workspace left its draft behind (a listing refresh could drop the tabs before the removal finished); the fix reads the draft keys before the removal and deletes them after the host confirms, shown above.

Drafts in their own storage (final head 393eb7f and 2fc2929, M4Air, macOS):

Step After
Reloaded: draft back (stored in IndexedDB, nothing in localStorage) — 393eb7f
Quit 25 ms after the last keystroke, relaunched: back with the last keys — 393eb7f
Stop gave a queued message back, reloaded: still there — 393eb7f
A skill chosen from the picker shows as a chip
Reloaded: still a chip
Pasted screenshot, quit and relaunched: back with its preview
Workspace deleted through the dialog: its drafts are gone, including one whose tab was closed earlier; other drafts stay

After merging main (8b6c8c5, M4Air; the later commits change only the quit backup's rules):

Step After
Pasted screenshot, quit and relaunched: the image itself is back (read through the app's chat-image access)
Stop gave a queued message back and the app quit in that same frame: after relaunch it is there exactly once
Workspace deleted through the dialog: its draft is gone, the other three stay

Final head (071c934, Windows):

Step After
Stop gave a queued message back and the app quit in that same frame: after relaunch it is there exactly once
A chat holding a typed draft is not reused when another chat is requested: a new tab opens and the text stays (an empty chat is reused, as before)

Typing check on the after build: 223 characters typed fast, an insert mid-text, then an input-method composition ("nihao" committed as 你好). No lost or doubled characters, the caret stayed in place, and the same text came back after a reload. The keys and composition were sent through the browser debugging protocol, not a real OS input method.

Typed After reload

Not captured: the "couldn't be saved yet" warning by Send (a refused save could not be forced in the app; unit tests cover it), the queued-message Edit path (still behind a feature flag), quitting from the menu (the rig closed the window or used the app's own quit), images attached from outside the workspace, two web-client tabs, and the send button's hover tooltip (its accessible label, which the tooltip shows, was checked), reopening a closed structured chat (the rig has no way to reopen one; closing keeps its saved draft, checked in storage), and one conversation shown in two panes (no UI to do that).

Testing

  • native-chat-composer-draft-indexeddb.test.ts (5 tests): against a stand-in database, a write and a read-modify-write settle only when their transaction completes, and a write whose transaction aborts is refused. Through the draft store, the per-scope confirm for a draft given text before the load landed stays pending until its transaction completes, then resolves true, and resolves false when the transaction aborts.

  • native-chat-composer-draft-store.test.ts (40 tests). These simulate a reload by resetting module memory while keeping storage, an in-memory stand-in for IndexedDB behind the same interface (the IndexedDB adapter itself is thin, and is proven in the app). They cover:

    • a draft sent while storage refused never comes back, however much was given back to it first; a load doesn't replay an addition a live window journaled after the load began reading;
    • text given back and then a crash before storage committed it: back once on the next start, also when it was given back before the load landed; and a crash after storage committed it but before the window confirmed: back once, images included;
    • text, chips and images restored;
    • a pending save written on pagehide;
    • a write storage never confirmed before the window went away, replayed from the journal by the next run and then written to storage, with the journal gone;
    • a closed window's journal never replayed over a draft another window sent since;
    • a refused draft journaled and back after a quit, with the journal under its cap;
    • text and images given back before a slow load lands added to the loaded draft;
    • a load that failed once and is retried after text was given back keeps the saved draft, and an addition made after the retried load began reading is kept too;
    • the first save starts the load in a window whose startup never did;
    • a failed load retried three times with one warning, then left;
    • an equal document from another window's draft not counted as a change here;
    • an image already checked staying checked when another window saves the draft;
    • given-back text and images saved before their source goes;
    • send clearing even with typing pending;
    • no loss past the old budget: 200 drafts and over 5,000,000 characters, plus a returned message of the largest size a send allows, all come back;
    • a refused write shown as not saved, retried on the next save with no new change, and cleared once it lands;
    • a slow startup load: typing before it lands keeps the typing, and the load still fills in the other drafts after startup stopped waiting;
    • another window's save and clear followed over the broadcast channel, while a change here not saved yet wins;
    • a draft records the workspace it was written in, and deleting by that workspace drops a closed chat's draft and nothing else;
    • untouched launch text not saved; a stored record that isn't a draft dropped;
    • a paste in Orca's paste folder saved as the real image, while an SSH paste comes back as a placeholder by name, a file the user re-attaches taking its placeholder's place, and an image Stop gives back added beside it instead;
    • a closed terminal tab's drafts deleted, without touching a tab whose id extends it, while a conversation's draft survives any tab close and goes only when deleted by its key.
  • native-chat-composer-draft-lifecycle.test.tsx (16 tests, real hooks):

    • a send that settles after its composer unmounted still clears the saved draft, through the real send hook;
    • nothing is left after a send with typing pending;
    • a late paste failure in a replaced composer doesn't bring back a sent image;
    • a late image or file reference from a replaced composer joins the current draft, not its old one;
    • a send settling after the user typed new text in the replacement composer keeps that text;
    • an accepted send clears its text and images from the shown composer, a draft replaced while the send was on its way stays, and two composers of one conversation share its draft and keep what the other added;
    • an image pasted while /compact is on its way stays after the command is accepted;
    • a composer whose save is refused keeps its text, is marked not saved, and loses the mark once a retry lands;
    • a restored image whose file is gone becomes a placeholder, while one that can't be checked stays;
    • a restored paste waits until main confirms it is kept, then shows as the real image, and one main doesn't keep becomes a placeholder;
    • another window's send of the same draft, heard over the broadcast channel, replaces this composer's copy, unless this composer has an unsaved edit;
    • untouched launch text is not brought back after a reload.
  • native-chat-prompt-editor-store-value.test.tsx (4 tests, real editor): a draft that loads late is set on the editor and not saved back; a mention accepted next to a skill chip keeps the chip, also after the composer remounts; another window's send stands instead of being echoed back by the window that showed the draft; a draft that arrives later shows its skill chip from its saved document.

  • Placeholder and not-saved UI: NativeChatImageAttachmentPreview.test.tsx shows each placeholder chip with its name, its visible hint, its explanation and the remove button; NativeChatComposerActions.test.tsx shows the disabled send button naming what to do, and the not-saved icon by Send only after a refusal; use-native-chat-composer-submit.test.tsx refuses to send while a placeholder is present.

  • Keys: native-chat-composer-draft-comparison.test.ts covers what a send leaves; NativeChatStructuredSession.test.tsx checks that a structured chat's composer, and the Stop and queued-card hand-back, use the conversation key while drops still route by pane.

  • native-chat-launch-draft-teardown.test.ts: a user tab close deletes only its own panes' drafts; closing a structured chat keeps its conversation's draft; a worktree removal deletes its terminal tabs' drafts and its structured chats' conversation drafts, and nothing else. workspace-chat-draft-removal.test.ts: a worktree removal deletes them even when a listing purge drops the tabs mid-removal, and also deletes the draft of a chat whose tab was closed earlier; a removal the host refuses keeps them; a listing purge alone, and re-pairing a server under the same id, keep them; removing a project or a folder workspace deletes only its own. use-native-chat-launch-draft-adoption.test.tsx was updated for the launch-text change. use-native-chat-composer-submit.test.tsx now drives the draft store: a goal that settles after the draft was edited leaves the edit alone.

  • Ablations, each reverted: each protection was removed one at a time against the final code, and each made at least one of these tests fail. The one exception: removing the immediate delete of an emptied draft stays green, because the save that follows also removes an empty record.

  • src/main/window/native-chat-paste-files.test.ts (26 tests):

    • containment on POSIX and Windows paths: '..' segments, a sibling folder sharing the name, another drive, \\?\ and \\?\UNC\ prefixes, letter case;
    • on disk: a symlink inside the folder pointing out, a path escaping with '..', a folder named like a paste, a missing file, relative and non-string input (never throws, keeps only a real file inside), and the folder reached through a symlinked alias, as /var is of /private/var on macOS;
    • a path that names an outside file as text while its real path is inside (a link followed by ..) is not kept; neither that outside file nor the file a kept paste's stored spelling names becomes readable through chat-image access;
    • a kept paste is readable by the composer preview's chat-image access, with no grant;
    • a paste folder that is itself a link is neither restored from nor swept;
    • the 30-day sweep deletes only old Orca paste files: never a link (even an old one), another file in the folder, a nested folder or a missing folder;
    • the TTL is longer than one send id stays valid on the host.
    • Windows junctions themselves aren't created on this machine; they resolve through the same real-path step the symlink tests cover.
    • clipboard-image-temp-file.test.ts checks that a terminal, editor or phone paste stays in the system temp folder and only a composer paste lands in the paste folder; the clipboard handler tests check that the new call refuses an untrusted sender; native-chat-runtime-send.test.ts checks that a spaced paste path is escaped when sent to an agent's input.
  • Related suites: 530 test files: the ones that use the changed modules, render the app shell, the composer or the editor, or cover startup, sends, tab close, removal, purges and clipboard saves, plus every test file main changed since the last merge. 5,431 tests pass, 14 skipped. 49 files fail the same way on main itself in this checkout: 46 can't load main's new stream-json dependency (this machine's install predates it), and 3 editor image-paste files fail identically on a clean main checkout.

  • Typecheck: web and node projects clean apart from main's own: stream-json not installed in this checkout, and main's rich-markdown-image-insert.test.ts passing insertPos, which its type doesn't have.

  • Lint: oxlint clean; the changed-code quality gate and React Doctor report 0 new findings.

  • Platforms: renderer-only logic on IndexedDB, a localStorage journal, a broadcast channel and window events, the same on macOS, Linux and Windows.

    • SSH: drafts stay on the client, and a remote image keeps its connection id and path.
    • Folder workspaces: pane keys don't depend on git.
    • Run in the app on Windows (see Visual Proof); not run on macOS or Linux.
  • I manually tested these changes locally

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

Review

Agent skill upstream boundary

  • Not applicable, or this change follows docs/reference/agent-skill-sharing-upstream-boundary.md and copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

Notes

  • Merge order with fix(native-chat): start a new chat after an earlier start failed #24917: done. fix(native-chat): start a new chat after an earlier start failed #24917 is on main and merged in. Its check for whether a chat is empty (and can be reused, or take another request's text) now reads the chat's draft under its conversation key, text and images. While saved drafts are still loading at startup, no chat counts as empty, since it may hold a draft not yet loaded; a new chat opens instead. Tests: a chat whose draft holds text, or only an image, is never reused or taken over (these fail with the old per-pane key), and no chat is taken while drafts are loading.

  • Security: drafts are your own text and file references in the app's own storage, as the outbox already does. A restored paste is kept only when its real path is inside Orca's paste folder, checked in the main process; symlinks and junctions are resolved first, and Windows paths are compared without case and without \\?\ prefixes. No path is granted: the preview reads it as a chat image, which main checks on its own. The restore check for other images uses the existing file check, which refuses paths outside what Orca may read.

  • Backwards compatibility: a new key that older builds ignore.

  • Performance: typing does no serialization. Each save writes only changed drafts, and the limits use an in-memory index built by one read per run.

  • Mobile: unaffected (renderer-only modules).

Author: @BrennanKB5

Checklist

  • This PR is small and focused
  • I explained what changed and why (ELI5, the user-facing before/after, the mechanism, and why over the alternatives)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (or CI will cover; local preferred)

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.
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.
…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.
…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
@brennanb2025

Copy link
Copy Markdown
Contributor Author

Review summary

The problem. The native chat composer kept an unsent draft (text, skill and file chips, attached images) only in memory. A window reload, quit, update restart or crash threw it away. That included text Orca itself puts back in the composer: Stop returns not-yet-sent messages there, and editing a queued message moves its text there. That text existed nowhere else, so a reload before you re-sent it lost the message for good.

What changes for you.

  • A half-typed message comes back after a reload or a restart, in the same chat, with its formatting and any images attached from your own files.
  • A message that Stop gave back to the composer is still there after a restart.
  • A sent or cleared message never comes back. Closing a chat tab, or removing its worktree in Orca, deletes its saved draft.
  • A pasted screenshot in an unsent draft doesn't come back after a restart; its text does. Nothing tells you the image is gone. This is a labelled temporary limit (see below).

What the review changed. The first version saved text and images through two separate writers, each patching what storage held. The review proved this could silently drop a draft's images after a reload. It also found:

  • drafts could fill the storage the send outbox needs, so sends would fail;
  • a sent /goal or /compact could come back after a reload;
  • a returned message larger than the size cap deleted the saved draft;
  • launch text could be typed twice;
  • a slow attach in a replaced composer could bring back a sent message or image.

The change now has one store that owns each composer's whole draft. Storage is always written whole from it, and the composer reads straight from the store. Every change applies to the draft as it is now. A send clears the draft only if it still equals what was sent. Total size is bounded so a send can always be saved. Drafts are deleted when you close their tab or remove their worktree in Orca. Pasted images are not saved (see below). These are the same mechanisms the common pattern uses, apart from the labelled differences in the PR body.

Fixed in review (all with tests that fail without the fix):

  • images lost when text and images were saved separately;
  • no total size budget;
  • drafts never deleted on tab close or worktree removal;
  • writes made from inside React state updates or effects;
  • a sent host command or image coming back;
  • returned text over the old size cap lost;
  • untouched launch text saved;
  • a stale composer overwriting newer edits;
  • closing one remote chat tab deleting another's drafts;
  • a pasted image lost when /compact was accepted;
  • memory eviction of a draft that is on screen.

Deferred (each labelled in the PR body):

  • Pasted images and images from outside the workspace after a restart. The pasted image is dropped. An outside image shows a generic icon. A macOS screenshot-thumbnail copy expires after 7 days. Follow-up: image durability, which puts pastes in an Orca-owned folder with their preview restored. A brief is written; not yet filed.
  • Very large drafts. Past about 1,000,000 characters of drafts in total, the oldest are not restored, including text Stop gave back. Follow-up: the return path keeps its source until the save is confirmed.
  • Edited multi-line launch text may be typed twice after a reload. Follow-up: the launch-draft rework (fix(native-chat): a new chat starts at rest; its first message starts the agent #24450).
  • Smaller leftovers:
    • a late SSH attach after its tab closed leaves an orphan record that the limits retire;
    • closing a split pane leaves its draft for the limits to retire;
    • a refused save that is later pushed out of memory is not retried;
    • the web client with two browser tabs on one chat: the last save wins.

What was verified.

  • Automated: 604 tests across the 59 files that use the changed code pass. Each protection was removed once and its test failed. Web typecheck clean; lint and code-quality gates clean; after the main merge, 609 tests across those files pass.
  • Reviews: five rounds plus a readiness pass, including two independent design reviews. No P0 or P1 issues are open.
  • Manual QA on Windows, in hidden isolated app instances with a stand-in agent, before vs. after:
    • a typed draft survives a reload and a full relaunch;
    • an image from a project file survives;
    • a pasted image is dropped while its text stays;
    • a sent message never returns;
    • Stop-returned text survives a reload;
    • closing a tab deletes its draft.
      Also fast typing, an insert mid-text and an input-method composition, with no lost or doubled characters. QA ran on 9a7f9c9; the later merge from main only touched imports, and CI is green on the merged head. Screenshots are in the PR body.

Not verified.

  • macOS and Linux in the app (the logic is renderer-only and the same on every platform).
  • SSH workspaces in the app.
  • The queued-message Edit path, which is still behind a feature flag.
  • Quitting from the menu (the rig closed the window).
  • Images from outside the workspace, and attached files deleted later.
  • What Codex and terminal-backed chats show for a missing image file.
  • The exact browser storage quota the budget assumes.

@brennanb2025
brennanb2025 marked this pull request as ready for review October 4, 2026 05:04
@coderabbitai

coderabbitai Bot commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: df2ef762-fa2d-4265-946a-a9604552d404
📥 Commits

Reviewing files that changed from the base of the PR and between 393eb7f and 99b7ec8.

📒 Files selected for processing (33)
  • src/main/window/clipboard-image-temp-file.test.ts
  • src/main/window/clipboard-image-temp-file.ts
  • src/main/window/clipboard-ipc-handlers.test.ts
  • src/main/window/clipboard-ipc-handlers.ts
  • src/main/window/native-chat-paste-files.test.ts
  • src/main/window/native-chat-paste-files.ts
  • src/preload/api/ui-window-api.ts
  • src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx
  • src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx
  • src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx
  • src/renderer/src/components/native-chat/native-chat-composer-draft-addition.ts
  • src/renderer/src/components/native-chat/native-chat-composer-draft-image-check.ts
  • src/renderer/src/components/native-chat/native-chat-composer-draft-indexeddb.test.ts
  • src/renderer/src/components/native-chat/native-chat-composer-draft-journal.ts
  • src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx
  • src/renderer/src/components/native-chat/native-chat-composer-draft-load.ts
  • src/renderer/src/components/native-chat/native-chat-composer-draft-persistence.ts
  • src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts
  • src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts
  • src/renderer/src/components/native-chat/native-chat-draft-cache.ts
  • src/renderer/src/components/native-chat/native-chat-image-paste.ts
  • src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts
  • src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx
  • src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts
  • src/renderer/src/i18n/locales/en.json
  • src/renderer/src/i18n/locales/es.json
  • src/renderer/src/i18n/locales/fr.json
  • src/renderer/src/i18n/locales/ja.json
  • src/renderer/src/i18n/locales/ko.json
  • src/renderer/src/i18n/locales/zh.json
  • src/renderer/src/lib/structured-agent-session-empty-chat-reuse.test.ts
  • src/renderer/src/lib/structured-agent-session-launch-different-request.test.ts
  • src/renderer/src/lib/structured-agent-session-launch-empty-chat.ts
🚧 Files skipped from review as they are similar to previous changes (6)
  • src/renderer/src/i18n/locales/en.json
  • src/renderer/src/i18n/locales/es.json
  • src/renderer/src/i18n/locales/fr.json
  • src/renderer/src/i18n/locales/zh.json
  • src/renderer/src/i18n/locales/ja.json
  • src/renderer/src/i18n/locales/ko.json

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 8 remain after this review.


📝 Walkthrough

Walkthrough

The change adds persistent, scoped native-chat drafts with IndexedDB storage, recovery journaling, hydration, and cross-window updates. It adds application-data storage and restoration checks for pasted images, plus unavailable-image indicators and send blocking. Composer scopes and send handling now preserve draft content that changes while a send is in progress. Tab and workspace teardown capture and remove drafts for the affected scopes.

Priority: ➖ Normal

Merge Risk: 🔵 Low · up to 99b7e

Draft persistence is broadly in place. A few known follow-ups remain open: a small memory leak from image previews, Windows test fragility around symlinks, and draft saves that can keep failing for the rest of a session after another window upgrades or deletes the database. These are bounded and suitable for follow-up, but the owner should be aware of them before merging.

Security Architecture Review

Security architecture risk: 🔵 Low · up to 99b7e

Image recovery preserves existing access checks and limits restoration to application-owned files. Saved drafts introduce a data-retention obligation: failed cleanup followed by an abrupt exit can leave previously cleared content in storage.

Retained concerns

  • Medium · security · inferred: A previously saved draft can outlive explicit cleanup if storage deletion fails or is interrupted and the application terminates without a lifecycle event. Clear and owner-cleanup operations remove the in-memory record before durable deletion completes; failure leaves a dirty scope, but no immediate recovery tombstone. This introduces a privacy and state-ownership failure mode absent from the former memory-only cache. Graceful-unload journaling and successful transaction completion mitigate it, but do not cover abrupt termination.
Security review details

Security Blast Radius

  • inferred — The identified retention failure affects previously persisted draft records whose cleanup does not commit. It does not demonstrate access by another tenant, an external service, or an untrusted window. Image-restoration authority is separately bounded to the trusted main window and application-owned paste folder.

Security Findings and Attack Paths

  • inferred — The supported privacy scenario is a saved draft followed by explicit clear or owner cleanup, failed or interrupted storage removal, and termination without pagehide or unload. Old content can remain in storage despite the empty in-memory state. This is a recovery failure scenario, not a verified remote exploit.

Trust Boundaries and Controls

  • observed — The new restore handler authenticates the sender before processing paths. Production registration pins clipboard authority to the main window's WebContents ID, and tests reject dashboard-popout restoration. The unchanged policy is window-level rather than frame-level; intended subframe authority remains undocumented.
  • observed — Restoration requires absolute paths inside the configured or real paste folder, resolves links, verifies real-folder containment, and requires a regular file. Outside or unreadable paths return refused metadata. Preview reads retain the existing chat-image controls, including real-target type checks and network/device-path restrictions.

Resilience and Maintainability Implications

  • observed — Storage writes settle on transaction completion or abort, not merely request success. Confirmed changes prune older recovery entries, and failed writes remain retryable. Lifecycle journaling covers graceful departure, including refused clears; abrupt termination before durable cleanup remains the relevant gap.

Hardening Proposals

  • proposed — Give destructive draft transitions a durable removal marker before presenting cleanup as complete, retain it until storage deletion commits, and exercise failed-removal recovery without dispatching lifecycle events. Preserve newer legitimate edits through explicit ordering rules.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 48.85% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 174 functions across 80 files. (6 skipped… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely summarizes the main change: unsent native-chat drafts survive reloads and quits.
Description check ✅ Passed The description is detailed and mostly complete. It covers the user impact, implementation, rationale, linked-issue context, visual proof, testing, limitations, and checklist. It also reports which ch…
Full details: Docstring Coverage

Explanation

Docstring coverage is 48.85% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 174 functions across 80 files. (6 skipped: 6 unsupported.)

✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration
  • Configuration used: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 1ef4bdb1-2882-4c74-a27f-b34ee936ffc2
📥 Commits

Reviewing files that changed from the base of the PR and between 3284b4c and 407ce4a.

📒 Files selected for processing (21)
  • src/renderer/src/components/native-chat/NativeChatComposer.tsx
  • src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx
  • src/renderer/src/components/native-chat/native-chat-composer-draft-storage.ts
  • src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts
  • src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts
  • src/renderer/src/components/native-chat/native-chat-composer-scope-cache.test.ts
  • src/renderer/src/components/native-chat/native-chat-composer-scope-cache.ts
  • src/renderer/src/components/native-chat/native-chat-draft-cache.ts
  • src/renderer/src/components/native-chat/structured-session-takeover-report.test.tsx
  • src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts
  • src/renderer/src/components/native-chat/use-native-chat-composer-submit.test.tsx
  • src/renderer/src/components/native-chat/use-native-chat-composer-submit.ts
  • src/renderer/src/components/native-chat/use-native-chat-draft.ts
  • src/renderer/src/components/native-chat/use-native-chat-launch-draft-adoption.test.tsx
  • src/renderer/src/components/native-chat/use-native-chat-launch-draft-adoption.ts
  • src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx
  • src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts
  • src/renderer/src/store/slices/native-chat-launch-draft-teardown.test.ts
  • src/renderer/src/store/slices/tabs/tabs-close-actions.ts
  • src/renderer/src/store/slices/worktrees/teardown/removed-worktree-renderer-teardown.ts
  • src/renderer/src/store/terminals/terminal-tab-close.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 4 remain after this review.

Comment on lines +72 to +79
// Chips still being written, and the clipboard previews this composer minted, are its own.
const [local, setLocal] = useState<LocalAttachments>(NO_LOCAL_ATTACHMENTS)
// Read by callbacks between renders; only they change it, always together with the state.
const localRef = useRef(local)
const updateLocal = useCallback((next: LocalAttachments) => {
localRef.current = next
setLocal(next)
}, [])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

#!/bin/bash
git show HEAD~1:src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts 2>/dev/null | rg -n -C3 'revokeObjectURL|useEffect' || true
rg -n -C3 'revokeObjectURL' src/renderer/src/components/native-chat

Repository: stablyai/orca

Length of output: 5863


🏁 Script executed:

#!/bin/bash
set -e
git diff --no-ext-diff --unified=35 3284b4c70c901402831bb4ccc5576ea083d2e5ae 407ce4ac30ce305e5e8b505681b263ea620577f5 -- src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts
printf '\\n--- current attachment hook ---\\n'
nl -ba src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts | sed -n '1,330p'
printf '\\n--- paste hook cleanup and async path ---\\n'
nl -ba src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts | sed -n '1,245p'
printf '\\n--- attachment hook bindings ---\\n'
rg -n -C 3 'useNativeChatComposerAttachments|useNativeChatComposerPaste|resolvePendingImageAttachment|forgetLocalAttachment|dropPendingRef' src/renderer/src/components/native-chat

Repository: stablyai/orca

Length of output: 42783


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\\n' '--- paste hook lifecycle and resolution ---'
nl -ba src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts | sed -n '47,160p'
printf '%s\\n' '--- attachment hook call sites ---'
rg -n -C 5 'useNativeChatComposerAttachments\\(' src/renderer/src

Repository: stablyai/orca

Length of output: 5183


🏁 Script executed:

#!/bin/bash
printf '%s\\n' '--- async paste continuation ---'
nl -ba src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts | sed -n '157,245p'
printf '%s\\n' '--- attachment hook callers ---'
rg -F -n -C 5 'useNativeChatComposerAttachments(' src/renderer/src || true

Repository: stablyai/orca

Length of output: 13378


🏁 Script executed:

#!/bin/bash
set -e
rg -l -F '<NativeChatComposer' src/renderer/src --glob '*.tsx' | while IFS= read -r file; do
  printf '\\n--- %s ---\\n' "$file"
  rg -n -C 18 -F '<NativeChatComposer' "$file"
done

Repository: stablyai/orca

Length of output: 42454


Release composer-owned blob previews on unmount.

When a clipboard paste settles, resolvePendingImageAttachment moves its preview URL into local.previews. The paste hook removes that ID from its pending set before resolving it, so its cleanup cannot revoke the settled URL. When a question prompt replaces the composer, the preview can remain alive without an owner. Add cleanup for both local collections.

♻️ Suggested fix
   const updateLocal = useCallback((next: LocalAttachments) => {
     localRef.current = next
     setLocal(next)
   }, [])
+  useEffect(() => {
+    return () => {
+      const current = localRef.current
+      current.pending.forEach((attachment) => releasePreviewUrl(attachment.previewUrl))
+      current.previews.forEach(releasePreviewUrl)
+      localRef.current = NO_LOCAL_ATTACHMENTS
+    }
+  }, [])
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Chips still being written, and the clipboard previews this composer minted, are its own.
const [local, setLocal] = useState<LocalAttachments>(NO_LOCAL_ATTACHMENTS)
// Read by callbacks between renders; only they change it, always together with the state.
const localRef = useRef(local)
const updateLocal = useCallback((next: LocalAttachments) => {
localRef.current = next
setLocal(next)
}, [])
// Chips still being written, and the clipboard previews this composer minted, are its own.
const [local, setLocal] = useState<LocalAttachments>(NO_LOCAL_ATTACHMENTS)
// Read by callbacks between renders; only they change it, always together with the state.
const localRef = useRef(local)
const updateLocal = useCallback((next: LocalAttachments) => {
localRef.current = next
setLocal(next)
}, [])
useEffect(() => {
return () => {
const current = localRef.current
current.pending.forEach((attachment) => releasePreviewUrl(attachment.previewUrl))
current.previews.forEach(releasePreviewUrl)
localRef.current = NO_LOCAL_ATTACHMENTS
}
}, [])

Source: Learnings

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ No critical issues — one behavior worth a look inline, plus a test-name nit.

Reviewed changes

  • One store owns each pane's draft. native-chat-composer-draft-store.ts keeps the text, editor document, and image refs per scope in memory and writes one localStorage record whole, with a 250 ms debounce and a flush on pagehide/beforeunload/hidden visibilitychange.
  • Storage and bounds. native-chat-composer-draft-storage.ts caps drafts at 128 records / 1,000,000 chars (oldest first), drops the editor document before the text, and excludes pasted images and unsaved launch text.
  • Hooks re-plumbed. use-native-chat-draft reads via useSyncExternalStore with an IME composition hold; attachments render settled images from the store plus local pending/preview chips.
  • Sends clear by scope. The structured and goal send hooks take draftScopeKey and clear through clearNativeChatComposerDraftIfUnchanged, with a mounted ref for a send that settles after its composer unmounted.
  • Teardown deletes drafts by tab. Terminal user close, structured tab close, and worktree removal each delete that tab's drafts; setBoundedScopeCacheEntry gained eviction hooks that skip in-use entries.

ℹ️ Nitpicks

  • native-chat-composer-draft-lifecycle.test.tsx:177 — the first test is named "clears the saved draft when a send settles after the composer unmounted", but it never calls send; it directly calls held.setDraft('') / held.attachments.clearImageAttachments(). The next test (:217) is the one that actually exercises a settling send. Rename the first to describe the replaced-composer clear it tests.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

sent: NativeChatComposerDraft
): boolean {
const current = readNativeChatComposerDraft(scopeKey)
if (current.text !== sent.text || !sameImages(current.images, sent.images)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This all-or-nothing check means that if an image pasted during a host command's round trip finishes saving before the command settles, images no longer matches and the sent command text is neither cleared nor prevented from persisting. Because this PR now persists drafts, an accepted /compact or /clear whose text was stranded this way reappears in the composer after a reload, inviting a duplicate invocation.

Technical details
# Host-command text can survive the clear and resurrect on reload

## Affected sites
- `src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts:264-274` — `clearNativeChatComposerDraftIfUnchanged` returns `false` when either text or images differ, clearing neither.
- `src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts:84-89` — the host-command branch clears only through this helper, so the `false` path leaves the text to be persisted.
- `src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx:271-300` — the only test of this path settles the command *before* the image resolves, so the resolved-first ordering is unexercised.

## Required outcome
- A host command (`/compact`, `/clear`, `/goal`) whose text is unchanged since the send must have that text cleared even when the user attached an image during the round trip; only the image added since the send should remain. Today the text alone persists, and the PR's own test at `:271` asserts the intended outcome (`draft` empty, image kept) for the pending ordering only.

## Suggested approach (optional)
- Compare the two fields independently: clear the text whenever `current.text === sent.text`, and remove just the images present in `sent` (for host commands, none) from `current.images` rather than bailing out on any image difference. Keep the `!mounted.current` case conservative if the changed-text behavior there is deliberate.

## Open questions for the human
- The PR notes the in-session "command text stays too" matches main; is the durable resurrection after reload acceptable, or should the clear split text and images?

@brennanb2025
brennanb2025 marked this pull request as draft October 4, 2026 05:51
…ach 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.
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.
… 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.
…ore 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.
…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.
…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.
@brennanb2025

Copy link
Copy Markdown
Contributor Author

Follow-up: images in a restored draft

The problem. A user-side check of the earlier head found that a screenshot pasted into an unsent draft silently disappeared after a restart, while its text came back. The text could then refer to an image the agent never got. A restored image whose file had since been deleted looked normal, but sending it failed after the composer had already cleared. In the web client, a second browser tab kept a stale copy of a draft another tab had already sent.

What changes for you.

  • A pasted screenshot now comes back after a restart as the real image, with its preview, and can be sent. Orca keeps composer pastes in its own data folder for 30 days, instead of the system temp folder.
  • An image Orca can't bring back now comes back as a placeholder chip showing its name and "Attach again" (or "Not kept" for a paste). Examples: a file deleted since, an SSH paste, or a paste older than 30 days. Send stays disabled, with "An image couldn't be brought back. Remove it to send.", until you remove the chip or attach the file again. Attaching a file with the same name replaces the placeholder. This is how the common pattern handles a draft attachment it couldn't keep. Copy is in all six languages.
  • Two web-client tabs on one chat now follow each other's saved drafts. A tab with its own unsaved edit keeps it.
  • Terminal, editor and phone pastes are unchanged; they still go to the system temp folder.

Fixed in review of this round:

  • The new paste folder was at first used for every paste in Orca, including terminal Cmd+V. On macOS its path contains a space, which would have stopped a screenshot pasted into a Codex terminal from attaching. That was caught before push and scoped to the chat composer. Image paths sent to a terminal-backed chat are now escaped.
  • The main-process call that restores a paste's preview now grants read access only to real files inside Orca's paste folder. Path tricks with .., symlinks and Windows prefixes are tested and refused.
  • The cleanup sweep deletes only Orca's own paste files and never follows a link.

Deferred (labelled in the PR body):

  • SSH pastes come back as placeholders; the follow-up is an Orca-owned folder on the remote host.
  • Two rare web-client two-tab cases.
  • A dragged macOS screenshot thumbnail still expires after 7 days.
  • Placeholder copy wording.

Verified.

  • 910 tests across the 84 affected files pass (plus the paste-file tests added for the last two fixes), and each new protection fails its test when removed. Web and node typechecks are clean, and the lint, code-quality and localization checks pass. CI is green on 4b9f91a.
  • Manual QA on Windows at 4b9f91a, screenshots in the PR body:
    • a pasted screenshot is back after a quit and relaunch, and the agent received the identical image;
    • a deleted file shows the "Attach again" chip with Send disabled, and removing it re-enables Send;
    • re-attaching replaces the chip;
    • a terminal paste still lands in the temp folder;
    • typed drafts still survive.

Not verified:

  • macOS and Linux in the app (a partial macOS run was stopped by another session's Keychain prompt; its pasted-image capture matched);
  • SSH in the app;
  • two real browser tabs;
  • Windows junctions (they resolve through the same real-path check that the symlink tests cover);
  • how agents other than Codex read an escaped path.

…nsaction's complete, and false on abort, for an append before the load
… 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.
#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.
@brennanb2025

Copy link
Copy Markdown
Contributor Author

Follow-up: up to date with main, given-back text is safe through a crash, and reuse checks the right draft

Main moved twice under this PR.

What this means for you.

  • A chat you've typed in is never taken over by a new chat request; a new chat opens instead. QA showed this, with an empty chat still reused as before.
  • Text Stop gives back survives even an instant crash. Draft saves are asynchronous since drafts moved to their own browser database, so the hand-back now also writes the text to a small quit backup synchronously, before its queued copy is removed. QA quit in the same frame the text appeared, and it came back exactly once.
  • A queued card's Edit keeps the card until the draft is confirmed saved.

Fixed in review this round:

  • if storage refused writes for a whole run, a sent draft could come back after a lot of text had been given back;
  • in the web client, a second window's startup could overwrite text typed in the first.

Deferred (labelled in the PR body):

  • if both the quit backup and the database refuse at once and Orca crashes, text Stop gave back can be lost; the send-settlement PR removes that by keeping the queued copy until the save is confirmed;
  • a hand-back replayed after a crash onto an empty draft isn't deleted with its workspace.

Verified.

  • Automated: 2,857 tests across 259 affected files pass, and each new protection fails its test when removed.
  • CI: green on 99b7ec8.
  • Manual QA:
    • pasted image back with its preview after a relaunch, and Stop-then-instant-quit, on macOS at 8b6c8c5;
    • reload, Stop-then-instant-quit and no reuse of a chat with a typed draft, on Windows at 071c934.
    • The later commits are a main merge and test-only changes.

Not verified:

  • other chat-start paths (the CLI, creating a worktree) in the app;
  • typecheck on this machine, because main's new dependencies aren't installed here (CI typechecks).

@brennanb2025
brennanb2025 marked this pull request as ready for review October 5, 2026 02:09

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

A previous run's journaled whole draft is silently lost when the next run appends to the same conversation before its startup load begins reading. The append-before-load path writes a partial record with a fresh savedAt over a storage base that never absorbed the journal entry, so the entry is pruned and replayed as stale. Reproduced with a focused test (below).

Reviewed changes

Delta against the prior pullfrog review (393eb7f), the six commits since:

  • Additions are durable at append time. A new native-chat-composer-draft-addition.ts and a second journal entry kind let appendNativeChatComposerDraft journal text/images given back the moment they are added; it returns whether the addition was durable, and a queued card's Edit deletes only once storage confirms the scope's write.
  • Per-scope write confirm. native-chat-composer-draft-persistence.ts tracks writes per scope (scopeWrites), so nativeChatComposerDraftWriteSettled includes an append made before the load landed and returns false when storage refused.
  • Journal caps split. Whole drafts stop at 256k, additions at 800k, and removals are never capped; a load snapshots the journal when it begins reading, so a live window's later addition is not replayed over its newer typing.
  • Paste restore rule. restoreNativeChatPastes keeps a paste by its real path only (the stored-spelling sameFile check is gone); the preview read stays safe because chat-image access reads only image files.
  • Empty-chat reuse. isStructuredLaunchChatEmpty now reads the conversation's draft and treats every chat as non-empty while saved drafts are still loading, so a draft-holding chat is never reused or taken over.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

continue
}
const stored = drafts.get(entry.scopeKey)
if (stored && stored.savedAt >= entry.at) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The previous run's journaled whole draft can be dropped here. When the next run appends to the same scope before its startup load begins reading, appendBeforeLoad read-modify-writes a partial record onto storage (its base never absorbed the journaled draft) and stamps a fresh savedAt; this comparison then treats that partial record as superseding the whole-draft journal entry and skips it, and withAppends will not re-apply the append (sequence == readAtSequence), so the earlier text is gone.

Technical details
# A journaled whole draft is lost when the next run appends before its load starts

## Affected sites
- `native-chat-composer-draft-journal.ts:127` — `stored.savedAt >= entry.at` treats the append-before-load partial record as newer than the whole-draft journal entry, so the entry is dropped and its text lost.
- `native-chat-composer-draft-persistence.ts:175,188` — `appendedAt` is the new record's `savedAt`, so `pruneNativeChatComposerDraftJournal(scopeKey, appendedAt)` also removes the older whole-draft entry from localStorage (it intended to remove only this run's addition entry).
- `native-chat-composer-draft-load.ts:45` — `!entry.committed || entry.sequence > readAtSequence` skips re-applying the append when it was made before `readAtSequence` was captured, even though the loaded draft never contained the journaled draft.

## Required outcome
- A run that starts with a journaled (unconfirmed) whole draft and appends to the same scope before its load lands must end with the journaled text plus the addition, not just the addition.
- Add a test: run A types a draft whose write never commits and quits (journal holds it); run B appends to the same `agent-session:<id>` before `hydrateNativeChatComposerDrafts` begins reading, then loads. Expect `"<run A text>\n\n<append>"`. A variant where the append happens after the load starts already passes, so the ordering is the whole difference.

## Suggested approach (optional)
- Make the append-before-load write base on the journaled draft for the scope (merge the pending whole-draft entry) rather than on storage alone, and/or keep whole-draft journal entries whose `at` predates an append-before-load write; then re-apply the append on the replayed draft instead of relying on `sequence > readAtSequence`.

get(),
getKnownRepoWorktreeIds(get(), projectId, ownerHostId).map((workspaceId) => ({
workspaceId,
executionHostId: ownerHostId

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: ownerHostId here is getRepoExecutionHostId(ownerRepo), which is local for an unstamped repo, while resolveNativeChatDraftOwner stamps the draft from resolveWorktreeOperationRoute — runtime:<env> for an unstamped worktree with a single focused runtime. The owner match then fails and a closed conversation's draft is left behind. Same-id-across-hosts and SSH-through-a-HUB are documented as temporary; this write-vs-removal route mismatch is not.

brennanb2025 added a commit that referenced this pull request Oct 5, 2026
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.
@brennanb2025
brennanb2025 marked this pull request as draft October 5, 2026 02:52
… 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.
…fts-survive-reload

# Conflicts:
#	src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx
#	src/renderer/src/components/native-chat/native-chat-draft-cache.ts
#	src/renderer/src/components/native-chat/use-native-chat-draft.ts
…l 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.
…fts-survive-reload

Main's new composer lines put NativeChatComposer.tsx over max-lines on
the merged tree; the composer's dictation state moves into its existing
dictation hook (renamed useNativeChatDictation), with no behaviour change.
…oast

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.
@brennanb2025

Copy link
Copy Markdown
Contributor Author

Follow-up: up to date with main again

What changed since the last ready head (99b7ec8).

Verified.

  • Automated:
    • Every test file main changed in these merges, plus this PR's own tests, pass. The exceptions are suites that can't load main's new stream-json dependency on this machine, and one SQLite backup test from main that times out here; this PR doesn't touch either.
    • Localization checks pass.
    • The web typecheck is clean apart from the missing stream-json package. The node typecheck shows only that same main-side duplicate import.
  • CI on 9a576cc: repository guards, "test vs non-test LoC" and the PR verify pass.
  • "static analysis and typecheck" is red for a reason on main, not here. In src/main/native-chat/agent-session-journal/journal-submission-positions.test.ts, two fixes (fix(sidebar): remember whether the left sidebar is open across restarts #22682 and fix(test): use the current provider handle shape in the submission-positions test (unbreaks main typecheck) #25709) each added the same codexProviderHandle import, so the file now declares it twice. The file is byte-identical on main, and every branch that merges current main hits this. It goes away with main's fix; nothing in this PR needs to change.
  • No new app QA this round: the user-visible change is only the shared text rule, which unit tests cover.

@brennanb2025
brennanb2025 marked this pull request as ready for review October 5, 2026 23:59

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

A previous run's journaled whole draft is still silently lost when the next run appends to the same scope before its startup load begins reading. The three branch commits since the last pullfrog review do not touch this path, and it re-reproduces at 9a576cc.

Reviewed changes

Delta against the prior pullfrog review (99b7ec8) — the three branch commits since, plus merges of main:

  • Returned-text dedup narrowed to whole paragraphs. 35904851d7 made "already there" mean the draft equals the text or ends with it after a blank line, so "go" given back onto "please go" is added again, and a whitespace-only draft counts as empty. The merge with main replaced the local helpers with the shared appendReturnedDraftText, which carries the same rule.
  • A no-op hand-back stays durable. b6b576b573 journals an addition even when it changes nothing, provided the draft is not confirmed saved, so a hand-back the draft already ends with survives a crash before that draft is written.
  • Lint fix. 3b2ed2b516 merges the duplicated shared/worktree/removal import in delete-worktree-failure-toast.tsx.

The delta itself is correct and its tests pass (native-chat-composer-draft-addition.test.ts 5/5). The blocking issue below predates it and is unchanged, and the prior review's minor owner-route finding at repo-removal.ts:95 also remains open.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

Comment on lines +177 to +188
const written = nativeChatComposerDraftStorage()
.update(scopeKey, (stored) => {
const base = parseStoredNativeChatComposerDraft(stored) ?? empty
return savedForm({
...append({ ...base, ...(base.owner || !owner ? {} : { owner }) }),
savedAt: nextSavedAt()
})
})
.then(
() => {
entry.committed = true
pruneNativeChatComposerDraftJournal(scopeKey, appendedAt)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The append-before-load read-modify-write bases only on storage, so it never sees a previous run's journaled whole draft; it stamps a fresh savedAt (line 182) and then pruneNativeChatComposerDraftJournal (line 188) drops that older whole-draft entry. replayNativeChatComposerDraftJournal treats the partial record as newer and skips the entry, and withAppends will not re-apply the append because it committed before readAtSequence — so the run-A text is gone. Re-reproduces at 9a576cc: run A types "typed before quit" with a write that never commits and quits; run B appends "returned by Stop" before hydrateNativeChatComposerDrafts begins reading. Expected "typed before quit\n\nreturned by Stop", got "returned by Stop". The reverse ordering (load reads before the append) passes, so the ordering is the whole difference.

Technical details
# A journaled whole draft is lost when the next run appends before its load reads

## Affected sites
- `native-chat-composer-draft-persistence.ts:177-188` — the append-before-load `update` bases on `storage` alone and prunes the journal with the new record's `savedAt`.
- `native-chat-composer-draft-journal.ts:127` — `stored.savedAt >= entry.at` drops the whole-draft entry as stale once the append's fresh `savedAt` lands in storage.
- `native-chat-composer-draft-load.ts:44-45` — `withAppends` skips re-applying the append when it committed before `readAtSequence` was captured.

## Required outcome
- A run that starts with a journaled (unconfirmed) whole draft and appends to the same scope before its load reads must end with the journaled text plus the addition, not just the addition.

## Suggested approach
- Make the append-before-load write base on the journaled draft for the scope (merge the pending whole-draft entry) rather than on storage alone, and/or keep whole-draft entries whose `at` predates an append-before-load write, then re-apply the append on the replayed draft instead of relying on `sequence > readAtSequence`.

@brennanb2025
brennanb2025 merged commit 135c92e into main Oct 6, 2026
48 of 52 checks passed
brennanb2025 added a commit that referenced this pull request Oct 6, 2026
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.
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