diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index 4c310972e06..3c1a49a28a5 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -30,7 +30,7 @@ import { isAutomationGeneratedWorkspace, isDefaultBranchWorkspace } from '@/components/sidebar/visible-worktrees' -import { isInactiveWorkspace } from '@/lib/worktree-activity-state' +import { getWorktreeIdsWithLiveAgent, isInactiveWorkspace } from '@/lib/worktree-activity-state' import { orderEmptyQueryWorktrees } from '@/lib/order-empty-query-worktrees' import StatusIndicator from '@/components/sidebar/StatusIndicator' import { cn } from '@/lib/utils' @@ -436,6 +436,13 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { const hasQuery = deferredQuery.trim().length > 0 const isLoading = repos.length > 0 && Object.keys(worktreesByRepo).length === 0 + // Why: keep running-agent workspaces visible under "Hide sleeping" even when + // their live PTY is momentarily absent, matching the sidebar filter. #7197 + const worktreeIdsWithLiveAgent = useMemo( + () => getWorktreeIdsWithLiveAgent(agentStatusByPaneKey, tabsByWorktree), + [agentStatusByPaneKey, tabsByWorktree] + ) + // Why: the empty-query palette mirrors sidebar filters so opening Search // starts from the same quiet list. Typed search switches to the global // non-archived scope below. @@ -453,7 +460,13 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { } if ( !showSleepingWorkspaces && - isInactiveWorkspace(worktree.id, tabsByWorktree, ptyIdsByTabId, browserTabsByWorktree) + isInactiveWorkspace( + worktree.id, + tabsByWorktree, + ptyIdsByTabId, + browserTabsByWorktree, + worktreeIdsWithLiveAgent + ) ) { return false } @@ -466,7 +479,8 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { hideDefaultBranchWorkspace, ptyIdsByTabId, showSleepingWorkspaces, - tabsByWorktree + tabsByWorktree, + worktreeIdsWithLiveAgent ] ) diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index adcb4bd43fa..1575ec99af1 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -117,6 +117,7 @@ import { setVisibleWorktreeIds, sidebarHasActiveFilters } from './visible-worktrees' +import { getWorktreeIdsWithLiveAgent } from '@/lib/worktree-activity-state' import { getEmptyProjectPlaceholderRepoIds } from './empty-project-placeholder-repos' import { getVisibleWorktreeBrowserActivityTabs, @@ -5543,6 +5544,14 @@ const WorktreeList = React.memo(function WorktreeList({ tabsByWorktree, ptyIdsByTabId, browserTabsByWorktree, + // Why snapshot (not a subscribed selector): agent-status changes bump + // sortEpoch, so this memo already recomputes through `sortedIds` when a + // worktree gains/loses a running agent — reading getState() here keeps + // the sidebar off the full-map subscription its perf design avoids. #7197 + worktreeIdsWithLiveAgent: getWorktreeIdsWithLiveAgent( + useAppStore.getState().agentStatusByPaneKey, + tabsByWorktree + ), hideDefaultBranchWorkspace, hideAutomationGeneratedWorkspaces, repoMap, diff --git a/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts b/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts index 03c3dac373d..156d86e5c8e 100644 --- a/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts +++ b/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts @@ -1,7 +1,9 @@ import { useMemo } from 'react' +import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '@/store' import type { Repo, Worktree } from '../../../../shared/types' import { computeVisibleWorktreeIds } from './visible-worktrees' +import { getWorktreeIdsWithLiveAgent } from '@/lib/worktree-activity-state' import { getSettingsFocusedExecutionHostId } from '../../../../shared/execution-host' type UseVisibleWorkspaceKanbanWorktreeIdsParams = { @@ -9,6 +11,10 @@ type UseVisibleWorkspaceKanbanWorktreeIdsParams = { repoMap: Map } +// Why module-level: a stable empty array keeps the useShallow selector from +// allocating a fresh reference each render when Hide sleeping is off. +const EMPTY_LIVE_AGENT_LIST: string[] = [] + export function useVisibleWorkspaceKanbanWorktreeIds({ allWorktrees, repoMap @@ -26,6 +32,20 @@ export function useVisibleWorkspaceKanbanWorktreeIds({ const browserTabsByWorktree = useAppStore((s) => !showSleepingWorkspaces ? s.browserTabsByWorktree : null ) + // Why useShallow over a sorted id list: the board must keep running-agent + // workspaces visible under Hide sleeping, but agent-status pings change the + // raw map constantly — gating re-renders on set membership avoids churn. #7197 + const worktreeIdsWithLiveAgentList = useAppStore( + useShallow((s) => + !showSleepingWorkspaces + ? [...getWorktreeIdsWithLiveAgent(s.agentStatusByPaneKey, s.tabsByWorktree)].sort() + : EMPTY_LIVE_AGENT_LIST + ) + ) + const worktreeIdsWithLiveAgent = useMemo( + () => new Set(worktreeIdsWithLiveAgentList), + [worktreeIdsWithLiveAgentList] + ) return useMemo(() => { // Why: the board has its own status ordering, but visibility must match @@ -38,6 +58,7 @@ export function useVisibleWorkspaceKanbanWorktreeIds({ tabsByWorktree, ptyIdsByTabId, browserTabsByWorktree, + worktreeIdsWithLiveAgent, hideDefaultBranchWorkspace, hideAutomationGeneratedWorkspaces, repoMap, @@ -62,6 +83,7 @@ export function useVisibleWorkspaceKanbanWorktreeIds({ repoMap, showSleepingWorkspaces, tabsByWorktree, + worktreeIdsWithLiveAgent, worktreesByRepo ]) } diff --git a/src/renderer/src/components/sidebar/visible-worktrees.test.ts b/src/renderer/src/components/sidebar/visible-worktrees.test.ts index 25165df25b8..6cdbdd50c9c 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.test.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.test.ts @@ -80,6 +80,7 @@ function visibleOptions(overrides: Partial = {}): VisibleOptions tabsByWorktree: {}, ptyIdsByTabId: {}, browserTabsByWorktree: {}, + worktreeIdsWithLiveAgent: new Set(), hideDefaultBranchWorkspace: false, hideAutomationGeneratedWorkspaces: false, repoMap, @@ -180,6 +181,24 @@ describe('computeVisibleWorktreeIds', () => { expect(result).toEqual([]) }) + it('keeps a running-agent worktree visible without a live pty when sleeping is hidden (#7197)', () => { + const wt = makeWorktree('wt-agent') + + const result = computeVisibleWorktreeIds( + { repo1: [wt] }, + [wt.id], + visibleOptions({ + showSleepingWorkspaces: false, + // No live PTY for the tab, but the agent session is live. + tabsByWorktree: { [wt.id]: [makeTab('tab-agent', wt.id, null)] }, + ptyIdsByTabId: { 'tab-agent': [] }, + worktreeIdsWithLiveAgent: new Set([wt.id]) + }) + ) + + expect(result).toEqual([wt.id]) + }) + it('hides paired web host terminal mirrors while their stream handle is pending', () => { const wt = makeWorktree('wt-web-pending') diff --git a/src/renderer/src/components/sidebar/visible-worktrees.ts b/src/renderer/src/components/sidebar/visible-worktrees.ts index 434df0ee0de..fdeff25a486 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.ts @@ -1,6 +1,6 @@ import type { Worktree, Repo, TerminalTab, WorktreeLineage } from '../../../../shared/types' import { buildWorktreeComparator, sortWorktreesSmart } from './smart-sort' -import { isInactiveWorkspace } from '@/lib/worktree-activity-state' +import { getWorktreeIdsWithLiveAgent, isInactiveWorkspace } from '@/lib/worktree-activity-state' import { useAppStore } from '@/store' import { getAllWorktreesFromState, getRepoMapFromState } from '@/store/selectors' import { DEFAULT_SHOW_SLEEPING_WORKSPACES } from '../../../../shared/constants' @@ -110,6 +110,11 @@ export function computeVisibleWorktreeIds( tabsByWorktree: Record[]> | null ptyIdsByTabId: Record | null browserTabsByWorktree?: Record | null + // Why required: the sleeping filter must keep a workspace with a running + // agent visible even when its live PTY is momentarily absent. Making the + // field required stops a future caller silently dropping the signal. + // Build it with getWorktreeIdsWithLiveAgent. #7197 + worktreeIdsWithLiveAgent: ReadonlySet // Why required: every caller (WorktreeList, getVisibleWorktreeIds // fallback, tests) reads the flag from the UI store. Making the field // required prevents a future caller from silently dropping the filter by @@ -171,7 +176,8 @@ export function computeVisibleWorktreeIds( w.id, opts.tabsByWorktree, opts.ptyIdsByTabId, - opts.browserTabsByWorktree + opts.browserTabsByWorktree, + opts.worktreeIdsWithLiveAgent ) ) } @@ -305,6 +311,10 @@ export function getVisibleWorktreeIds(): string[] { tabsByWorktree: state.tabsByWorktree, ptyIdsByTabId: state.ptyIdsByTabId, browserTabsByWorktree: state.browserTabsByWorktree, + worktreeIdsWithLiveAgent: getWorktreeIdsWithLiveAgent( + state.agentStatusByPaneKey, + state.tabsByWorktree + ), hideDefaultBranchWorkspace: state.hideDefaultBranchWorkspace, hideAutomationGeneratedWorkspaces: state.hideAutomationGeneratedWorkspaces, repoMap, diff --git a/src/renderer/src/lib/worktree-activity-state.test.ts b/src/renderer/src/lib/worktree-activity-state.test.ts index 8548d88fd56..53957b6836c 100644 --- a/src/renderer/src/lib/worktree-activity-state.test.ts +++ b/src/renderer/src/lib/worktree-activity-state.test.ts @@ -1,11 +1,29 @@ import { describe, expect, it } from 'vitest' -import { hasActiveWorkspaceActivity, isInactiveWorkspace } from './worktree-activity-state' +import { + getWorktreeIdsWithLiveAgent, + hasActiveWorkspaceActivity, + isInactiveWorkspace +} from './worktree-activity-state' import type { TerminalTab } from '../../../shared/types' +import type { AgentStatusEntry } from '../../../shared/agent-status-types' function makeTab(id: string): Pick { return { id } } +function makeAgentEntry( + overrides: Partial & { paneKey: string } +): AgentStatusEntry { + return { + state: 'working', + prompt: '', + updatedAt: 0, + stateStartedAt: 0, + stateHistory: [], + ...overrides + } +} + describe('worktree activity state', () => { it('treats a slept wake-hint workspace as inactive', () => { expect(isInactiveWorkspace('wt-1', { 'wt-1': [makeTab('tab-1')] }, { 'tab-1': [] }, {})).toBe( @@ -63,4 +81,66 @@ describe('worktree activity state', () => { ) ).toBe(true) }) + + it('keeps a workspace with a running agent active even without a live pty (#7197)', () => { + const worktreeIdsWithLiveAgent = new Set(['wt-1']) + expect( + hasActiveWorkspaceActivity( + 'wt-1', + { 'wt-1': [makeTab('tab-1')] }, + { 'tab-1': [] }, + {}, + worktreeIdsWithLiveAgent + ) + ).toBe(true) + expect( + isInactiveWorkspace( + 'wt-1', + { 'wt-1': [makeTab('tab-1')] }, + { 'tab-1': [] }, + {}, + worktreeIdsWithLiveAgent + ) + ).toBe(false) + }) + + it('still hides a slept workspace with no live agent entry', () => { + expect( + isInactiveWorkspace('wt-1', { 'wt-1': [makeTab('tab-1')] }, { 'tab-1': [] }, {}, new Set()) + ).toBe(true) + }) +}) + +describe('getWorktreeIdsWithLiveAgent', () => { + it('returns an empty set when there are no agent entries', () => { + expect(getWorktreeIdsWithLiveAgent({}, {})).toEqual(new Set()) + expect(getWorktreeIdsWithLiveAgent(null, null)).toEqual(new Set()) + }) + + it('attributes an entry by its main-stamped worktreeId', () => { + const entries = { + 'tab-1:leaf-1': makeAgentEntry({ paneKey: 'tab-1:leaf-1', worktreeId: 'wt-1' }) + } + expect(getWorktreeIdsWithLiveAgent(entries, {})).toEqual(new Set(['wt-1'])) + }) + + it('falls back to the paneKey tabId when worktreeId is absent', () => { + const entries = { + 'tab-1:00000000-0000-4000-8000-000000000000': makeAgentEntry({ + paneKey: 'tab-1:00000000-0000-4000-8000-000000000000' + }) + } + expect(getWorktreeIdsWithLiveAgent(entries, { 'wt-1': [makeTab('tab-1')] })).toEqual( + new Set(['wt-1']) + ) + }) + + it('ignores entries that cannot be attributed to any worktree', () => { + const entries = { + 'orphan:00000000-0000-4000-8000-000000000000': makeAgentEntry({ + paneKey: 'orphan:00000000-0000-4000-8000-000000000000' + }) + } + expect(getWorktreeIdsWithLiveAgent(entries, {})).toEqual(new Set()) + }) }) diff --git a/src/renderer/src/lib/worktree-activity-state.ts b/src/renderer/src/lib/worktree-activity-state.ts index fbd39c32e5b..851105d9757 100644 --- a/src/renderer/src/lib/worktree-activity-state.ts +++ b/src/renderer/src/lib/worktree-activity-state.ts @@ -1,5 +1,7 @@ import { tabHasLivePty } from '@/lib/tab-has-live-pty' import type { TerminalTab } from '../../../shared/types' +import type { AgentStatusEntry } from '../../../shared/agent-status-types' +import { parsePaneKey } from '../../../shared/stable-pane-id' type TerminalLikeTab = Pick type BrowserLikeTab = { id: string } @@ -8,29 +10,73 @@ type TabsByWorktree = Record type PtyIdsByTabId = Record type BrowserTabsByWorktree = Record +/** + * Worktree ids that currently have a live agent session, derived from the + * live `agentStatusByPaneKey` map. + * + * Why presence is a reliable liveness signal: entries live in this map only + * while an agent is attached — sleep and teardown drop every entry attributed + * to the worktree via `dropAgentStatusByWorktree` (completed rows move to the + * separate `retainedAgentsByPaneKey`), so a lingering entry never resurrects a + * slept/hibernated workspace. Each entry is attributed by its main-stamped + * `worktreeId`, falling back to the paneKey's tabId — orchestration workers can + * report status before their terminal tab is mirrored in the renderer. + */ +export function getWorktreeIdsWithLiveAgent( + agentStatusByPaneKey: Record | null | undefined, + tabsByWorktree: TabsByWorktree | null | undefined +): Set { + const entries = Object.values(agentStatusByPaneKey ?? {}) + if (entries.length === 0) { + return new Set() + } + const worktreeIdByTabId = new Map() + for (const [worktreeId, tabs] of Object.entries(tabsByWorktree ?? {})) { + for (const tab of tabs) { + worktreeIdByTabId.set(tab.id, worktreeId) + } + } + const result = new Set() + for (const entry of entries) { + const worktreeId = + entry.worktreeId ?? worktreeIdByTabId.get(parsePaneKey(entry.paneKey)?.tabId ?? '') + if (worktreeId) { + result.add(worktreeId) + } + } + return result +} + export function hasActiveWorkspaceActivity( worktreeId: string, tabsByWorktree: TabsByWorktree | null | undefined, ptyIdsByTabId: PtyIdsByTabId | null | undefined, - browserTabsByWorktree: BrowserTabsByWorktree | null | undefined + browserTabsByWorktree: BrowserTabsByWorktree | null | undefined, + worktreeIdsWithLiveAgent?: ReadonlySet | null ): boolean { const tabs = tabsByWorktree?.[worktreeId] ?? [] const hasLiveTerminal = ptyIdsByTabId != null && tabs.some((tab) => tabHasLivePty(ptyIdsByTabId, tab.id)) const hasBrowser = (browserTabsByWorktree?.[worktreeId] ?? []).length > 0 - return hasLiveTerminal || hasBrowser + // Why: a running agent session keeps the workspace visible under "Hide + // sleeping" even when its live-PTY entry is momentarily absent (SSH reconnect + // grace, an unmounted pane, a remote surface not yet `ready`). #7197 + const hasLiveAgent = worktreeIdsWithLiveAgent?.has(worktreeId) ?? false + return hasLiveTerminal || hasBrowser || hasLiveAgent } export function isInactiveWorkspace( worktreeId: string, tabsByWorktree: TabsByWorktree | null | undefined, ptyIdsByTabId: PtyIdsByTabId | null | undefined, - browserTabsByWorktree: BrowserTabsByWorktree | null | undefined + browserTabsByWorktree: BrowserTabsByWorktree | null | undefined, + worktreeIdsWithLiveAgent?: ReadonlySet | null ): boolean { return !hasActiveWorkspaceActivity( worktreeId, tabsByWorktree, ptyIdsByTabId, - browserTabsByWorktree + browserTabsByWorktree, + worktreeIdsWithLiveAgent ) } diff --git a/src/renderer/src/store/slices/store-session-cascades.test.ts b/src/renderer/src/store/slices/store-session-cascades.test.ts index d2f93e73ab4..7135ac2ffd5 100644 --- a/src/renderer/src/store/slices/store-session-cascades.test.ts +++ b/src/renderer/src/store/slices/store-session-cascades.test.ts @@ -1518,6 +1518,7 @@ describe('reconnectPersistedTerminals', () => { tabsByWorktree: s.tabsByWorktree, ptyIdsByTabId: s.ptyIdsByTabId, browserTabsByWorktree: s.browserTabsByWorktree, + worktreeIdsWithLiveAgent: new Set(), hideDefaultBranchWorkspace: false, hideAutomationGeneratedWorkspaces: false, repoMap: new Map(s.repos.map((repo) => [repo.id, repo])), @@ -1866,6 +1867,7 @@ describe('reconnectPersistedTerminals', () => { tabsByWorktree: s.tabsByWorktree, ptyIdsByTabId: s.ptyIdsByTabId, browserTabsByWorktree: s.browserTabsByWorktree, + worktreeIdsWithLiveAgent: new Set(), hideDefaultBranchWorkspace: false, hideAutomationGeneratedWorkspaces: false, repoMap: new Map(s.repos.map((repo) => [repo.id, repo])),