From ef41ba5004e22ae7e4821a9cc604fec8cadf7f91 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Wed, 12 Aug 2026 03:00:23 -0700 Subject: [PATCH] fix(renderer): keep OpenCode OC| reclaim durable under launch ownership Native OC| titles reclaim and stay OpenCode after process observation, with sidebar title-derived rows matching tab identity. Nested Claude/Codex still fenced while launch is active (#13341). --- .../worktree-title-derived-agent-rows.test.ts | 15 ++++++ .../worktree-title-derived-agent-rows.ts | 5 ++ .../src/lib/tab-agent-from-signals.ts | 50 +++++++++--------- .../lib/use-tab-agent-nested-identity.test.ts | 51 +++++++++++++++++++ ...se-tab-agent-opencode-native-title.test.ts | 7 ++- 5 files changed, 100 insertions(+), 28 deletions(-) diff --git a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts index e996a77837c..25e1b9b58ea 100644 --- a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts +++ b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts @@ -239,6 +239,21 @@ describe('buildTitleDerivedAgentRows', () => { expect(rows.map((row) => [row.agentType, row.state])).toEqual([['claude', 'working']]) }) + // Why (#8478): native OC| under a non-OpenCode launch matches tab-agent reclaim (sidebar parity). + it('lets a native OpenCode title own the row under an active Claude launch', () => { + const rows = buildWorktreeAgentRows({ + tabs: [makeTab('tab-1', { launchAgent: 'claude' })], + entries: [], + retained: [], + runtimePaneTitlesByTabId: { 'tab-1': { 1: 'OC | Greeting' } }, + ptyIdsByTabId: { 'tab-1': ['pty-opencode'] }, + terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(LEAF_ID_1) }, + now: 2000 + }) + + expect(rows.map((row) => [row.agentType, row.state])).toEqual([['opencode', 'idle']]) + }) + it('produces no row for a spinner-only title when the tab has no launch identity', () => { const rows = buildWorktreeAgentRows({ tabs: [makeTab('tab-1')], diff --git a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts index c1ba2cf81eb..dc5ee107722 100644 --- a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts @@ -20,6 +20,7 @@ import { resolveCompatibleAgentTypeForOwner } from '../../../../shared/agent-title-owner' import { resolvePaneAgentOwner } from '../../../../shared/pane-agent-owner' +import { isOpenCodeNativeTitle } from '../../../../shared/opencode-terminal-title' const EMPTY_RUNTIME_TITLES: Record> = {} const EMPTY_LIVE_PTY_IDS: Record = {} @@ -213,6 +214,10 @@ export function resolveTitleDerivedAgentType( } return agentType } + // Why (#8478): native OC| is provider identity (pane reuse), not a nested child — match tab-agent. + if (agentType === 'opencode' && isOpenCodeNativeTitle(title)) { + return agentType + } // Why (#13341): nested child titles keep the durable owner until launch clears. if (owner && agentType !== 'unknown' && agentType !== owner) { return null diff --git a/src/renderer/src/lib/tab-agent-from-signals.ts b/src/renderer/src/lib/tab-agent-from-signals.ts index 5099ee0e7fc..7ca6eb62804 100644 --- a/src/renderer/src/lib/tab-agent-from-signals.ts +++ b/src/renderer/src/lib/tab-agent-from-signals.ts @@ -114,15 +114,6 @@ export function resolveTabAgentFromSignals(args: { processShellForeground: args.processShellForeground }) const activeLaunchAgent = launchedAgentExited ? null : launchAgent - // Why: re-own the foreground process within its title-identity group so OMP's nested pi (shell → omp → pi) can't flip an OMP-owned tab's icon. - const processAgentRaw = resolveSignalAgentForLaunchOwner(args.processAgent, owner) - // Why (#13341): Claude↔Codex nested CLIs become the local foreground process while the - // parent session still owns the pane. Do not let that child process rebrand the icon - // until launch ownership has exited (then process may name a genuine reuse). - const processAgent = - activeLaunchAgent && processAgentRaw && processAgentRaw !== activeLaunchAgent - ? null - : processAgentRaw // Title carries identity only as a reuse override (names a DIFFERENT-group agent) or a legacy standalone id when no hook — same-group titles say nothing (OMP wraps Pi), so the record wins. const explicitTitleAgent = resolveSignalAgentForLaunchOwner( @@ -131,38 +122,49 @@ export function resolveTabAgentFromSignals(args: { ) const priorIdentity = idleFocusedIdentity ?? activeLaunchAgent const nativeOpenCodeTitle = explicitTitleAgent === 'opencode' && isOpenCodeNativeTitle(args.title) + // Why (#8478): native `OC |` is provider identity for pane reuse — durable while the frame + // remains, including after process observation and under a non-OpenCode launch (#13341 still + // fences other cross-group nested titles/processes). + const openCodeNativeReclaim = + nativeOpenCodeTitle && + (activeLaunchAgent === null || activeLaunchAgent !== 'opencode') && + (priorIdentity === null || priorIdentity !== 'opencode') + // Why: re-own the foreground process within its title-identity group so OMP's nested pi (shell → omp → pi) can't flip an OMP-owned tab's icon. + const processAgentRaw = resolveSignalAgentForLaunchOwner(args.processAgent, owner) + // Why (#13341): Claude↔Codex nested CLIs become the local foreground process while the + // parent session still owns the pane. Do not let that child process rebrand the icon + // until launch ownership has exited (then process may name a genuine reuse). + // Why (#8478): after OC| reclaim, a launch-matching process must not undo OpenCode. + const processAgent = + openCodeNativeReclaim && processAgentRaw && processAgentRaw !== 'opencode' + ? null + : activeLaunchAgent && processAgentRaw && processAgentRaw !== activeLaunchAgent + ? null + : processAgentRaw // Why: a "claude" token in another agent's task text is a mention, not identity, so it must // not take a pane from its known owner — only a title that PRESENTS Claude may (#8940). const titleClaimsIdentity = explicitTitleAgent !== 'claude' || isClaudeIdentityFrameTitle(args.title) - // Why: native OpenCode titles can reclaim stale launch intent before any observed hook signal. - // Why (#13341): once a launch owner is still active, a nested child's title is not pane reuse. + // Why (#13341): nested child titles are not pane reuse while launch ownership is active — + // except native OC| (#8478), which is provider identity, not a nested CLI. const titleReclaimsReusedPane = priorIdentity !== null && explicitTitleAgent !== null && explicitTitleAgent !== priorIdentity && titleClaimsIdentity && - !activeLaunchAgent && + (!activeLaunchAgent || nativeOpenCodeTitle) && (args.hasObservedAgentSignal || hasCompletedHook || nativeOpenCodeTitle) - // Why: OpenCode's native `OC |` frame is provider identity at startup (before hooks), not a nested child. - const openCodeStartupTitle = - nativeOpenCodeTitle && - launchAgent !== null && - launchAgent !== 'opencode' && - !args.hasObservedAgentSignal // Why: native OpenCode titles lack a provider generation and cannot displace durable ownership. const titleAgent = processProvesShell || sleepingSessionAgent || (nativeOpenCodeTitle && idleFocusedIdentity !== null) ? null - : openCodeStartupTitle + : openCodeNativeReclaim || titleReclaimsReusedPane ? explicitTitleAgent - : titleReclaimsReusedPane - ? explicitTitleAgent - : priorIdentity - ? null - : explicitTitleAgent + : priorIdentity + ? null + : explicitTitleAgent // Identity-first precedence (see useTabAgent JSDoc): live hook > process > title > completed > sleeping > launch > sibling. return ( diff --git a/src/renderer/src/lib/use-tab-agent-nested-identity.test.ts b/src/renderer/src/lib/use-tab-agent-nested-identity.test.ts index e6da6c2a667..6174b78a13d 100644 --- a/src/renderer/src/lib/use-tab-agent-nested-identity.test.ts +++ b/src/renderer/src/lib/use-tab-agent-nested-identity.test.ts @@ -155,4 +155,55 @@ describe('resolveTabAgentFromSignals — nested child identity (#13341)', () => }) ).toBe('codex') }) + + // Why (#8478): native OC| reclaim must stay OpenCode after observation and under launch. + it('keeps OpenCode OC| reclaim after process observation under a non-OpenCode launch', () => { + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: false, + isRemote: false, + title: 'OC | Greeting', + hookAgent: null, + processAgent: null, + launchAgent: 'claude' + }) + ).toBe('opencode') + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: false, + title: 'OC | Greeting', + hookAgent: null, + processAgent: 'claude', + launchAgent: 'claude' + }) + ).toBe('opencode') + }) + + it('still fences nested Codex title under Claude launch (not OC|)', () => { + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: false, + title: '✦ Codex', + hookAgent: null, + processAgent: 'codex', + launchAgent: 'claude' + }) + ).toBe('claude') + }) + + it('clears OpenCode reclaim on shell return so launch can exit', () => { + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: false, + title: 'zsh', + hookAgent: null, + processAgent: null, + processShellForeground: true, + launchAgent: 'claude' + }) + ).toBeNull() + }) }) diff --git a/src/renderer/src/lib/use-tab-agent-opencode-native-title.test.ts b/src/renderer/src/lib/use-tab-agent-opencode-native-title.test.ts index 483d2585c1b..301905c88dc 100644 --- a/src/renderer/src/lib/use-tab-agent-opencode-native-title.test.ts +++ b/src/renderer/src/lib/use-tab-agent-opencode-native-title.test.ts @@ -241,18 +241,17 @@ describe('OpenCode native title tab identity', () => { launchAgent: 'claude' }) ).toBe('claude') - // Why (#13341): nested process under active launch is not stronger than parent ownership. + // Why (#8478): native OC| reclaim is durable — process observation must not undo OpenCode. expect( resolveTabAgentFromSignals({ - hasObservedAgentSignal: false, + hasObservedAgentSignal: true, isRemote: false, title: 'OC | Greeting', hookAgent: null, processAgent: 'codex', - sleepingSessionAgent: 'claude', launchAgent: 'claude' }) - ).toBe('claude') + ).toBe('opencode') for (const title of [ 'OpenCode ready',