diff --git a/src/components/sidebar/hooks/useExternalCliSessions.test.ts b/src/components/sidebar/hooks/useExternalCliSessions.test.ts index 0acc487..5571437 100644 --- a/src/components/sidebar/hooks/useExternalCliSessions.test.ts +++ b/src/components/sidebar/hooks/useExternalCliSessions.test.ts @@ -39,6 +39,57 @@ test('hydrates external metadata onto a discovery row that arrived first', () => }]); }); +test('rebinds a restarted process in the same pane to its new provider session', () => { + const restartedProcess = { pid: 84, startedAtMs: 200 }; + const row: DiscoveryRow = { + key: 'external:1', lane: 'external', tmuxName: 'stream-name', tmux, + process: restartedProcess, kind: 'omo', providerSessionId: 'transcript-2', + activity: 'waiting_user', cwd: '/new-project', presence: 'present', + }; + const staleSession: ExternalCliSession = { + tmuxName: 'rest-name', tmux, process, kind: 'omo', transcriptSessionId: 'transcript-1', + sessionName: 'Previous session', model: 'old-model', effort: 'high', + transcriptEnded: true, projectPath: '/old-project', + }; + + const sessions = mergeExternalDiscoveryRows( + [row], + new Map([[tmuxPaneIdentityKey(tmux), staleSession]]), + [staleSession], + ); + + assert.deepEqual(sessions, [{ + tmuxName: 'stream-name', + tmux, + process: restartedProcess, + kind: 'omo', + activity: 'waiting_user', + projectPath: '/new-project', + transcriptSessionId: 'transcript-2', + presence: 'present', + authority: 'stream', + }]); +}); + +test('rebinds a new provider session even when the process generation is unchanged', () => { + const row: DiscoveryRow = { + key: 'external:1', lane: 'external', tmuxName: 'stream-name', tmux, process, + kind: 'claude', providerSessionId: 'transcript-2', activity: 'waiting_user', + cwd: '/stream', presence: 'present', + }; + const staleSession: ExternalCliSession = { + tmuxName: 'rest-name', tmux, process, kind: 'claude', transcriptSessionId: 'transcript-1', + sessionName: 'Previous session', model: 'old-model', effort: 'high', + }; + + const [session] = mergeExternalDiscoveryRows([row], new Map(), [staleSession]); + + assert.equal(session.transcriptSessionId, 'transcript-2'); + assert.equal(session.sessionName, undefined); + assert.equal(session.model, undefined); + assert.equal(session.effort, undefined); +}); + test('stream loss clears mutable provider activity before REST fallback', () => { const sessions: ExternalCliSession[] = [ { tmuxName: 'error', tmux, process, kind: 'claude', activity: 'error' }, diff --git a/src/components/sidebar/hooks/useExternalCliSessions.ts b/src/components/sidebar/hooks/useExternalCliSessions.ts index 8ca6f1f..1d53903 100644 --- a/src/components/sidebar/hooks/useExternalCliSessions.ts +++ b/src/components/sidebar/hooks/useExternalCliSessions.ts @@ -28,6 +28,23 @@ export type ExternalCliSession = { authority?: 'stream' | 'rest' | 'none'; connectionIssue?: ProviderConnectionIssue; }; + +function canReuseExternalMetadata( + row: DiscoveryRow, + metadata: ExternalCliSession | undefined, +): metadata is ExternalCliSession { + if (!metadata || metadata.kind !== row.kind) return false; + if (row.kind === 'ssh' || row.kind === 'shell') return true; + if (!row.process || !metadata.process) return false; + if ( + row.process.pid !== metadata.process.pid + || row.process.startedAtMs !== metadata.process.startedAtMs + ) return false; + return !row.providerSessionId + || !metadata.transcriptSessionId + || row.providerSessionId === metadata.transcriptSessionId; +} + export function mergeExternalDiscoveryRows( rows: DiscoveryRow[], restSessions: Map, @@ -40,8 +57,15 @@ export function mergeExternalDiscoveryRows( return rows .filter((row) => row.lane === 'external' && ['claude', 'codex', 'cursor', 'opencode', 'omp', 'omo', 'ssh', 'shell'].includes(row.kind)) .map((row) => { - const metadata = restSessions.get(tmuxPaneIdentityKey(row.tmux)) ?? previous.get(tmuxPaneIdentityKey(row.tmux)); - const { connectionIssue: _staleConnectionIssue, ...stableMetadata } = metadata ?? {}; + const paneKey = tmuxPaneIdentityKey(row.tmux); + const restMetadata = restSessions.get(paneKey); + const previousMetadata = previous.get(paneKey); + const reusableMetadata = canReuseExternalMetadata(row, restMetadata) + ? restMetadata + : canReuseExternalMetadata(row, previousMetadata) + ? previousMetadata + : undefined; + const { connectionIssue: _staleConnectionIssue, ...stableMetadata } = reusableMetadata ?? {}; if (row.presence !== 'present') { return externalIdentityOnly({ tmuxName: row.tmuxName, @@ -59,7 +83,8 @@ export function mergeExternalDiscoveryRows( kind: row.kind as ExternalCliSession['kind'], activity: row.activity, ...(row.connectionIssue ? { connectionIssue: row.connectionIssue } : {}), - projectPath: row.cwd ?? metadata?.projectPath, + projectPath: row.cwd ?? reusableMetadata?.projectPath, + ...(row.providerSessionId ? { transcriptSessionId: row.providerSessionId } : {}), presence: 'present' as const, authority: 'stream' as const, };