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 ) }