Skip to content

fix(native-chat): keep a tab launch draft out of split sibling composers - #16883

Merged
nwparker merged 2 commits into
mainfrom
nwparker/fix-16695-stale-composer-draft-on-split
Aug 30, 2026
Merged

nwparker merged 2 commits into
mainfrom
nwparker/fix-16695-stale-composer-draft-on-split

Conversation

@nwparker

@nwparker nwparker commented Aug 27, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 2 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​148 $\color{#cf222e}{\Huge{\mathbf{−}}}$​3 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​145
Prod 7 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​101 $\color{#cf222e}{\Huge{\mathbf{−}}}$​24 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​77

ELI5

When Orca starts an agent with a prompt it does not send (an issue link, a brief), it mirrors that unsent text into the GUI chat composer so the context is visible. That mirror was stored per tab, not per pane, so any pane of the tab could claim it. Split a pane in that tab — even a day later — open chat in the new pane, and yesterday's unsent prompt appeared in its empty composer, ready to be submitted by an Enter or orca terminal send --enter. Now only the pane the draft was seeded for can adopt it; a split sibling always starts empty.

Root cause

src/renderer/src/components/native-chat/use-native-chat-launch-draft-adoption.ts

const launchDraft = useAppStore((s) => s.nativeChatLaunchDraftByTabId[args.terminalTabId] ?? null)

The only additional filter was launchDraft?.agent === args.agent. There is no pane/leaf gate, so every pane of the tab that resolves to the same agent sees the same draft, and useNativeChatLaunchDraftAdoption writes it into whichever composer mounted with an empty draft.

The asymmetry was local and explicit: in the same render tree TerminalPane.tsx:2976 gates the tab's launch agent through nativeChatLaunchAgentForLeaf, which refuses tab-wide evidence as soon as leafIds.length !== 1, and the mobile publisher buildMobileLaunchDraftsByPaneKey (src/renderer/src/runtime/sync-runtime-graph.ts:1900) runs the same store draft through that identical predicate before emitting launchDraftByPaneKey. Only the desktop composer's draft signal skipped it.

Fix: extract that ownership rule as nativeChatLeafOwnsTabWideEvidence in native-chat-leaf-routing.ts (now also the body of nativeChatLaunchAgentForLeaf, so desktop and mobile share one predicate), compute it for the chat pane in TerminalPane, and apply it to the composer write.

Where the gate goes, and why not in the signal

The first revision of this PR gated the signal: useNativeChatLaunchDraftSignal returned null for a non-owning pane. That was wrong. useNativeChatLaunchDraftAdoption early-returns on !launchDraft, so nulling the signal did not merely block the pickup branch — it disabled the whole per-draft state machine for every pane of a split tab, including the resolution branch that calls setDraft('') and clearNativeChatLaunchDraft.

Splitting flips ownsTabWideLaunchDraft to false for both panes (the predicate requires leafIds.length === 1), so the pane that had already mirrored the seed lost its cleanup path too: launch with a seeded prompt, open chat so the owning pane adopts it, split the tab, submit from the TUI — and the seeded text stayed in the original pane's composer permanently (useNativeChatDraft(paneKey) is pane-scoped and survives TUI/GUI toggles), where a later Enter re-submits an already-submitted prompt. That is #16695's own harm class, reintroduced in the original pane.

The gate now sits on the only branch that writes text into a composer:

if (launchDraftResolved) {
  const holdsUntouchedCopy = launchDraft.adopted && draft === launchDraft.text
  if (holdsUntouchedCopy) { setDraft(''); setCaret(0) }
  if (ownsTabWideLaunchDraft || holdsUntouchedCopy) {
    useAppStore.getState().clearNativeChatLaunchDraft(terminalTabId)
  }
  return
}
if (launchDraft.adopted) return
if (!ownsTabWideLaunchDraft) return   // #16695: never mirror into a split tab's pane

Cleanup is keyed on holding the copy rather than on ownership, because ownership is gone for every pane once the tab is split. The clearNativeChatLaunchDraft guard is the mirror-image protection: a pane that neither owns the seed nor holds its copy must not drop it, or a sibling's transcript could destroy a seed the owner has not adopted yet.

The composer's three launch-draft props are folded into one launchSeed object (NativeChatLaunchSeed) — they are one unit produced by one hook, and NativeChatView already passed them as a group.

Proof

Test file: src/renderer/src/components/native-chat/use-native-chat-launch-draft-adoption.test.tsx
Command: npx vitest run --config config/vitest.config.ts src/renderer/src/components/native-chat/use-native-chat-launch-draft-adoption.test.tsx

BEFORE the fix (tests written first, run against ad4e8bd's source): 12 of 23 fail. Most fail because the ownership flag moved from the signal hook to the adoption hook, but the load-bearing one is the regression itself — an adopted seed on a split tab, resolved by the transcript, is never cleaned up:

 FAIL  src/renderer/src/components/native-chat/use-native-chat-launch-draft-adoption.test.tsx > launch draft adoption across a split > clears the adopted copy after a split once the transcript resolves the seed
AssertionError: expected "vi.fn()" to be called with arguments: [ '' ]

Number of calls: 0

 ❯ src/renderer/src/components/native-chat/use-native-chat-launch-draft-adoption.test.tsx:373:22
    371|     })
    372|
    373|     expect(setDraft).toHaveBeenCalledWith('')
       |                      ^
    374|     expect(setCaret).toHaveBeenCalledWith(0)
    375|     expect(mocks.clearNativeChatLaunchDraft).toHaveBeenCalledWith('tab…

 Test Files  1 failed (1)
      Tests  12 failed | 11 passed (23)

AFTER the fix:

 Test Files  1 passed (1)
      Tests  23 passed (23)
   Duration  569ms

Wider runs, all green:

$ npx vitest run --config config/vitest.config.ts src/renderer/src/components/native-chat/
 Test Files  74 passed (74)
      Tests  796 passed (796)
   Duration  3.86s

$ npx vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/
 Test Files  397 passed (397)
      Tests  3831 passed (3831)
   Duration  17.21s

oxlint and oxfmt --check are clean over both directories. pnpm run tc:web reports one pre-existing error unrelated to this change (useSessionRestoredBannerDismiss.test.tsx(26,3), a querySelector cast in an untouched file).

Regression guards now live in the composed signal-plus-adoption suite, so they assert composer behavior rather than signal shape. The two earlier tests that asserted a sibling's launchDraft is null were rewritten, since the signal is deliberately pane-agnostic now:

  • never fills a non-owning pane's composer with the tab's launch draft — [Bug]: splitting a pane can restore a stale unsent composer draft into the new pane #16695's original protection.
  • never lets a non-owning pane destroy a seed nobody has adopted yet.
  • still mirrors the launch draft into the owning pane composer.
  • clears the adopted copy after a split once the transcript resolves the seed — the regression above.
  • Plus two unit-level cases on the adoption hook (does not mirror the seed into a pane that does not own the tab-wide evidence, leaves an unadopted seed alone when a non-owning pane resolves it) and the pure-predicate test in native-chat-leaf-routing.test.ts.

Risk

Low. Renderer-only; the gate reuses the predicate that already governs the launch agent on the very same pane. nativeChatLaunchAgentForLeaf was refactored to call the extracted predicate with identical semantics (!launchAgent short-circuit first, then the same leafIds.length === 1 && leafIds[0] === leafId && ownerLeafId === leafId), and its existing tests pass unchanged. NativeChatComposer's launchDraft / launchDraftResolved props became one optional launchSeed prop; the component has a single production call site and no test passes those props. No persisted state, no store shape change, no IPC/RPC or remote-wire field, no mobile publisher behavior change, no platform-specific code.

User-facing regressions

  • Mirroring into the original pane (fix(native-chat): mirror multi-line launch drafts into the chat composer #11253): unchanged. The sole-leaf owner still adopts the launch draft; covered by two tests.
  • While a tab is split, no pane adopts the launch draft — including the original pane. This is deliberate and matches mobile: after a split the text is only in the TUI input buffer, where the user put it. The unsent context is not lost, just not duplicated into a GUI composer that cannot prove it owns it.
  • Closing the sibling restores adoption in the original pane. Ownership is nativeChatLeafOwnsTabWideEvidence({ ownerLeafId: getTabWideAgentHintLeafId(), leafId, leafIds: getNativeChatLeafIds() }). tabWideAgentHintLeafId binds once, but getNativeChatLeafIds() is recomputed on every call — it unions the live pane list with expectedLayoutLeafIds, which is derived from the tab's layout root and shrinks when a pane closes. So after split-then-close-sibling, leafIds.length returns to 1 and matches the bound owner leaf, and the original pane adopts the seed again — unless the transcript has already resolved it, in which case the resolved branch runs first and drops the seed instead of re-offering it. An earlier revision of this description claimed adoption was lost permanently once a tab had been split; that was wrong.
  • Composer cleanup now survives a split. The pane still holding the untouched adopted copy clears its own composer and retires the seed when the transcript resolves it, whether or not it currently owns the tab-wide evidence. The complementary guard is that a pane holding no copy and owning nothing will not call clearNativeChatLaunchDraft, so a split sibling cannot delete a seed the owner has yet to adopt.
  • The seed can now outlive adoption on a split tab, staying in the in-memory nativeChatLaunchDraftByTabId until ownership returns, the transcript resolves it in the pane holding the copy, or the tab closes. It is inert in that state: the pickup branch refuses it and the cleanup branch is its only other consumer. No growth beyond one entry per tab.
  • Not addressed here (checked, left alone): nativeChatLaunchPromptByTabId — the submitted launch prompt rendered as a transcript bubble — is still tab-keyed and can render in a split sibling's chat view. It is display-only, cannot be submitted by an Enter, and untangling it from shouldPruneLaunchPrompt is a separate change.

Why Refs, not Fixes

This lands the pane gate the reported scenario needs, but it must not auto-close #16695. The reporter says Ctrl-U cleared the text. Per the #16290 discriminator, Ctrl-U is a PTY-side line kill: text that Ctrl-U erases was living in the agent TUI's input buffer, not in Orca's GUI composer (a React textarea, which does not respond to Ctrl-U that way). So the occurrence actually reported was PTY-side, and this renderer-layer change cannot be shown to fix it. Leaving the issue open keeps that unexplained PTY-side path visible.

Refs #16695

@coderabbitai

coderabbitai Bot commented Aug 27, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 3ed90b2c-a541-48e2-810b-42d85457d4e4

📥 Commits

Reviewing files that changed from the base of the PR and between fb6c280 and 890ffa2.

📒 Files selected for processing (9)
  • src/renderer/src/components/native-chat/NativeChatComposer.tsx
  • src/renderer/src/components/native-chat/NativeChatView.tsx
  • src/renderer/src/components/native-chat/native-chat-composer-types.ts
  • src/renderer/src/components/native-chat/native-chat-leaf-routing.test.ts
  • src/renderer/src/components/native-chat/native-chat-leaf-routing.ts
  • src/renderer/src/components/native-chat/native-chat-view-types.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/terminal-pane/TerminalPane.tsx
🚧 Files skipped from review as they are similar to previous changes (8)
  • src/renderer/src/components/native-chat/native-chat-leaf-routing.test.ts
  • src/renderer/src/components/native-chat/NativeChatView.tsx
  • src/renderer/src/components/native-chat/native-chat-leaf-routing.ts
  • src/renderer/src/components/native-chat/NativeChatComposer.tsx
  • src/renderer/src/components/native-chat/native-chat-composer-types.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/terminal-pane/TerminalPane.tsx

Included review availability: Your plan provides up to 10 included reviews per hour; 3 remain after this review.


📝 Walkthrough

Walkthrough

The change adds tab-wide launch draft ownership tracking for native chat panes. Leaf routing determines ownership only for a single-pane tab. Terminal panes pass ownership through NativeChatView, NativeChatResolvedView, and NativeChatComposer using launchSeed. Launch draft adoption and cleanup now require ownership or an untouched seed copy. Tests cover single-pane ownership, split panes, signal resolution, adoption, and cleanup.

Merge Risk: 🔵 Low · up to 890ff

The change prevents split siblings from adopting a tab launch draft, but shared-draft cleanup can still let a sibling submission or identical composer text remove pending context from the intended pane. The impact is limited to local split-pane draft state and is mergeable with explicit owner awareness and follow-up.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description provides detailed context, root cause, implementation details, linked issue, risks, and test results. However, it omits the required Visual Proof section or an explicit N/A statement, … Add the required Visual Proof section with before-and-after attachments, or write N/A with a reason if visual proof is not applicable. Also include the required template sections and checklist items, including AI Disclosure, Review, Agent s…
Docstring Coverage ⚠️ Warning Docstring coverage is 23.08% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 13 functions across 9 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly states the primary change: preventing tab launch drafts from appearing in split sibling composers.
Linked Issues check ✅ Passed The changes directly address the renderer launch-draft path in #16695 by preventing split sibling composers from adopting tab-scoped drafts and by preserving cleanup for already-adopted copies. The un…
Out of Scope Changes check ✅ Passed The changes are within scope. Ownership predicate extraction, prop consolidation, view plumbing, composer adoption logic, and regression tests all support preventing stale launch drafts in split sibli…
Full details: Description check

Explanation

The description provides detailed context, root cause, implementation details, linked issue, risks, and test results. However, it omits the required Visual Proof section or an explicit N/A statement, and it does not follow several template headings and checklist sections.

Resolution

Add the required Visual Proof section with before-and-after attachments, or write N/A with a reason if visual proof is not applicable. Also include the required template sections and checklist items, including AI Disclosure, Review, Agent skill upstream boundary, Notes, and Checklist.

Full details: Linked Issues check

Explanation

The changes directly address the renderer launch-draft path in #16695 by preventing split sibling composers from adopting tab-scoped drafts and by preserving cleanup for already-adopted copies. The unresolved PTY-side Ctrl-U behavior is explicitly outside the confirmed renderer scope of the issue.

Full details: Out of Scope Changes check

Explanation

The changes are within scope. Ownership predicate extraction, prop consolidation, view plumbing, composer adoption logic, and regression tests all support preventing stale launch drafts in split sibling composers.

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch

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: Pro Plus

Run ID: 4a82cf2d-a16a-4cba-9d5f-b1e1a47c1874

📥 Commits

Reviewing files that changed from the base of the PR and between 1485aa4 and ed7cad3.

📒 Files selected for processing (9)
  • src/renderer/src/components/native-chat/NativeChatComposer.tsx
  • src/renderer/src/components/native-chat/NativeChatView.tsx
  • src/renderer/src/components/native-chat/native-chat-composer-types.ts
  • src/renderer/src/components/native-chat/native-chat-leaf-routing.test.ts
  • src/renderer/src/components/native-chat/native-chat-leaf-routing.ts
  • src/renderer/src/components/native-chat/native-chat-view-types.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/terminal-pane/TerminalPane.tsx

Included review availability: Your plan provides up to 10 included reviews per hour; 0 remain after this review.

Comment thread src/renderer/src/components/native-chat/NativeChatComposer.tsx Outdated
The launch draft is stored per terminal tab, but the desktop composer
selected it with no pane gate, so any pane of that tab — including one
created by splitting, hours or days later — adopted the tab's unsent
launch text into its empty composer.

Gate the desktop draft on the same pane ownership the launch agent and
the mobile launch-draft publisher already use, extracted as
nativeChatLeafOwnsTabWideEvidence.

Fixes #16695
…achine

Gating pane ownership inside useNativeChatLaunchDraftSignal nulled the seed
for every pane of a split tab, and the adoption effect early-returns on a null
seed — so it also killed the resolved/cleanup branch. A pane that had already
mirrored the seed into its composer kept that text forever once the tab was
split and the prompt was submitted TUI-side, and a later Enter re-sent it.

Keep the signal pane-agnostic and gate the only branch that writes text into a
composer. Cleanup now runs for the pane still holding the untouched copy, while
a pane that neither owns the seed nor holds its copy cannot drop it out from
under the owner.

Fold the composer's launch-draft props into one `launchSeed` object: they are
one unit produced by one hook, and the view already passed them as a group.

Refs #16695
@nwparker
nwparker force-pushed the nwparker/fix-16695-stale-composer-draft-on-split branch from ed7cad3 to 890ffa2 Compare August 29, 2026 09:56
@coderabbitai

coderabbitai Bot commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@greptile-apps

greptile-apps Bot commented Aug 29, 2026

Copy link
Copy Markdown

Greptile Summary

The PR adds pane ownership to native-chat launch-draft adoption so split siblings cannot claim tab-wide seeded text, while preserving transcript-driven cleanup after ownership changes.

  • Extracts a shared sole-leaf ownership predicate for launch-agent and launch-draft routing.
  • Consolidates composer launch-draft inputs into a launchSeed object propagated from TerminalPane.
  • Extends launch-draft tests for split-pane adoption and cleanup behavior.
  • The new cleanup heuristic can still mistake independently entered sibling text for the adopted copy.

Confidence Score: 4/5

The PR should not merge until cleanup can identify the actual adopting pane without deleting equal user-entered text from a split sibling.

Launch-draft ownership is correctly gated during adoption, but resolved cleanup conflates text equality with provenance and can erase an independent pane-scoped draft.

Files Needing Attention: src/renderer/src/components/native-chat/use-native-chat-launch-draft-adoption.ts

Important Files Changed

Filename Overview
src/renderer/src/components/native-chat/use-native-chat-launch-draft-adoption.ts Adds ownership-gated adoption and split-safe cleanup, but text equality plus a tab-wide adopted bit can erase an independently entered sibling draft.
src/renderer/src/components/terminal-pane/TerminalPane.tsx Computes launch-draft ownership from the bound owner leaf and current live pane topology, then propagates it into native chat.
src/renderer/src/components/native-chat/native-chat-leaf-routing.ts Extracts the sole-leaf ownership predicate and reuses it without changing launch-agent routing semantics.
src/renderer/src/components/native-chat/NativeChatView.tsx Propagates pane ownership and combines it with the pane-agnostic launch-draft signal.
src/renderer/src/components/native-chat/NativeChatComposer.tsx Replaces separate launch-draft props with the optional launchSeed object for adoption and send behavior.
src/renderer/src/components/native-chat/use-native-chat-launch-draft-adoption.test.tsx Adds broad split-pane regression coverage but does not cover independently entered sibling text equal to an adopted seed.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  S[Tab-wide launch seed] --> O{Leaf owns tab-wide evidence?}
  O -->|Sole owning leaf| A[Adopt into pane-scoped composer]
  O -->|Split tab| N[Do not adopt]
  A --> B[Tab-wide adopted flag]
  B --> R{Transcript resolves seed}
  N --> R
  R --> E{Adopted flag and draft text equals seed}
  E -->|Yes| C[Clear current pane draft and seed]
  E -->|No, but current owner| D[Clear seed only]
  E -->|Neither| K[Leave seed and draft unchanged]
Loading

Reviews (1): Last reviewed commit: "fix(native-chat): gate the launch-draft ..." | Re-trigger Greptile

Comment on lines +107 to +112
const holdsUntouchedCopy = launchDraft.adopted && draft === launchDraft.text
if (holdsUntouchedCopy) {
setDraft('')
setCaret(0)
}
useAppStore.getState().clearNativeChatLaunchDraft(terminalTabId)
// A pane that neither owns the seed nor holds its copy has no standing to

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Cleanup loses adopting pane identity

When the original pane adopts a seed, a split sibling later contains independently entered text equal to that seed, and the transcript resolves, holdsUntouchedCopy treats the sibling's text as the adopted copy because adopted is tab-wide. The cleanup then erases the sibling's user-entered draft and retires the shared seed.

Knowledge Base Used: Agent workspaces and sessions

@nwparker
nwparker merged commit 668c1bc into main Aug 30, 2026
27 of 29 checks passed
@nwparker
nwparker deleted the nwparker/fix-16695-stale-composer-draft-on-split branch August 30, 2026 04:16
enrick-dev pushed a commit to enrick-dev/orca that referenced this pull request Oct 15, 2026
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.

[Bug]: splitting a pane can restore a stale unsent composer draft into the new pane

1 participant