fix(sidebar): preserve agent identity through hydration

This commit is contained in:
Brennan Benson
2026-07-31 17:10:25 -07:00
parent 475f63ea1b
commit 600b620562
15 changed files with 207 additions and 92 deletions
@@ -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>()
@@ -11,9 +11,11 @@ type UnifiedTabsByWorktree = AppState['unifiedTabsByWorktree']
type UnifiedTab = UnifiedTabsByWorktree[string][number]
const TERMINAL_TAB_LIVE_TITLE_KEYS = new Set<keyof TerminalTab>(['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<keyof TerminalTab>(['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<keyof TerminalTab>([
'pendingActivationSpawn',
'titleHydrationPending'
])
function terminalTabChangedForSession(prev: TerminalTab, next: TerminalTab): boolean {
if (prev === next) {
@@ -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 })
@@ -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' }
+3 -1
View File
@@ -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
])
@@ -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()
})
@@ -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'
@@ -0,0 +1,22 @@
import type { TerminalTab, WorkspaceSessionState } from '../../../shared/types'
export function buildSanitizedTabsByWorktree(
tabsByWorktree: Record<string, TerminalTab[]>
): 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
})
])
)
}
+1 -16
View File
@@ -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<WorkspaceSessionState, 'activeWorktreeIdsOnShutdown' | 'remoteSessionIdsByTabId'> {
@@ -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'
@@ -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)
@@ -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}`
}
@@ -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
})
])
+36 -26
View File
@@ -1958,46 +1958,56 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
}
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
)
+2
View File
@@ -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