Keep running-agent workspaces visible under "Hide sleeping" (#7197)

The "Hide sleeping" sidebar filter judged a workspace active only when it
had a live PTY (or a browser tab), so a workspace with a running agent
whose live-PTY entry was momentarily absent — an SSH reconnect grace
window, an unmounted pane, a remote surface not yet `ready`, or an
orchestration worker reporting before its tab is mirrored — was
classified as "sleeping" and hidden while its session was still open.

The smart sort already treats a fresh `agentStatusByPaneKey` entry as
"working" independent of live-PTY, so the filter and sort disagreed. Add
`getWorktreeIdsWithLiveAgent`, which derives the worktrees with an open
agent session from the live agent-status map (sleep/teardown drop those
entries via dropAgentStatusByWorktree, so slept/hibernated workspaces
still hide), and consult it in `hasActiveWorkspaceActivity`. Wire it
through the sidebar list, Cmd+J jump palette, and kanban board.
This commit is contained in:
brennanb2025
2026-07-05 23:08:44 -07:00
parent 553ad4fb67
commit 4b034bdd1d
8 changed files with 212 additions and 10 deletions
@@ -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
]
)
@@ -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,
@@ -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<string, Repo>
}
// 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
])
}
@@ -80,6 +80,7 @@ function visibleOptions(overrides: Partial<VisibleOptions> = {}): 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')
@@ -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<string, Pick<TerminalTab, 'id'>[]> | null
ptyIdsByTabId: Record<string, string[]> | null
browserTabsByWorktree?: Record<string, { id: string }[]> | 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<string>
// 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,
@@ -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<TerminalTab, 'id'> {
return { id }
}
function makeAgentEntry(
overrides: Partial<AgentStatusEntry> & { 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())
})
})
@@ -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<TerminalTab, 'id'>
type BrowserLikeTab = { id: string }
@@ -8,29 +10,73 @@ type TabsByWorktree = Record<string, readonly TerminalLikeTab[]>
type PtyIdsByTabId = Record<string, string[]>
type BrowserTabsByWorktree = Record<string, readonly BrowserLikeTab[]>
/**
* 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<string, AgentStatusEntry> | null | undefined,
tabsByWorktree: TabsByWorktree | null | undefined
): Set<string> {
const entries = Object.values(agentStatusByPaneKey ?? {})
if (entries.length === 0) {
return new Set()
}
const worktreeIdByTabId = new Map<string, string>()
for (const [worktreeId, tabs] of Object.entries(tabsByWorktree ?? {})) {
for (const tab of tabs) {
worktreeIdByTabId.set(tab.id, worktreeId)
}
}
const result = new Set<string>()
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<string> | 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<string> | null
): boolean {
return !hasActiveWorkspaceActivity(
worktreeId,
tabsByWorktree,
ptyIdsByTabId,
browserTabsByWorktree
browserTabsByWorktree,
worktreeIdsWithLiveAgent
)
}
@@ -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])),