mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 08:03:33 +00:00
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:
@@ -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])),
|
||||
|
||||
Reference in New Issue
Block a user