mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 16:03:20 +00:00
fix(sidebar): preserve agent identity through hydration
This commit is contained in:
@@ -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' }
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
])
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
])
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user