fix(dashboard): follow active agent pane mode

This commit is contained in:
Brennan Benson
2026-08-12 19:52:54 -07:00
parent 94eb909f11
commit 43eb1103ca
11 changed files with 151 additions and 50 deletions
@@ -199,7 +199,7 @@ describe('buildDashboardSnapshot', () => {
expect(card.unseen).toBe(true)
})
it('carries native-chat session identity when Chat UI is the default view', () => {
it('carries native-chat session identity when the exact live leaf is in chat', () => {
const snapshot = buildDashboardSnapshot(
baseState({
agentStatusByPaneKey: {
@@ -211,32 +211,35 @@ describe('buildDashboardSnapshot', () => {
}
})
},
settings: { experimentalNativeChat: true, openAgentTabsInChatByDefault: true } as never
runtimeNativeChatLeafIdByTabId: { [TAB_ID]: LEAF_ID },
settings: { experimentalNativeChat: true, openAgentTabsInChatByDefault: false } as never
} as unknown as Partial<DashboardSnapshotState>),
NOW
)
expect(snapshot.cards[0]).toMatchObject({
hostKind: 'local',
viewMode: 'chat',
sessionId: 'session-abc',
transcriptPath: '/home/dev/.claude/projects/orca/session-abc.jsonl'
transcriptPath: expect.stringContaining('session-abc')
})
})
it('keeps native chat disabled when the experiment is off', () => {
it.each([
['the experiment is off', 'claude', false],
['the leaf agent is unsupported', 'aider', true]
] as const)('keeps native chat disabled when %s', (_case, agentType, experimentEnabled) => {
const snapshot = buildDashboardSnapshot(
baseState({
agentStatusByPaneKey: { [PANE_KEY]: entry({}) },
settings: { experimentalNativeChat: false, openAgentTabsInChatByDefault: true }
agentStatusByPaneKey: { [PANE_KEY]: entry({ agentType }) },
runtimeNativeChatLeafIdByTabId: { [TAB_ID]: LEAF_ID },
settings: { experimentalNativeChat: experimentEnabled, openAgentTabsInChatByDefault: true }
} as unknown as Partial<DashboardSnapshotState>),
NOW
)
expect(snapshot.cards[0].viewMode).toBeUndefined()
})
it('omits native-chat payload when the default view is the terminal', () => {
it('omits native-chat payload when the live leaf is terminal despite a chat default', () => {
const snapshot = buildDashboardSnapshot(
baseState({
agentStatusByPaneKey: {
@@ -248,26 +251,43 @@ describe('buildDashboardSnapshot', () => {
}
})
},
settings: { experimentalNativeChat: true, openAgentTabsInChatByDefault: false }
} as unknown as Partial<DashboardSnapshotState>),
NOW
)
expect(snapshot.cards[0]).not.toHaveProperty('viewMode')
expect(snapshot.cards[0]).not.toHaveProperty('sessionId')
expect(snapshot.cards[0]).not.toHaveProperty('transcriptPath')
})
it('keeps agents without a native-chat renderer in terminal mode', () => {
const snapshot = buildDashboardSnapshot(
baseState({
agentStatusByPaneKey: { [PANE_KEY]: entry({ agentType: 'aider' }) },
terminalLayoutsByTabId: {},
runtimeNativeChatLeafIdByTabId: { [TAB_ID]: LEAF_ID },
settings: { experimentalNativeChat: true, openAgentTabsInChatByDefault: true }
} as unknown as Partial<DashboardSnapshotState>),
NOW
)
expect(snapshot.cards[0].viewMode).toBeUndefined()
expect(snapshot.cards[0]).not.toHaveProperty('viewMode')
})
it('marks only the native-chat owner in a split tab as chat', () => {
const snapshot = buildDashboardSnapshot(
baseState({
agentStatusByPaneKey: {
[PANE_KEY]: entry({}),
[CHILD_PANE_KEY]: entry({ paneKey: CHILD_PANE_KEY })
},
terminalLayoutsByTabId: {
[TAB_ID]: {
root: {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', leafId: LEAF_ID },
second: { type: 'leaf', leafId: CHILD_LEAF_ID }
},
ptyIdsByLeafId: { [LEAF_ID]: 'pty1', [CHILD_LEAF_ID]: 'pty-child' }
} as never
},
ptyIdsByTabId: { [TAB_ID]: ['pty1', 'pty-child'] },
runtimeNativeChatLeafIdByTabId: { [TAB_ID]: LEAF_ID },
settings: { experimentalNativeChat: true, openAgentTabsInChatByDefault: true } as never
}),
NOW
)
expect(snapshot.cards.find((card) => card.paneKey === PANE_KEY)?.viewMode).toBe('chat')
expect(snapshot.cards.find((card) => card.paneKey === CHILD_PANE_KEY)?.viewMode).toBeUndefined()
})
it('marks SSH transcript paths as remote from the dashboard renderer', () => {
@@ -39,10 +39,7 @@ import {
resolveDashboardCardContext,
type DashboardCardContextState
} from './dashboard-card-context'
import {
dashboardCardNativeChatMetadata,
dashboardNativeChatByDefault
} from './dashboard-card-native-chat'
import { dashboardCardNativeChatMetadata } from './dashboard-card-native-chat'
import {
dashboardCardMapWorkspaceMetadata,
collectActiveDashboardWorkspaces
@@ -79,7 +76,11 @@ export type DashboardSnapshotState = Pick<
| 'settings'
> &
DashboardCardContextState &
Partial<DashboardCardTerminalInputState & DashboardLaunchDetectionState>
Partial<
DashboardCardTerminalInputState &
DashboardLaunchDetectionState &
Pick<AppState, 'runtimeNativeChatLeafIdByTabId'>
>
/**
* Derive the serializable dashboard snapshot from the live renderer store.
@@ -101,7 +102,6 @@ export function buildDashboardSnapshot(
const includeCardDetails = options.includeCardDetails !== false
const generatedTitlesEnabled = state.settings?.tabAutoGenerateTitle === true
const showIdle = state.settings?.experimentalAgentDashboardShowIdle === true
const nativeChatByDefault = includeCardDetails && dashboardNativeChatByDefault(state.settings)
const activeWorktrees = collectActiveDashboardWorkspaces(state, includeCardDetails)
const filterOptions =
options.includeFilterOptions === false
@@ -277,9 +277,13 @@ export function buildDashboardSnapshot(
terminalInput ?? undefined,
clientHost.platform
),
...(nativeChatByDefault
? dashboardCardNativeChatMetadata(row.agentType, row.entry.providerSession)
: {})
...dashboardCardNativeChatMetadata(
row.agentType,
row.entry.providerSession,
state.settings?.experimentalNativeChat === true &&
layoutPtyId !== null &&
state.runtimeNativeChatLeafIdByTabId?.[tabId] === leafId
)
}
: {}),
workspaceStatusId: context?.workspaceStatus.id,
@@ -1,28 +1,15 @@
import type { GlobalSettings } from '../../../../shared/types'
import { isNativeChatSupportedAgent } from '../../../../shared/native-chat-agent-support'
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
import type { DashboardAgentRow } from './useDashboardData'
/** The inspector follows the user's existing Chat UI default-view setting —
* no separate dashboard opt-in, and no per-tab mode reads. */
export function dashboardNativeChatByDefault(
settings:
| Pick<GlobalSettings, 'experimentalNativeChat' | 'openAgentTabsInChatByDefault'>
| null
| undefined
): boolean {
return (
settings?.experimentalNativeChat === true && settings?.openAgentTabsInChatByDefault === true
)
}
/** Chat-mode cards carry the transcript coordinates the inspector reads.
* hostKind comes from the map's workspace metadata, not from here. */
export function dashboardCardNativeChatMetadata(
agentType: string,
providerSession: DashboardAgentRow['entry']['providerSession']
providerSession: DashboardAgentRow['entry']['providerSession'],
isNativeChatView: boolean
): Pick<DashboardCard, 'viewMode' | 'sessionId' | 'transcriptPath'> {
if (!isNativeChatSupportedAgent(agentType)) {
if (!isNativeChatView || !isNativeChatSupportedAgent(agentType)) {
return {}
}
return {
@@ -65,6 +65,7 @@ function makeSnapshotWatchState(): DashboardSnapshotWatchState {
terminalLayoutsByTabId: {},
ptyIdsByTabId: {},
runtimePaneTitlesByTabId: {},
runtimeNativeChatLeafIdByTabId: {},
acknowledgedAgentsByPaneKey: {},
settings: null,
agentStatusEpoch: 0,
@@ -186,6 +187,7 @@ describe('useDashboardPopoutBridge', () => {
'terminalLayoutsByTabId',
'ptyIdsByTabId',
'runtimePaneTitlesByTabId',
'runtimeNativeChatLeafIdByTabId',
'acknowledgedAgentsByPaneKey',
'hostedReviewCache',
'prCache',
@@ -52,6 +52,7 @@ export function dashboardSnapshotInputsChanged(
state.terminalLayoutsByTabId !== previousState.terminalLayoutsByTabId ||
state.ptyIdsByTabId !== previousState.ptyIdsByTabId ||
state.runtimePaneTitlesByTabId !== previousState.runtimePaneTitlesByTabId ||
state.runtimeNativeChatLeafIdByTabId !== previousState.runtimeNativeChatLeafIdByTabId ||
state.acknowledgedAgentsByPaneKey !== previousState.acknowledgedAgentsByPaneKey ||
state.hostedReviewCache !== previousState.hostedReviewCache ||
state.prCache !== previousState.prCache ||
@@ -22,6 +22,7 @@ export function useLiveDashboardSnapshot(): DashboardSnapshot {
const terminalLayoutsByTabId = useAppStore((s) => s.terminalLayoutsByTabId)
const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId)
const runtimePaneTitlesByTabId = useAppStore((s) => s.runtimePaneTitlesByTabId)
const runtimeNativeChatLeafIdByTabId = useAppStore((s) => s.runtimeNativeChatLeafIdByTabId)
const acknowledgedAgentsByPaneKey = useAppStore((s) => s.acknowledgedAgentsByPaneKey)
const hostedReviewCache = useAppStore((s) => s.hostedReviewCache)
const prCache = useAppStore((s) => s.prCache)
@@ -74,6 +75,7 @@ export function useLiveDashboardSnapshot(): DashboardSnapshot {
terminalLayoutsByTabId,
ptyIdsByTabId,
runtimePaneTitlesByTabId,
runtimeNativeChatLeafIdByTabId,
acknowledgedAgentsByPaneKey,
hostedReviewCache,
prCache,
@@ -112,6 +114,7 @@ export function useLiveDashboardSnapshot(): DashboardSnapshot {
terminalLayoutsByTabId,
ptyIdsByTabId,
runtimePaneTitlesByTabId,
runtimeNativeChatLeafIdByTabId,
acknowledgedAgentsByPaneKey,
hostedReviewCache,
prCache,
@@ -510,6 +510,7 @@ function TerminalPane(
?.viewMode === 'chat'
)
const nativeChatEnabled = useAppStore((store) => store.settings?.experimentalNativeChat === true)
const settings = useAppStore((store) => store.settings)
const effectiveChatViewMode = nativeChatEnabled && isChatViewMode
const unifiedTabLabel = useAppStore(
(store) =>
@@ -707,7 +708,6 @@ function TerminalPane(
const clearTerminalPaneUnread = useAppStore((store) => store.clearTerminalPaneUnread)
const openSpacePage = useAppStore((store) => store.openSpacePage)
const refreshWorkspaceSpace = useAppStore((store) => store.refreshWorkspaceSpace)
const settings = useAppStore((store) => store.settings)
const updateSettings = useAppStore((store) => store.updateSettings)
const requestLinkRoutingPreference = useLinkRoutingPreferenceDialog()
const keybindings = useAppStore((store) => store.keybindings)
@@ -2876,6 +2876,27 @@ function TerminalPane(
applyNativeChatLeafRoute,
isChatEligibleForLeaf
])
useEffect(() => {
const leafId =
settings?.experimentalAgentDashboardPopout === true &&
effectiveChatViewMode &&
chatLeafStillMounted
? chatLeafId
: null
useAppStore.getState().setRuntimeNativeChatLeafId(tabId, leafId)
}, [
chatLeafId,
chatLeafStillMounted,
effectiveChatViewMode,
settings?.experimentalAgentDashboardPopout,
tabId
])
useEffect(
() => () => {
useAppStore.getState().setRuntimeNativeChatLeafId(tabId, null)
},
[tabId]
)
const chatPane =
isChatViewMode && chatLeafId
? (managedPanes.find((pane) => pane.leafId === chatLeafId) ?? null)
@@ -109,6 +109,7 @@ function seedMaps(store: ReturnType<typeof createTestStore>): void {
[TAB2]: { command: 'setup-b', direction: 'vertical' }
},
pendingStartupByTabId: { [TAB1]: { command: 'start-a' }, [TAB2]: { command: 'start-b' } },
runtimeNativeChatLeafIdByTabId: { [TAB1]: 'leaf-1', [TAB2]: 'leaf-2' },
codexRestartNoticeByPtyId: {
[PTY1]: { previousAccountLabel: 'a1', nextAccountLabel: 'a2' },
[PTY2]: { previousAccountLabel: 'b1', nextAccountLabel: 'b2' }
@@ -173,6 +174,16 @@ function seedMaps(store: ReturnType<typeof createTestStore>): void {
describe('bulk worktree purge evicts the per-tab/per-pty terminal maps it previously leaked', () => {
beforeEach(() => vi.clearAllMocks())
it('drops native-chat ownership immediately when a tab closes', () => {
const store = createTestStore()
seedMaps(store)
store.getState().closeTab(TAB1, { remoteCloseOwnedByHost: true })
expect(store.getState().runtimeNativeChatLeafIdByTabId[TAB1]).toBeUndefined()
expect(store.getState().runtimeNativeChatLeafIdByTabId[TAB2]).toBe('leaf-2')
})
it('drops removed worktree entries (both tabId- and ptyId-keyed), retains survivors', () => {
const store = createTestStore()
seedMaps(store)
@@ -188,6 +199,7 @@ describe('bulk worktree purge evicts the per-tab/per-pty terminal maps it previo
expect(s.pendingIssueCommandSplitByTabId[TAB1]).toBeUndefined()
expect(s.pendingSetupSplitByTabId[TAB1]).toBeUndefined()
expect(s.pendingStartupByTabId[TAB1]).toBeUndefined()
expect(s.runtimeNativeChatLeafIdByTabId[TAB1]).toBeUndefined()
expect(s.codexRestartNoticeByPtyId[PTY1]).toBeUndefined()
expect(s.migrationUnsupportedByPtyId[PTY1]).toBeUndefined()
expect(s.suppressedPtyExitIds[PTY1]).toBeUndefined()
@@ -208,6 +220,7 @@ describe('bulk worktree purge evicts the per-tab/per-pty terminal maps it previo
expect(s.pendingIssueCommandSplitByTabId[TAB2]).toEqual({ command: 'b' })
expect(s.pendingSetupSplitByTabId[TAB2]).toEqual({ command: 'setup-b', direction: 'vertical' })
expect(s.pendingStartupByTabId[TAB2]).toEqual({ command: 'start-b' })
expect(s.runtimeNativeChatLeafIdByTabId[TAB2]).toBe('leaf-2')
expect(s.codexRestartNoticeByPtyId[PTY2]).toBeDefined()
expect(s.migrationUnsupportedByPtyId[PTY2]).toBeDefined()
expect(s.suppressedPtyExitIds[PTY2]).toBe(true)
@@ -81,4 +81,23 @@ describe('tab view mode', () => {
store.getState().toggleTabViewMode('missing-tab')
expect(store.getState().unifiedTabsByWorktree[WT]).toBe(before)
})
it('tracks and clears the exact runtime chat leaf without retaining stale tabs', () => {
store.getState().setRuntimeNativeChatLeafId('left-tab', 'leaf-chat')
expect(store.getState().runtimeNativeChatLeafIdByTabId).toEqual({
'left-tab': 'leaf-chat'
})
store.getState().setRuntimeNativeChatLeafId('left-tab', null)
expect(store.getState().runtimeNativeChatLeafIdByTabId).toEqual({})
})
it('preserves the runtime chat map reference for repeated ownership', () => {
store.getState().setRuntimeNativeChatLeafId('left-tab', 'leaf-chat')
const before = store.getState().runtimeNativeChatLeafIdByTabId
store.getState().setRuntimeNativeChatLeafId('left-tab', 'leaf-chat')
expect(store.getState().runtimeNativeChatLeafIdByTabId).toBe(before)
})
})
@@ -522,6 +522,9 @@ export type TerminalSlice = {
ptyIdsByTabId: Record<string, string[]>
/** Live pane titles by tabId then paneId; preserves per-pane agent status (unlike the legacy tab title) while TerminalPane is mounted. */
runtimePaneTitlesByTabId: Record<string, Record<number, string>>
/** Dashboard-only live ownership of the leaf currently rendered as native chat. */
runtimeNativeChatLeafIdByTabId: Record<string, string>
setRuntimeNativeChatLeafId: (tabId: string, leafId: string | null) => void
/** Per-tab unread flags (BEL or agent-complete); ephemeral UI state, not persisted. Cleared when the user activates/interacts with the tab. */
unreadTerminalTabs: Record<string, true>
/** Pane-keyed attention marker (narrower than unreadTerminalTabs); clears when the user interacts with the exact pane that raised it. */
@@ -1051,6 +1054,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
activeTabIdByWorktree: {},
ptyIdsByTabId: {},
runtimePaneTitlesByTabId: {},
runtimeNativeChatLeafIdByTabId: {},
unreadTerminalTabs: {},
unreadTerminalPanes: {},
unreadAgentCompletionPanes: {},
@@ -1674,6 +1678,8 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
delete nextPendingReconnectPtyIdByTabId[tabId]
const nextRuntimePaneTitlesByTabId = { ...s.runtimePaneTitlesByTabId }
delete nextRuntimePaneTitlesByTabId[tabId]
const nextRuntimeNativeChatLeafIdByTabId = { ...s.runtimeNativeChatLeafIdByTabId }
delete nextRuntimeNativeChatLeafIdByTabId[tabId]
const nextDirectSshPaneRetryByTabId = { ...s.directSshPaneRetryByTabId }
delete nextDirectSshPaneRetryByTabId[tabId]
const nextDirectSshLivePtyBindingByTabId = {
@@ -1786,6 +1792,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
deferredSshSessionIdsByTabId: nextDeferredSshSessionIdsByTabId,
pendingReconnectPtyIdByTabId: nextPendingReconnectPtyIdByTabId,
runtimePaneTitlesByTabId: nextRuntimePaneTitlesByTabId,
runtimeNativeChatLeafIdByTabId: nextRuntimeNativeChatLeafIdByTabId,
directSshPaneRetryByTabId: nextDirectSshPaneRetryByTabId,
directSshLivePtyBindingByTabId: nextDirectSshLivePtyBindingByTabId,
directSshPaneRetryHistoryByTabId: nextDirectSshPaneRetryHistoryByTabId,
@@ -3614,6 +3621,29 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
transferNormalizedTerminalLayoutPtyOwnership(get(), tabId, ownershipTransfers)
},
setRuntimeNativeChatLeafId: (tabId, leafId) => {
set((s) => {
const current = s.runtimeNativeChatLeafIdByTabId[tabId]
if (leafId === null) {
if (current === undefined) {
return s
}
const next = { ...s.runtimeNativeChatLeafIdByTabId }
delete next[tabId]
return { runtimeNativeChatLeafIdByTabId: next }
}
if (current === leafId) {
return s
}
return {
runtimeNativeChatLeafIdByTabId: {
...s.runtimeNativeChatLeafIdByTabId,
[tabId]: leafId
}
}
})
},
syncPaneDetachPtyOwnership: ({
detachedLeafId,
detachedPtyId,
@@ -2582,6 +2582,7 @@ function buildWorktreePurgeState(s: AppState, worktreeIds: string[]): Partial<Ap
terminalLayoutsByTabId: omitByTabId(s.terminalLayoutsByTabId),
ptyIdsByTabId: omitByTabId(s.ptyIdsByTabId),
runtimePaneTitlesByTabId: omitByTabId(s.runtimePaneTitlesByTabId),
runtimeNativeChatLeafIdByTabId: omitByTabId(s.runtimeNativeChatLeafIdByTabId),
automaticAgentResumeClaimsByTabId: omitByTabId(s.automaticAgentResumeClaimsByTabId),
nativeChatLaunchPromptByTabId: omitByTabId(s.nativeChatLaunchPromptByTabId),
nativeChatLaunchDraftByTabId: omitByTabId(s.nativeChatLaunchDraftByTabId),