From 43eb1103ca4f40d7f6e2a792b22d6e43e8c28044 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 12 Aug 2026 19:52:54 -0700 Subject: [PATCH] fix(dashboard): follow active agent pane mode --- .../build-dashboard-snapshot.test.ts | 68 ++++++++++++------- .../dashboard/build-dashboard-snapshot.ts | 22 +++--- .../dashboard/dashboard-card-native-chat.ts | 19 +----- .../useDashboardPopoutBridge.test.tsx | 2 + .../dashboard/useDashboardPopoutBridge.ts | 1 + .../dashboard/useLiveDashboardSnapshot.ts | 3 + .../components/terminal-pane/TerminalPane.tsx | 23 ++++++- ...-worktree-purge-terminal-maps-leak.test.ts | 13 ++++ .../src/store/slices/tab-view-mode.test.ts | 19 ++++++ src/renderer/src/store/slices/terminals.ts | 30 ++++++++ src/renderer/src/store/slices/worktrees.ts | 1 + 11 files changed, 151 insertions(+), 50 deletions(-) diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts index bf97afc92c5..96532f0b8fa 100644 --- a/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts +++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts @@ -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), 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), 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), - 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), 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', () => { diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts index b62cf91b842..d82b8256c42 100644 --- a/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts +++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts @@ -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 + Partial< + DashboardCardTerminalInputState & + DashboardLaunchDetectionState & + Pick + > /** * 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, diff --git a/src/renderer/src/components/dashboard/dashboard-card-native-chat.ts b/src/renderer/src/components/dashboard/dashboard-card-native-chat.ts index 61e86eeee34..ddf6b70d8e4 100644 --- a/src/renderer/src/components/dashboard/dashboard-card-native-chat.ts +++ b/src/renderer/src/components/dashboard/dashboard-card-native-chat.ts @@ -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 - | 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 { - if (!isNativeChatSupportedAgent(agentType)) { + if (!isNativeChatView || !isNativeChatSupportedAgent(agentType)) { return {} } return { diff --git a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx index c632f19bc4e..eaac2658363 100644 --- a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx +++ b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx @@ -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', diff --git a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts index 78b2bc49690..11584660450 100644 --- a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts +++ b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts @@ -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 || diff --git a/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts b/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts index 9782f91c697..2cb20c751a4 100644 --- a/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts +++ b/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts @@ -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, diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 323a3dc497e..bd0d24b41cd 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -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) diff --git a/src/renderer/src/store/slices/bulk-worktree-purge-terminal-maps-leak.test.ts b/src/renderer/src/store/slices/bulk-worktree-purge-terminal-maps-leak.test.ts index 0163aacc4c9..778051170bb 100644 --- a/src/renderer/src/store/slices/bulk-worktree-purge-terminal-maps-leak.test.ts +++ b/src/renderer/src/store/slices/bulk-worktree-purge-terminal-maps-leak.test.ts @@ -109,6 +109,7 @@ function seedMaps(store: ReturnType): 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): 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) diff --git a/src/renderer/src/store/slices/tab-view-mode.test.ts b/src/renderer/src/store/slices/tab-view-mode.test.ts index aa892756b23..22683ff127b 100644 --- a/src/renderer/src/store/slices/tab-view-mode.test.ts +++ b/src/renderer/src/store/slices/tab-view-mode.test.ts @@ -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) + }) }) diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index c644b6db241..6e107ba3985 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -522,6 +522,9 @@ export type TerminalSlice = { ptyIdsByTabId: Record /** Live pane titles by tabId then paneId; preserves per-pane agent status (unlike the legacy tab title) while TerminalPane is mounted. */ runtimePaneTitlesByTabId: Record> + /** Dashboard-only live ownership of the leaf currently rendered as native chat. */ + runtimeNativeChatLeafIdByTabId: Record + 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 /** 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 activeTabIdByWorktree: {}, ptyIdsByTabId: {}, runtimePaneTitlesByTabId: {}, + runtimeNativeChatLeafIdByTabId: {}, unreadTerminalTabs: {}, unreadTerminalPanes: {}, unreadAgentCompletionPanes: {}, @@ -1674,6 +1678,8 @@ export const createTerminalSlice: StateCreator 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 deferredSshSessionIdsByTabId: nextDeferredSshSessionIdsByTabId, pendingReconnectPtyIdByTabId: nextPendingReconnectPtyIdByTabId, runtimePaneTitlesByTabId: nextRuntimePaneTitlesByTabId, + runtimeNativeChatLeafIdByTabId: nextRuntimeNativeChatLeafIdByTabId, directSshPaneRetryByTabId: nextDirectSshPaneRetryByTabId, directSshLivePtyBindingByTabId: nextDirectSshLivePtyBindingByTabId, directSshPaneRetryHistoryByTabId: nextDirectSshPaneRetryHistoryByTabId, @@ -3614,6 +3621,29 @@ export const createTerminalSlice: StateCreator 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, diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 94a3a692360..8ccc3105209 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -2582,6 +2582,7 @@ function buildWorktreePurgeState(s: AppState, worktreeIds: string[]): Partial