From 600b620562dbd60120234ec26aba6e98f1125ee6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 31 Jul 2026 17:10:25 -0700 Subject: [PATCH] fix(sidebar): preserve agent identity through hydration --- .../src/lib/session-write-subscriber.test.ts | 51 ++++++++------- .../src/lib/session-write-subscriber.ts | 8 ++- .../lib/use-tab-agent-process-signals.test.ts | 7 ++- src/renderer/src/lib/use-tab-agent.test.ts | 46 ++++++++++++++ src/renderer/src/lib/use-tab-agent.ts | 4 +- .../src/lib/workspace-session-patch.test.ts | 4 +- .../src/lib/workspace-session-patch.ts | 2 +- .../lib/workspace-session-terminal-tabs.ts | 22 +++++++ src/renderer/src/lib/workspace-session.ts | 17 +---- .../src/store/slices/store-cascades.test.ts | 25 ++++++++ .../src/store/slices/terminal-helpers.test.ts | 17 ++++- .../src/store/slices/terminal-helpers.ts | 18 +++--- .../store/slices/terminals-hydration.test.ts | 14 ++--- src/renderer/src/store/slices/terminals.ts | 62 +++++++++++-------- src/shared/types.ts | 2 + 15 files changed, 207 insertions(+), 92 deletions(-) create mode 100644 src/renderer/src/lib/workspace-session-terminal-tabs.ts diff --git a/src/renderer/src/lib/session-write-subscriber.test.ts b/src/renderer/src/lib/session-write-subscriber.test.ts index 3c31296095a..2d93e14b62f 100644 --- a/src/renderer/src/lib/session-write-subscriber.test.ts +++ b/src/renderer/src/lib/session-write-subscriber.test.ts @@ -308,33 +308,36 @@ describe('createSessionWriteSubscriber', () => { cleanup() }) - it('ignores pendingActivationSpawn-only changes', () => { - const persist = vi.fn<(payload: WorkspaceSessionWrite) => void>() - const cleanup = createSessionWriteSubscriber({ store: useAppStore, persist }) + it.each(['pendingActivationSpawn', 'titleHydrationPending'] as const)( + 'ignores %s-only changes', + (transientKey) => { + const persist = vi.fn<(payload: WorkspaceSessionWrite) => void>() + const cleanup = createSessionWriteSubscriber({ store: useAppStore, persist }) - useAppStore.setState({ - workspaceSessionReady: true, - hydrationSucceeded: true, - ...makeTerminalSessionState('bash') - }) - vi.advanceTimersByTime(200) - persist.mockClear() + useAppStore.setState({ + workspaceSessionReady: true, + hydrationSucceeded: true, + ...makeTerminalSessionState('bash') + }) + vi.advanceTimersByTime(200) + persist.mockClear() - useAppStore.setState({ - tabsByWorktree: { - 'wt-1': [ - { - ...useAppStore.getState().tabsByWorktree['wt-1'][0], - pendingActivationSpawn: true - } - ] - } - }) - vi.advanceTimersByTime(200) + useAppStore.setState({ + tabsByWorktree: { + 'wt-1': [ + { + ...useAppStore.getState().tabsByWorktree['wt-1'][0], + [transientKey]: true + } + ] + } + }) + vi.advanceTimersByTime(200) - expect(persist).not.toHaveBeenCalled() - cleanup() - }) + expect(persist).not.toHaveBeenCalled() + cleanup() + } + ) it('ignores decorative unified terminal label churn', () => { const persist = vi.fn<(payload: WorkspaceSessionWrite) => void>() diff --git a/src/renderer/src/lib/session-write-subscriber.ts b/src/renderer/src/lib/session-write-subscriber.ts index ba1e2c7cdd0..d6316f1a6b8 100644 --- a/src/renderer/src/lib/session-write-subscriber.ts +++ b/src/renderer/src/lib/session-write-subscriber.ts @@ -11,9 +11,11 @@ type UnifiedTabsByWorktree = AppState['unifiedTabsByWorktree'] type UnifiedTab = UnifiedTabsByWorktree[string][number] const TERMINAL_TAB_LIVE_TITLE_KEYS = new Set(['title']) -// Why: this handoff flag is stripped from workspace sessions, so toggling it -// alone should not rebuild and rewrite the durable session payload. -const TERMINAL_TAB_TRANSIENT_SESSION_KEYS = new Set(['pendingActivationSpawn']) +// Why: these handoff flags are stripped from workspace sessions, so toggling them alone must not rewrite the durable payload. +const TERMINAL_TAB_TRANSIENT_SESSION_KEYS = new Set([ + 'pendingActivationSpawn', + 'titleHydrationPending' +]) function terminalTabChangedForSession(prev: TerminalTab, next: TerminalTab): boolean { if (prev === next) { diff --git a/src/renderer/src/lib/use-tab-agent-process-signals.test.ts b/src/renderer/src/lib/use-tab-agent-process-signals.test.ts index e69b27a814b..dfa7f50a8d5 100644 --- a/src/renderer/src/lib/use-tab-agent-process-signals.test.ts +++ b/src/renderer/src/lib/use-tab-agent-process-signals.test.ts @@ -197,7 +197,12 @@ describe('useTabAgent process signals', () => { }) it('clears hookless launch identity on shell-foreground evidence despite a stale title', async () => { - const launchedTab = { ...baseTab, launchAgent: 'aider' as const, title: '⠸ aider working' } + const launchedTab = { + ...baseTab, + launchAgent: 'aider' as const, + title: '⠸ aider working', + titleHydrationPending: true as const + } const root = await renderHookProbe(launchedTab) await setPaneForeground({ agent: 'aider', shellForeground: false }) diff --git a/src/renderer/src/lib/use-tab-agent.test.ts b/src/renderer/src/lib/use-tab-agent.test.ts index 287b73b9c42..4e87994e338 100644 --- a/src/renderer/src/lib/use-tab-agent.test.ts +++ b/src/renderer/src/lib/use-tab-agent.test.ts @@ -780,6 +780,52 @@ describe('useTabAgent', () => { expect(clearTabLaunchAgent).toHaveBeenCalledWith('tab-1') }) + it('waits for authoritative title replay before clearing hydrated launch identity', async () => { + const paneKey = makePaneKey('tab-1', LEAF_ID) + useAppStore.setState({ + agentStatusByPaneKey: { + [paneKey]: completedAgentStatus(paneKey) + } + }) + const hydratedTab = { + ...baseTab, + title: 'Terminal 1', + defaultTitle: 'Terminal 1', + titleHydrationPending: true as const + } + + const root = await renderHookProbe(hydratedTab) + expect(clearTabLaunchAgent).not.toHaveBeenCalled() + + const { titleHydrationPending: _pendingTitle, ...settledTab } = hydratedTab + void _pendingTitle + await rerenderHookProbe(root, settledTab) + expect(clearTabLaunchAgent).toHaveBeenCalledWith('tab-1') + }) + + it('keeps hydrated launch identity when replay confirms a working agent title', async () => { + const paneKey = makePaneKey('tab-1', LEAF_ID) + useAppStore.setState({ + agentStatusByPaneKey: { + [paneKey]: completedAgentStatus(paneKey) + } + }) + const hydratedTab = { + ...baseTab, + title: 'Terminal 1', + defaultTitle: 'Terminal 1', + titleHydrationPending: true as const + } + + const root = await renderHookProbe(hydratedTab) + const { titleHydrationPending: _pendingTitle, ...settledTab } = hydratedTab + void _pendingTitle + await rerenderHookProbe(root, { ...settledTab, title: '⠋ indexing repository' }) + + expect(clearTabLaunchAgent).not.toHaveBeenCalled() + expect(latestHookAgent).toBe('codex') + }) + it('clears hookless launch identity once its own title evidence ends at a shell', async () => { const geminiTab = { ...baseTab, launchAgent: 'gemini' as const, title: '✦ Gemini CLI' } diff --git a/src/renderer/src/lib/use-tab-agent.ts b/src/renderer/src/lib/use-tab-agent.ts index 711f0f106d1..969ce86d250 100644 --- a/src/renderer/src/lib/use-tab-agent.ts +++ b/src/renderer/src/lib/use-tab-agent.ts @@ -291,7 +291,8 @@ export function useTabAgent(tab: TerminalTab): TuiAgent | null { ]) useEffect(() => { - if (!tab.launchAgent) { + // Why: an unresolved hydration title blocks heuristic clearing, but current shell-foreground proof remains authoritative. + if (!tab.launchAgent || (tab.titleHydrationPending && !processShellForeground)) { return } // Why: AND ref with state — the ref is generation-safe this commit while state can lag one render behind a respawn. @@ -321,6 +322,7 @@ export function useTabAgent(tab: TerminalTab): TuiAgent | null { tab.defaultTitle, tab.id, tab.launchAgent, + tab.titleHydrationPending, tab.title ]) diff --git a/src/renderer/src/lib/workspace-session-patch.test.ts b/src/renderer/src/lib/workspace-session-patch.test.ts index da578c1ff42..6cfffe77c64 100644 --- a/src/renderer/src/lib/workspace-session-patch.test.ts +++ b/src/renderer/src/lib/workspace-session-patch.test.ts @@ -181,7 +181,8 @@ describe('buildWorkspaceSessionPatch', () => { title: 'shell', ptyId: 'pty-1', worktreeId: localWorktreeId, - pendingActivationSpawn: true + pendingActivationSpawn: true, + titleHydrationPending: true } as never ] }, @@ -216,6 +217,7 @@ describe('buildWorkspaceSessionPatch', () => { ].sort() ) expect('pendingActivationSpawn' in patch.tabsByWorktree![localWorktreeId][0]).toBe(false) + expect('titleHydrationPending' in patch.tabsByWorktree![localWorktreeId][0]).toBe(false) expect(patch.terminalLayoutsByTabId?.['tab-local'].buffersByLeafId).toBeUndefined() expect(patch.terminalLayoutsByTabId?.['tab-local'].scrollbackRefsByLeafId).toBeUndefined() }) diff --git a/src/renderer/src/lib/workspace-session-patch.ts b/src/renderer/src/lib/workspace-session-patch.ts index e88f476a8f6..749e28df569 100644 --- a/src/renderer/src/lib/workspace-session-patch.ts +++ b/src/renderer/src/lib/workspace-session-patch.ts @@ -6,10 +6,10 @@ import { buildEditorSessionData, buildPersistedBrowserPagesByWorkspace, buildPersistedBrowserTabsByWorktree, - buildSanitizedTabsByWorktree, buildTerminalSessionData, type WorkspaceSessionSnapshot } from './workspace-session' +import { buildSanitizedTabsByWorktree } from './workspace-session-terminal-tabs' import { buildPersistedUnifiedTabSessionData } from './workspace-session-unified-tabs' import { buildLastVisitedAtByWorktreeId } from './workspace-session-focus-recency' import { buildSleepingAgentSessionData } from './workspace-session-sleeping-agents' diff --git a/src/renderer/src/lib/workspace-session-terminal-tabs.ts b/src/renderer/src/lib/workspace-session-terminal-tabs.ts new file mode 100644 index 00000000000..081c2862540 --- /dev/null +++ b/src/renderer/src/lib/workspace-session-terminal-tabs.ts @@ -0,0 +1,22 @@ +import type { TerminalTab, WorkspaceSessionState } from '../../../shared/types' + +export function buildSanitizedTabsByWorktree( + tabsByWorktree: Record +): WorkspaceSessionState['tabsByWorktree'] { + // Why: session:set persists without Zod re-parse, so renderer-only lifecycle flags must be stripped explicitly. + return Object.fromEntries( + Object.entries(tabsByWorktree).map(([worktreeId, tabs]) => [ + worktreeId, + tabs.map((tab) => { + const { + pendingActivationSpawn: _pendingActivationSpawn, + titleHydrationPending: _titleHydrationPending, + ...rest + } = tab + void _pendingActivationSpawn + void _titleHydrationPending + return rest + }) + ]) + ) +} diff --git a/src/renderer/src/lib/workspace-session.ts b/src/renderer/src/lib/workspace-session.ts index cfe1bbf3034..d7180751845 100644 --- a/src/renderer/src/lib/workspace-session.ts +++ b/src/renderer/src/lib/workspace-session.ts @@ -13,6 +13,7 @@ import { buildPersistedUnifiedTabSessionData } from './workspace-session-unified import { buildLastVisitedAtByWorktreeId } from './workspace-session-focus-recency' import { buildSleepingAgentSessionData } from './workspace-session-sleeping-agents' import { buildActiveConnectionIdsAtShutdown } from './workspace-session-reconnect-targets' +import { buildSanitizedTabsByWorktree } from './workspace-session-terminal-tabs' export { buildActiveConnectionIdsAtShutdown } @@ -223,22 +224,6 @@ export function buildPersistedBrowserPagesByWorkspace( ) } -export function buildSanitizedTabsByWorktree( - tabsByWorktree: WorkspaceSessionSnapshot['tabsByWorktree'] -): WorkspaceSessionState['tabsByWorktree'] { - // Why: strip transient pendingActivationSpawn — session:set persists without Zod re-parse, so a stale flag would drop the first PTY spawn on restart. - return Object.fromEntries( - Object.entries(tabsByWorktree).map(([worktreeId, tabs]) => [ - worktreeId, - tabs.map((tab) => { - const { pendingActivationSpawn: _unused, ...rest } = tab - void _unused - return rest - }) - ]) - ) -} - export function buildTerminalSessionData( snapshot: WorkspaceSessionSnapshot ): Pick { diff --git a/src/renderer/src/store/slices/store-cascades.test.ts b/src/renderer/src/store/slices/store-cascades.test.ts index 57bf015adec..391297d6705 100644 --- a/src/renderer/src/store/slices/store-cascades.test.ts +++ b/src/renderer/src/store/slices/store-cascades.test.ts @@ -2845,6 +2845,31 @@ describe('setActiveWorktree', () => { expect(store.getState().sortEpoch).toBe(sortEpoch) }) + it('settles title hydration when the authoritative title matches the fallback', () => { + const store = createTestStore() + const wt = 'repo1::/path/wt1' + seedStore(store, { + worktreesByRepo: { + repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })] + }, + tabsByWorktree: { + [wt]: [ + makeTab({ + id: 'tab-1', + worktreeId: wt, + title: 'Terminal 1', + defaultTitle: 'Terminal 1', + titleHydrationPending: true + }) + ] + } + }) + + store.getState().updateTabTitle('tab-1', 'Terminal 1') + + expect(store.getState().tabsByWorktree[wt][0]?.titleHydrationPending).toBeUndefined() + }) + it('repairs a stale unified tab label when a live title repeats', () => { const store = createTestStore() const wt = 'repo1::/path/wt1' diff --git a/src/renderer/src/store/slices/terminal-helpers.test.ts b/src/renderer/src/store/slices/terminal-helpers.test.ts index 93c6f395d3a..aebefc6f2bc 100644 --- a/src/renderer/src/store/slices/terminal-helpers.test.ts +++ b/src/renderer/src/store/slices/terminal-helpers.test.ts @@ -58,9 +58,7 @@ describe('clearTransientTerminalState', () => { expect(result.title).toBe('bash') }) - // Why: idle agent titles (e.g. "* Claude done") are reset to the fallback - // on hydration — the prior-session agent is no longer running, so showing - // its last title would be misleading. + // Why: hide the persisted status until the surviving PTY replays its current title. it('resets idle agent titles to fallback across hydration', () => { const tab = makeTab({ title: '* Claude done', customTitle: null }) const result = clearTransientTerminalState(tab, 0) @@ -73,6 +71,19 @@ describe('clearTransientTerminalState', () => { expect(result.title).toBe('Terminal 1') }) + it('fences launch identity until its neutralized title replays', () => { + const tab = makeTab({ title: '⠋ indexing repository', launchAgent: 'codex' }) + const result = clearTransientTerminalState(tab, 0) + + expect(result).toMatchObject({ title: 'Terminal 1', titleHydrationPending: true }) + }) + + it('does not fence neutralized titles without launch identity', () => { + const result = clearTransientTerminalState(makeTab({ title: '⠋ background job' }), 0) + + expect(result.titleHydrationPending).toBeUndefined() + }) + it('uses "Terminal {index+1}" when customTitle is whitespace only', () => { const tab = makeTab({ title: '⠋ codex running', customTitle: ' ' }) const result = clearTransientTerminalState(tab, 0) diff --git a/src/renderer/src/store/slices/terminal-helpers.ts b/src/renderer/src/store/slices/terminal-helpers.ts index 7f42c049ab6..ad9b9ce8607 100644 --- a/src/renderer/src/store/slices/terminal-helpers.ts +++ b/src/renderer/src/store/slices/terminal-helpers.ts @@ -24,18 +24,18 @@ export function singlePaneLayoutSnapshot( } export function clearTransientTerminalState(tab: TerminalTab, index: number): TerminalTab { + const { titleHydrationPending: _titleHydrationPending, ...persistedTab } = tab + void _titleHydrationPending + const titleWasReset = classifyTitleActivity(tab.title) !== null + const launchIdentityNeedsTitleReplay = titleWasReset && tab.launchAgent !== undefined return { - ...tab, + ...persistedTab, ptyId: null, - title: getResetTitle(tab, index) + title: titleWasReset ? getFallbackTitle(tab, index) : tab.title, + ...(launchIdentityNeedsTitleReplay ? { titleHydrationPending: true } : {}) } } -function getResetTitle(tab: TerminalTab, index: number): string { - const fallbackTitle = - tab.customTitle?.trim() || tab.defaultTitle?.trim() || `Terminal ${index + 1}` - // Why: reset any recognized agent title on hydration. The prior-session - // agent is no longer running after a restart, so showing a stale - // "Claude done" or spinner would be misleading. - return classifyTitleActivity(tab.title) ? fallbackTitle : tab.title +function getFallbackTitle(tab: TerminalTab, index: number): string { + return tab.customTitle?.trim() || tab.defaultTitle?.trim() || `Terminal ${index + 1}` } diff --git a/src/renderer/src/store/slices/terminals-hydration.test.ts b/src/renderer/src/store/slices/terminals-hydration.test.ts index 837fde338fd..10e0dd4983f 100644 --- a/src/renderer/src/store/slices/terminals-hydration.test.ts +++ b/src/renderer/src/store/slices/terminals-hydration.test.ts @@ -533,29 +533,29 @@ describe('hydrateWorkspaceSession', () => { it('resets persisted agent titles to the fallback label on hydration', () => { const store = createTestStore() - const worktreeId = 'repo1::/wt-1' + const wt = 'repo1::/wt-1' seedStore(store, { worktreesByRepo: { - repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1', path: '/wt-1' })] + repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/wt-1' })] } }) const session: WorkspaceSessionState = { activeRepoId: 'repo1', - activeWorktreeId: worktreeId, - activeTabId: 'tab-1', + activeWorktreeId: wt, + activeTabId: 't', terminalLayoutsByTabId: {}, tabsByWorktree: { - [worktreeId]: [makeTab({ id: 'tab-1', worktreeId, title: '* Claude done', ptyId: '207' })] + [wt]: [makeTab({ id: 't', worktreeId: wt, title: '✳ Claude ready', launchAgent: 'claude' })] } } store.getState().hydrateWorkspaceSession(session) - expect(store.getState().tabsByWorktree[worktreeId]).toEqual([ + expect(store.getState().tabsByWorktree[wt]).toEqual([ expect.objectContaining({ - id: 'tab-1', title: 'Terminal 1', + titleHydrationPending: true, ptyId: null }) ]) diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 4d1492db8fa..a107db9b2b3 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -1958,46 +1958,56 @@ export const createTerminalSlice: StateCreator } const nextTitle = title.trim() || getFallbackTabTitle(currentTab) const currentUnifiedTabs = s.unifiedTabsByWorktree[ownerWorktreeId] ?? [] - if (isDecorativeAgentTitleFrameChange(currentTab.title, nextTitle)) { - const unifiedTabsWithCurrentLabel = updateUnifiedTerminalLabel( - currentUnifiedTabs, - tabId, - currentTab.title - ) - return unifiedTabsWithCurrentLabel - ? { - unifiedTabsByWorktree: { - ...s.unifiedTabsByWorktree, - [ownerWorktreeId]: unifiedTabsWithCurrentLabel - } - } - : s + let settledTab: TerminalTab = currentTab + if (currentTab.titleHydrationPending) { + const { titleHydrationPending: _titleHydrationPending, ...tabWithoutPendingTitle } = + currentTab + void _titleHydrationPending + settledTab = tabWithoutPendingTitle } + const resolvedTitle = isDecorativeAgentTitleFrameChange(currentTab.title, nextTitle) + ? currentTab.title + : nextTitle const unifiedTabsWithUpdatedLabel = updateUnifiedTerminalLabel( currentUnifiedTabs, tabId, - nextTitle + resolvedTitle ) - if (currentTab.title === nextTitle) { - return unifiedTabsWithUpdatedLabel - ? { - unifiedTabsByWorktree: { - ...s.unifiedTabsByWorktree, - [ownerWorktreeId]: unifiedTabsWithUpdatedLabel + if (currentTab.title === resolvedTitle) { + if (!currentTab.titleHydrationPending) { + return unifiedTabsWithUpdatedLabel + ? { + unifiedTabsByWorktree: { + ...s.unifiedTabsByWorktree, + [ownerWorktreeId]: unifiedTabsWithUpdatedLabel + } } - } - : s + : s + } + const nextTabs = [...tabs] + nextTabs[tabIndex] = settledTab + return { + tabsByWorktree: { ...s.tabsByWorktree, [ownerWorktreeId]: nextTabs }, + ...(unifiedTabsWithUpdatedLabel + ? { + unifiedTabsByWorktree: { + ...s.unifiedTabsByWorktree, + [ownerWorktreeId]: unifiedTabsWithUpdatedLabel + } + } + : {}) + } } const ownerTabs = tabs.map((tab) => tab.id === tabId ? { - ...tab, + ...settledTab, // Why: PTYs can briefly emit an empty title as an agent exits; keep the stable fallback instead of a blank tab. - title: nextTitle, + title: resolvedTitle, defaultTitle: tab.defaultTitle ?? (/^Terminal \d+$/.test(tab.title) ? tab.title : undefined) ?? - (/^Terminal \d+$/.test(nextTitle) ? nextTitle : undefined) + (/^Terminal \d+$/.test(resolvedTitle) ? resolvedTitle : undefined) } : tab ) diff --git a/src/shared/types.ts b/src/shared/types.ts index 51a8d37dc53..6331d5846e2 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -899,6 +899,8 @@ export type TerminalTab = { * hook status overrides this once the agent does anything. Plain terminals * and manually-started agents omit it. */ launchAgent?: TuiAgent + /** True while a neutralized hydration title awaits its first authoritative update; never persisted. */ + titleHydrationPending?: true /** Why: when `setActiveWorktree` bumps generation on all-dead tabs to drive a * TerminalPane remount, the fresh PTY that results is caused by navigation, * not by the user doing work. Without this flag the resulting