From 6274bb62bda47d2329f3b7fce3eea418c6d98b97 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:34:19 -0700 Subject: [PATCH] fix(sidebar): stop a workspace with a structured chat reading as asleep A workspace whose only surface is a structured native chat showed the sleeping moon, lost its pull-request glyph to it, and would vanish entirely under the hide-sleeping filter. hasActiveWorkspaceActivity asked three terminal-shaped questions: a tab in tabsByWorktree with a live PTY, a browser tab, or a fresh non-done agent-status row. A structured chat answers none of them. Its tab lives in unifiedTabsByWorktree, so the PTY term never sees it, and an idle session projects state 'done', which is exactly what isFreshNonDoneAgentStatus refuses. Both chats finishing their turn was enough to draw the moon. Add a fourth term keyed on the chat EXISTING. Not on a live provider child: that child is held only while the chat's pane is visible and is evicted 15s after it is not, so keying on it would flip the glyph on every worktree switch and report a process recycle the user never sees. The transcript, and the session's ability to take the next send, outlive the child. The term goes in the shared predicate, not the card, because the moon, the hide-sleeping filter and the Cmd+J palette all read it and must not disagree about which workspaces are asleep. Supporting moves, no behaviour change: the projection sits beside its siblings in visible-worktree-activity-inputs, and buildVisibleWorktreeOptionsFromState moves to its own module, which leaves the filter a pure function of its options and keeps both files under the 300-line cap without raising it. --- .../sidebar/use-worktree-sleep-state.test.tsx | 100 ++++++++++++++++++ .../sidebar/use-worktree-sleep-state.ts | 10 +- .../visible-worktree-activity-inputs.ts | 32 ++++++ .../visible-worktree-options-from-state.ts | 53 ++++++++++ .../components/sidebar/visible-worktrees.ts | 61 ++--------- .../sidebar/worktree-filter-visibility.ts | 3 +- .../worktree-list/listing/use-filters.ts | 6 +- .../listing/use-visible-worktrees.ts | 10 +- .../use-worktree-jump-palette-worktrees.ts | 10 +- .../src/lib/worktree-activity-state.ts | 16 ++- 10 files changed, 234 insertions(+), 67 deletions(-) create mode 100644 src/renderer/src/components/sidebar/visible-worktree-options-from-state.ts diff --git a/src/renderer/src/components/sidebar/use-worktree-sleep-state.test.tsx b/src/renderer/src/components/sidebar/use-worktree-sleep-state.test.tsx index 65bfabcdb9d..54903d22e54 100644 --- a/src/renderer/src/components/sidebar/use-worktree-sleep-state.test.tsx +++ b/src/renderer/src/components/sidebar/use-worktree-sleep-state.test.tsx @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { resetAgentStatusEpochClockForTests } from '@/lib/agent-status-epoch-clock' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import { makePaneKey } from '../../../../shared/stable-pane-id' +import type { Tab } from '../../../../shared/tab-types' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { getWorktreeIdsWithLiveAgent, isInactiveWorkspace } from '@/lib/worktree-activity-state' import { @@ -15,6 +16,7 @@ const LEAF_ID = '11111111-1111-4111-8111-111111111111' type MockState = { tabsByWorktree: Record browserTabsByWorktree: Record + unifiedTabsByWorktree: Record ptyIdsByTabId: Record agentStatusEpoch: number agentStatusByPaneKey: Record @@ -59,6 +61,27 @@ function makeAgentStatusEntry(args: { } } +function makeUnifiedTab(args: { + id: string + worktreeId: string + contentType: Tab['contentType'] + agentSessionAgent?: Tab['agentSessionAgent'] +}): Tab { + return { + id: args.id, + entityId: `entity-${args.id}`, + groupId: 'group-1', + worktreeId: args.worktreeId, + contentType: args.contentType, + label: args.id, + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0, + ...(args.agentSessionAgent ? { agentSessionAgent: args.agentSessionAgent } : {}) + } +} + function SleepProbe({ worktreeId }: { worktreeId: string }) { return {String(useIsSleepingWorktree(worktreeId))} } @@ -73,6 +96,7 @@ describe('useIsSleepingWorktree', () => { mockState = { tabsByWorktree: {}, browserTabsByWorktree: {}, + unifiedTabsByWorktree: {}, ptyIdsByTabId: {}, agentStatusEpoch: 0, agentStatusByPaneKey: {}, @@ -92,6 +116,82 @@ describe('useIsSleepingWorktree', () => { ) }) + it('treats a worktree whose only surface is a structured chat as awake', () => { + const worktreeId = 'repo1::/path/wt1' + mockState = { + ...mockState, + unifiedTabsByWorktree: { + [worktreeId]: [ + makeUnifiedTab({ + id: 'chat-1', + worktreeId, + contentType: 'agent-session', + agentSessionAgent: 'claude' + }) + ] + } + } + + expect(renderToStaticMarkup()).toBe('false') + }) + + it('keeps a structured chat awake once its turn has finished', () => { + // The reported bug: an idle structured session reports state 'done', which is exactly what + // isFreshNonDoneAgentStatus refuses, so the live-agent term cannot hold this workspace open. + const worktreeId = 'repo1::/path/wt1' + const paneKey = makePaneKey('chat-1', LEAF_ID) + mockState = { + ...mockState, + unifiedTabsByWorktree: { + [worktreeId]: [ + makeUnifiedTab({ + id: 'chat-1', + worktreeId, + contentType: 'agent-session', + agentSessionAgent: 'codex' + }) + ] + }, + agentStatusByPaneKey: { + [paneKey]: makeAgentStatusEntry({ paneKey, state: 'done', worktreeId }) + } + } + + expect(renderToStaticMarkup()).toBe('false') + }) + + it('does not treat a non-chat unified tab as activity', () => { + // Negative control: the term keys on a structured chat, not on any unified tab existing. + const worktreeId = 'repo1::/path/wt1' + mockState = { + ...mockState, + unifiedTabsByWorktree: { + [worktreeId]: [makeUnifiedTab({ id: 'file-1', worktreeId, contentType: 'editor' })] + } + } + + expect(renderToStaticMarkup()).toBe('true') + }) + + it('does not treat a structured chat as activity for a different worktree', () => { + const worktreeId = 'repo1::/path/wt1' + mockState = { + ...mockState, + unifiedTabsByWorktree: { + 'repo1::/path/wt2': [ + makeUnifiedTab({ + id: 'chat-1', + worktreeId: 'repo1::/path/wt2', + contentType: 'agent-session', + agentSessionAgent: 'claude' + }) + ] + } + } + + expect(renderToStaticMarkup()).toBe('true') + }) + it('treats a worktree with a live PTY as awake', () => { const worktreeId = 'repo1::/path/wt1' mockState = { diff --git a/src/renderer/src/components/sidebar/use-worktree-sleep-state.ts b/src/renderer/src/components/sidebar/use-worktree-sleep-state.ts index 0db546d8652..894d6872639 100644 --- a/src/renderer/src/components/sidebar/use-worktree-sleep-state.ts +++ b/src/renderer/src/components/sidebar/use-worktree-sleep-state.ts @@ -1,7 +1,9 @@ import { useAppStore } from '@/store' import { getAgentStatusEpochNow } from '@/lib/agent-status-epoch-clock' import { getWorktreeIdsWithLiveAgent, isInactiveWorkspace } from '@/lib/worktree-activity-state' +import { getWorktreeIdsWithStructuredChat } from './visible-worktree-activity-inputs' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { Tab } from '../../../../shared/tab-types' type TabLike = { id: string } @@ -13,6 +15,7 @@ type SleepStateInput = { tabsByWorktree?: Record | null ptyIdsByTabId?: Record | null browserTabsByWorktree?: Record | null + unifiedTabsByWorktree?: Record | null } type LiveAgentGeneration = { @@ -53,8 +56,8 @@ function selectWorktreeIdsWithLiveAgent(state: SleepStateInput): ReadonlySet @@ -33,3 +35,33 @@ export function getVisibleWorktreeBrowserActivityTabs( ): Record { return browserProjection.project(browserTabsByWorktree) } + +const EMPTY_WORKTREE_IDS: ReadonlySet = new Set() +const structuredChatWorktreeIds = new WeakMap, ReadonlySet>() + +/** + * Worktree ids holding a structured chat tab. + * + * Why existence rather than a live provider child: that child is held only while the chat's pane is + * visible and is evicted 15s after it is not, so keying on it would flip a workspace to sleeping on + * every worktree switch and report a process recycle the user never sees. The chat itself — its + * transcript, and its ability to take the next send — outlives the child. + */ +export function getWorktreeIdsWithStructuredChat( + unifiedTabsByWorktree: Record | null | undefined +): ReadonlySet { + if (!unifiedTabsByWorktree) { + return EMPTY_WORKTREE_IDS + } + // Keyed on the snapshot, like the tab projection it reads: zustand re-runs every mounted card's + // selector on each store write, and this is a whole-store scan. + const cached = structuredChatWorktreeIds.get(unifiedTabsByWorktree) + if (cached) { + return cached + } + const worktreeIds = new Set( + getStructuredAgentSessionTabs(unifiedTabsByWorktree).map((tab) => tab.worktreeId) + ) + structuredChatWorktreeIds.set(unifiedTabsByWorktree, worktreeIds) + return worktreeIds +} diff --git a/src/renderer/src/components/sidebar/visible-worktree-options-from-state.ts b/src/renderer/src/components/sidebar/visible-worktree-options-from-state.ts new file mode 100644 index 00000000000..d9a09639cdf --- /dev/null +++ b/src/renderer/src/components/sidebar/visible-worktree-options-from-state.ts @@ -0,0 +1,53 @@ +import type { Repo } from '../../../../shared/repo-types' +import { getSettingsFocusedExecutionHostId } from '../../../../shared/execution-host' +import { getWorktreeIdsWithLiveAgent } from '@/lib/worktree-activity-state' +import type { useAppStore } from '@/store' +import { getWorktreeIdsWithStructuredChat } from './visible-worktree-activity-inputs' +import { + EMPTY_PAIRED_DEVICE_IDS_BY_ENVIRONMENT, + getPairedDeviceIdsByEnvironment +} from './workspace-creator-visibility' +import type { VisibleWorktreeOptions } from './visible-worktrees' + +/** + * Read the store into the filter inputs `computeVisibleWorktrees` decides from. + * + * Why separate from the filter itself: this is the only part of the pipeline that touches the + * store, so keeping it here leaves the filter a pure function of its options — which is what lets + * the sidebar, Cmd+J and the Cmd+1–9 handler all reuse it without a React render. + */ +export function buildVisibleWorktreeOptionsFromState( + state: ReturnType, + repoMap: Map +): VisibleWorktreeOptions { + return { + filterRepoIds: state.filterRepoIds, + showSleepingWorkspaces: state.showSleepingWorkspaces, + tabsByWorktree: state.tabsByWorktree, + ptyIdsByTabId: state.ptyIdsByTabId, + browserTabsByWorktree: state.browserTabsByWorktree, + worktreeIdsWithLiveAgent: getWorktreeIdsWithLiveAgent( + state.agentStatusByPaneKey, + state.tabsByWorktree, + Date.now() + ), + worktreeIdsWithStructuredChat: getWorktreeIdsWithStructuredChat(state.unifiedTabsByWorktree), + hideDefaultBranchWorkspace: state.hideDefaultBranchWorkspace, + hideAutomationGeneratedWorkspaces: state.hideAutomationGeneratedWorkspaces, + hideCliCreatedWorkspaces: state.hideCliCreatedWorkspaces, + hideDetachedHeadWorkspaces: state.hideDetachedHeadWorkspaces, + hideWorkspacesFromOtherDevices: state.hideWorkspacesFromOtherDevices, + pairedDeviceIdsByEnvironment: state.hideWorkspacesFromOtherDevices + ? getPairedDeviceIdsByEnvironment( + state.runtimeEnvironments, + state.runtimeStatusByEnvironmentId + ) + : EMPTY_PAIRED_DEVICE_IDS_BY_ENVIRONMENT, + alwaysShowDefaultBranchWorkspace: state.alwaysShowDefaultBranchWorkspace, + repoMap, + workspaceHostScope: state.workspaceHostScope, + visibleWorkspaceHostIds: state.visibleWorkspaceHostIds, + defaultHostId: getSettingsFocusedExecutionHostId(state.settings), + worktreeLineageById: state.worktreeLineageById + } +} diff --git a/src/renderer/src/components/sidebar/visible-worktrees.ts b/src/renderer/src/components/sidebar/visible-worktrees.ts index 0961cb8e981..2bbdb07c7f5 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.ts @@ -23,7 +23,10 @@ import { } from './visible-worktree-host-scope' import type { Worktree } from '../../../../shared/worktree/types' import { buildWorktreeComparator, sortWorktreesSmart } from './smart-sort' -import { getWorktreeIdsWithLiveAgent, isInactiveWorkspace } from '@/lib/worktree-activity-state' +import { isInactiveWorkspace } from '@/lib/worktree-activity-state' +export { getWorktreeIdsWithStructuredChat } from './visible-worktree-activity-inputs' +// Runtime edge only one way: the builder imports VisibleWorktreeOptions as a type, which erases. +import { buildVisibleWorktreeOptionsFromState } from './visible-worktree-options-from-state' import { useAppStore } from '@/store' import { getAllWorktreesFromState, getRepoMapFromState } from '@/store/selectors' import { @@ -41,11 +44,7 @@ import { computeRenderedSidebarWorktreeOrder, computeRenderedSidebarWorktrees } from './rendered-sidebar-worktree-order' -import { - EMPTY_PAIRED_DEVICE_IDS_BY_ENVIRONMENT, - getPairedDeviceIdsByEnvironment, - isWorkspaceFromOtherDevice -} from './workspace-creator-visibility' +import { isWorkspaceFromOtherDevice } from './workspace-creator-visibility' import { isDefaultBranchWorkspace } from './default-branch-workspace' import { getLineageAncestorIndex, getSortedWorktreeRankIndex } from './visible-worktree-indexes' import { getWorktreeHostIdentity } from '../../../../shared/worktree/host-qualified-identity' @@ -62,13 +61,14 @@ import { getWorktreeHostIdentity } from '../../../../shared/worktree/host-qualif * Why shared: the sidebar pipeline and the jump palette both apply this, and a * second copy is how the two surfaces drift. */ -type VisibleWorktreeOptions = { +export type VisibleWorktreeOptions = { filterRepoIds: readonly string[] showSleepingWorkspaces: boolean tabsByWorktree: Record[]> | null ptyIdsByTabId: Record | null browserTabsByWorktree?: Record | null worktreeIdsWithLiveAgent: ReadonlySet + worktreeIdsWithStructuredChat?: ReadonlySet hideDefaultBranchWorkspace: boolean hideAutomationGeneratedWorkspaces: boolean hideCliCreatedWorkspaces: boolean @@ -153,7 +153,8 @@ export function computeVisibleWorktrees( opts.tabsByWorktree, opts.ptyIdsByTabId, opts.browserTabsByWorktree, - opts.worktreeIdsWithLiveAgent + opts.worktreeIdsWithLiveAgent, + opts.worktreeIdsWithStructuredChat ) ) } @@ -257,50 +258,6 @@ export function setVisibleWorktreeShortcutTargets( _publishedVisibleShortcutTargets = targets } -/** - * Compute the visible worktree IDs on-demand from the current Zustand store - * state. Called by the App-level Cmd+1–9 handler (not a React hook — reads - * store snapshot at call time). - * - * If WorktreeList is mounted, returns the exact IDs it rendered. Otherwise - * recomputes the order the sidebar *would* render from the same row pipeline, - * so a closed sidebar numbers workspaces the same way an open one does (#9497). - */ -export function buildVisibleWorktreeOptionsFromState( - state: ReturnType, - repoMap: Map -): VisibleWorktreeOptions { - return { - filterRepoIds: state.filterRepoIds, - showSleepingWorkspaces: state.showSleepingWorkspaces, - tabsByWorktree: state.tabsByWorktree, - ptyIdsByTabId: state.ptyIdsByTabId, - browserTabsByWorktree: state.browserTabsByWorktree, - worktreeIdsWithLiveAgent: getWorktreeIdsWithLiveAgent( - state.agentStatusByPaneKey, - state.tabsByWorktree, - Date.now() - ), - hideDefaultBranchWorkspace: state.hideDefaultBranchWorkspace, - hideAutomationGeneratedWorkspaces: state.hideAutomationGeneratedWorkspaces, - hideCliCreatedWorkspaces: state.hideCliCreatedWorkspaces, - hideDetachedHeadWorkspaces: state.hideDetachedHeadWorkspaces, - hideWorkspacesFromOtherDevices: state.hideWorkspacesFromOtherDevices, - pairedDeviceIdsByEnvironment: state.hideWorkspacesFromOtherDevices - ? getPairedDeviceIdsByEnvironment( - state.runtimeEnvironments, - state.runtimeStatusByEnvironmentId - ) - : EMPTY_PAIRED_DEVICE_IDS_BY_ENVIRONMENT, - alwaysShowDefaultBranchWorkspace: state.alwaysShowDefaultBranchWorkspace, - repoMap, - workspaceHostScope: state.workspaceHostScope, - visibleWorkspaceHostIds: state.visibleWorkspaceHostIds, - defaultHostId: getSettingsFocusedExecutionHostId(state.settings), - worktreeLineageById: state.worktreeLineageById - } -} - export function getVisibleWorktreeIds(): string[] { // Prefer the published IDs that mirror the rendered sidebar order. if (_publishedVisibleIds) { diff --git a/src/renderer/src/components/sidebar/worktree-filter-visibility.ts b/src/renderer/src/components/sidebar/worktree-filter-visibility.ts index 321111f2392..298021f246d 100644 --- a/src/renderer/src/components/sidebar/worktree-filter-visibility.ts +++ b/src/renderer/src/components/sidebar/worktree-filter-visibility.ts @@ -6,7 +6,8 @@ import { normalizeExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' -import { buildVisibleWorktreeOptionsFromState, computeVisibleWorktrees } from './visible-worktrees' +import { computeVisibleWorktrees } from './visible-worktrees' +import { buildVisibleWorktreeOptionsFromState } from './visible-worktree-options-from-state' /** * Filter-only visibility for one worktree id: runs the sidebar filter pipeline diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/use-filters.ts b/src/renderer/src/components/sidebar/worktree-list/listing/use-filters.ts index 857b59205ea..38b15bab63e 100644 --- a/src/renderer/src/components/sidebar/worktree-list/listing/use-filters.ts +++ b/src/renderer/src/components/sidebar/worktree-list/listing/use-filters.ts @@ -25,7 +25,8 @@ import { getAgentStatusEpochNow } from '@/lib/agent-status-epoch-clock' import { getWorktreeIdsWithLiveAgent, isInactiveWorkspace } from '@/lib/worktree-activity-state' import { getVisibleWorktreeBrowserActivityTabs, - getVisibleWorktreeTerminalActivityTabs + getVisibleWorktreeTerminalActivityTabs, + getWorktreeIdsWithStructuredChat } from '../../visible-worktree-activity-inputs' export type SidebarWorktreeFilters = ReturnType @@ -133,7 +134,8 @@ export function useSidebarWorktreeFilters() { tabsByWorktree, state.ptyIdsByTabId, browserTabsByWorktree, - liveAgentWorktrees + liveAgentWorktrees, + getWorktreeIdsWithStructuredChat(state.unifiedTabsByWorktree) ) ) { state.setShowSleepingWorkspaces(true) diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/use-visible-worktrees.ts b/src/renderer/src/components/sidebar/worktree-list/listing/use-visible-worktrees.ts index 804d5118605..f48ea818444 100644 --- a/src/renderer/src/components/sidebar/worktree-list/listing/use-visible-worktrees.ts +++ b/src/renderer/src/components/sidebar/worktree-list/listing/use-visible-worktrees.ts @@ -12,7 +12,8 @@ import { } from '../../workspace-creator-visibility' import { getVisibleWorktreeBrowserActivityTabs, - getVisibleWorktreeTerminalActivityTabs + getVisibleWorktreeTerminalActivityTabs, + getWorktreeIdsWithStructuredChat } from '../../visible-worktree-activity-inputs' import type { SortBy } from '../../smart-sort' import type { SidebarWorktreeFilters } from './use-filters' @@ -70,6 +71,9 @@ export function useVisibleSidebarWorktrees(args: { const browserTabsByWorktree = useAppStore((s) => !showSleepingWorkspaces ? getVisibleWorktreeBrowserActivityTabs(s.browserTabsByWorktree) : null ) + const worktreeIdsWithStructuredChat = useAppStore((s) => + getWorktreeIdsWithStructuredChat(s.unifiedTabsByWorktree) + ) const recomputedVisibleWorktrees = useMemo(() => { // Keyed on the epoch, not `agentStatusNow`: two bumps in one millisecond @@ -81,6 +85,7 @@ export function useVisibleSidebarWorktrees(args: { tabsByWorktree, ptyIdsByTabId, browserTabsByWorktree, + worktreeIdsWithStructuredChat, // Why snapshot on agentStatusEpoch: update membership immediately without repainting on every hook ping. worktreeIdsWithLiveAgent: showSleepingWorkspaces ? EMPTY_WORKTREE_ID_SET @@ -127,7 +132,8 @@ export function useVisibleSidebarWorktrees(args: { sortedIds, worktreeLineageById, worktreesByRepo, - pairedDeviceIdsByEnvironment + pairedDeviceIdsByEnvironment, + worktreeIdsWithStructuredChat ]) // Why: agentStatusEpoch bumps recompute this memo even when membership and // order are unchanged; keeping the previous identity stops the whole diff --git a/src/renderer/src/components/use-worktree-jump-palette-worktrees.ts b/src/renderer/src/components/use-worktree-jump-palette-worktrees.ts index e07dcd3a668..4faad5451ec 100644 --- a/src/renderer/src/components/use-worktree-jump-palette-worktrees.ts +++ b/src/renderer/src/components/use-worktree-jump-palette-worktrees.ts @@ -3,7 +3,8 @@ import { isAutomationGeneratedWorkspace, isCliCreatedWorkspace, isDetachedHeadWorkspace, - isSleepingSweepExemptWorkspace + isSleepingSweepExemptWorkspace, + getWorktreeIdsWithStructuredChat } from '@/components/sidebar/visible-worktrees' import { isDefaultBranchWorkspace } from '@/components/sidebar/default-branch-workspace' import { sortWorktreesSmart } from '@/components/sidebar/smart-sort' @@ -56,6 +57,7 @@ export function useWorktreeJumpPaletteWorktrees({ alwaysShowDefaultBranchWorkspace, ptyIdsByTabId, browserTabsByWorktree, + unifiedTabsByWorktree, activeWorktreeId, activeWorkspaceExecutionHostId, runtimeEnvironments, @@ -129,7 +131,8 @@ export function useWorktreeJumpPaletteWorktrees({ tabsByWorktree, ptyIdsByTabId, browserTabsByWorktree, - worktreeIdsWithLiveAgent + worktreeIdsWithLiveAgent, + getWorktreeIdsWithStructuredChat(unifiedTabsByWorktree) ) ) { return false @@ -150,7 +153,8 @@ export function useWorktreeJumpPaletteWorktrees({ ptyIdsByTabId, showSleepingWorkspaces, tabsByWorktree, - worktreeIdsWithLiveAgent + worktreeIdsWithLiveAgent, + unifiedTabsByWorktree ] ) const { visibleWorktreesForState, switchableWorktreesForRows } = useMemo( diff --git a/src/renderer/src/lib/worktree-activity-state.ts b/src/renderer/src/lib/worktree-activity-state.ts index a319b6a8dc3..12aa67e7588 100644 --- a/src/renderer/src/lib/worktree-activity-state.ts +++ b/src/renderer/src/lib/worktree-activity-state.ts @@ -14,6 +14,8 @@ type PtyIdsByTabId = Record type BrowserTabsByWorktree = Record export type LiveAgentWorktreeStatus = 'working' | 'monitoring' | 'permission' +const EMPTY_WORKTREE_IDS: ReadonlySet = new Set() + /** * Worktree ids that currently have a live agent session, derived from the * live `agentStatusByPaneKey` map. @@ -74,7 +76,8 @@ export function hasActiveWorkspaceActivity( tabsByWorktree: TabsByWorktree | null | undefined, ptyIdsByTabId: PtyIdsByTabId | null | undefined, browserTabsByWorktree: BrowserTabsByWorktree | null | undefined, - worktreeIdsWithLiveAgent: ReadonlySet + worktreeIdsWithLiveAgent: ReadonlySet, + worktreeIdsWithStructuredChat: ReadonlySet = EMPTY_WORKTREE_IDS ): boolean { const tabs = tabsByWorktree?.[worktreeId] ?? [] const hasLiveTerminal = @@ -83,7 +86,10 @@ export function hasActiveWorkspaceActivity( // Why: a running agent keeps the workspace visible through brief PTY gaps // such as an SSH reconnect or an unmounted remote pane. #7197 const hasLiveAgent = worktreeIdsWithLiveAgent.has(worktreeId) - return hasLiveTerminal || hasBrowser || hasLiveAgent + // Why not folded into hasLiveTerminal: a structured chat has no PTY and no entry in + // tabsByWorktree, so every terminal-shaped signal above reads it as absent. + const hasStructuredChat = worktreeIdsWithStructuredChat.has(worktreeId) + return hasLiveTerminal || hasBrowser || hasLiveAgent || hasStructuredChat } export function isInactiveWorkspace( @@ -91,13 +97,15 @@ export function isInactiveWorkspace( tabsByWorktree: TabsByWorktree | null | undefined, ptyIdsByTabId: PtyIdsByTabId | null | undefined, browserTabsByWorktree: BrowserTabsByWorktree | null | undefined, - worktreeIdsWithLiveAgent: ReadonlySet + worktreeIdsWithLiveAgent: ReadonlySet, + worktreeIdsWithStructuredChat: ReadonlySet = EMPTY_WORKTREE_IDS ): boolean { return !hasActiveWorkspaceActivity( worktreeId, tabsByWorktree, ptyIdsByTabId, browserTabsByWorktree, - worktreeIdsWithLiveAgent + worktreeIdsWithLiveAgent, + worktreeIdsWithStructuredChat ) }