diff --git a/application/state/codingCliSessionSignalController.test.ts b/application/state/codingCliSessionSignalController.test.ts index 0188851d8..8bfff1408 100644 --- a/application/state/codingCliSessionSignalController.test.ts +++ b/application/state/codingCliSessionSignalController.test.ts @@ -1,9 +1,15 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import React from 'react'; +import { act, create, type ReactTestRenderer } from 'react-test-renderer'; import type { CodingCliProviderId } from '../../domain/codingCliProviders'; import type { DynamicTabTitleMode } from '../../domain/models'; -import { createCodingCliSessionSignalController } from './codingCliSessionSignalController'; +import { + createCodingCliSessionSignalController, + type CodingCliSessionSignalController, + useCodingCliSessionSignals, +} from './codingCliSessionSignalController'; test('connected sessions stop every icon mutation while dynamic titles are off and resume when enabled', () => { let mode: DynamicTabTitleMode = 'agent'; @@ -109,3 +115,114 @@ test('switching between enabled modes preserves exhausted output scans', () => { controller.handleTerminalOutput(session.id, 'Welcome to Claude Code'); assert.deepEqual(providerUpdates, []); }); + +test('useCodingCliSessionSignals preserves scan state and follows current props', async () => { + const actEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }; + const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT; + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + + type ProbeProps = { + mode: DynamicTabTitleMode; + sessionIds: string[]; + session: { id: string; codingCliProviderId?: CodingCliProviderId }; + onProvider: (providerId: CodingCliProviderId | null) => void; + }; + let controller: CodingCliSessionSignalController | null = null; + const Probe = (props: ProbeProps) => { + controller = useCodingCliSessionSignals({ + dynamicTabTitleMode: props.mode, + sessionIds: props.sessionIds, + getSession: (sessionId) => sessionId === props.session.id ? props.session : undefined, + onUpdateSessionCodingCliProvider: (_sessionId, providerId) => props.onProvider(providerId), + }); + return null; + }; + + const session = { id: 'session-hook' }; + const firstUpdates: Array = []; + const latestUpdates: Array = []; + let renderer: ReactTestRenderer | null = null; + + try { + await act(async () => { + renderer = create(React.createElement(Probe, { + mode: 'agent', + sessionIds: [session.id], + session, + onProvider: (providerId) => firstUpdates.push(providerId), + })); + }); + controller!.handleTerminalOutput(session.id, 'Welcome to Claude '); + + await act(async () => { + renderer!.update(React.createElement(Probe, { + mode: 'agent', + sessionIds: [session.id], + session, + onProvider: (providerId) => latestUpdates.push(providerId), + })); + }); + controller!.handleTerminalOutput(session.id, 'Code'); + assert.deepEqual(firstUpdates, []); + assert.deepEqual(latestUpdates, ['claude']); + + session.codingCliProviderId = undefined; + await act(async () => { + renderer!.update(React.createElement(Probe, { + mode: 'off', + sessionIds: [session.id], + session, + onProvider: (providerId) => latestUpdates.push(providerId), + })); + }); + controller!.handleCommandSubmitted(session.id, 'opencode'); + assert.deepEqual(latestUpdates, ['claude']); + } finally { + await act(async () => renderer?.unmount()); + actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment; + } +}); + +test('useCodingCliSessionSignals forgets scans for sessions removed from props', async () => { + const actEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }; + const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT; + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + + const session = { id: 'session-removed' }; + const providerUpdates: Array = []; + let sessionIds = [session.id]; + let controller: CodingCliSessionSignalController | null = null; + const Probe = () => { + controller = useCodingCliSessionSignals({ + dynamicTabTitleMode: 'agent', + sessionIds, + getSession: () => session, + onUpdateSessionCodingCliProvider: (_sessionId, providerId) => { + providerUpdates.push(providerId); + }, + }); + return null; + }; + let renderer: ReactTestRenderer | null = null; + + try { + await act(async () => { + renderer = create(React.createElement(Probe)); + }); + controller!.handleTerminalOutput(session.id, 'x'.repeat(16384)); + + sessionIds = []; + await act(async () => renderer!.update(React.createElement(Probe))); + sessionIds = [session.id]; + await act(async () => renderer!.update(React.createElement(Probe))); + controller!.handleTerminalOutput(session.id, 'Welcome to Claude Code'); + assert.deepEqual(providerUpdates, ['claude']); + } finally { + await act(async () => renderer?.unmount()); + actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment; + } +}); diff --git a/application/state/codingCliSessionSignalController.ts b/application/state/codingCliSessionSignalController.ts index 7798fa8f2..2497828e1 100644 --- a/application/state/codingCliSessionSignalController.ts +++ b/application/state/codingCliSessionSignalController.ts @@ -43,6 +43,7 @@ type UseCodingCliSessionSignalsOptions = Omit< 'getDynamicTabTitleMode' > & { dynamicTabTitleMode: DynamicTabTitleMode; + sessionIds: readonly string[]; }; export function createCodingCliSessionSignalController( @@ -195,9 +196,22 @@ export function useCodingCliSessionSignals( } const controller = controllerRef.current; + const liveSessionIdsRef = useRef(new Set(options.sessionIds)); useEffect(() => { controller.handleDynamicTabTitleModeChange(options.dynamicTabTitleMode); }, [controller, options.dynamicTabTitleMode]); + useEffect(() => { + const nextSessionIds = new Set(options.sessionIds); + for (const sessionId of liveSessionIdsRef.current) { + if (!nextSessionIds.has(sessionId)) controller.forgetSession(sessionId); + } + liveSessionIdsRef.current = nextSessionIds; + }, [controller, options.sessionIds]); + useEffect(() => () => { + for (const sessionId of liveSessionIdsRef.current) { + controller.forgetSession(sessionId); + } + }, [controller]); return controller; } diff --git a/components/TerminalLayer.tsx b/components/TerminalLayer.tsx index 711745595..2f5b67d74 100644 --- a/components/TerminalLayer.tsx +++ b/components/TerminalLayer.tsx @@ -302,6 +302,7 @@ const TerminalLayerInner: React.FC = ({ const codingCliSignalController = useCodingCliSessionSignals({ dynamicTabTitleMode: terminalSettings?.dynamicTabTitleMode ?? 'agent', + sessionIds: sessions.map((session) => session.id), getSession: (sessionId) => sessionsRef.current.find((candidate) => candidate.id === sessionId), onUpdateSessionCodingCliProvider, onUpdateSessionDynamicTitle, @@ -405,11 +406,11 @@ const TerminalLayerInner: React.FC = ({ terminalSettings?.autoCloseOnExit ?? true, ); if (intent.kind === "closeSession") { - onCloseSession(sessionId); + handleCloseSession(sessionId); } else { onUpdateSessionStatus(sessionId, 'disconnected'); } - }, [onCloseSession, onUpdateSessionStatus, terminalSettings?.autoCloseOnExit]); + }, [handleCloseSession, onUpdateSessionStatus, terminalSettings?.autoCloseOnExit]); const handleOsDetected = useCallback((hostId: string, distro: string) => { onUpdateHostDistro(hostId, distro);