mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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.
This commit is contained in:
@@ -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<string, TerminalTab[]>
|
||||
browserTabsByWorktree: Record<string, { id: string }[]>
|
||||
unifiedTabsByWorktree: Record<string, Tab[]>
|
||||
ptyIdsByTabId: Record<string, string[]>
|
||||
agentStatusEpoch: number
|
||||
agentStatusByPaneKey: Record<string, AgentStatusEntry>
|
||||
@@ -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 <span>{String(useIsSleepingWorktree(worktreeId))}</span>
|
||||
}
|
||||
@@ -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(<SleepProbe worktreeId={worktreeId} />)).toBe('<span>false</span>')
|
||||
})
|
||||
|
||||
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(<SleepProbe worktreeId={worktreeId} />)).toBe('<span>false</span>')
|
||||
})
|
||||
|
||||
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(<SleepProbe worktreeId={worktreeId} />)).toBe('<span>true</span>')
|
||||
})
|
||||
|
||||
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(<SleepProbe worktreeId={worktreeId} />)).toBe('<span>true</span>')
|
||||
})
|
||||
|
||||
it('treats a worktree with a live PTY as awake', () => {
|
||||
const worktreeId = 'repo1::/path/wt1'
|
||||
mockState = {
|
||||
|
||||
@@ -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<string, readonly TabLike[]> | null
|
||||
ptyIdsByTabId?: Record<string, string[]> | null
|
||||
browserTabsByWorktree?: Record<string, readonly TabLike[]> | null
|
||||
unifiedTabsByWorktree?: Record<string, Tab[]> | null
|
||||
}
|
||||
|
||||
type LiveAgentGeneration = {
|
||||
@@ -53,8 +56,8 @@ function selectWorktreeIdsWithLiveAgent(state: SleepStateInput): ReadonlySet<str
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a workspace is asleep: no live terminal, no browser tab, and no live
|
||||
* agent holding it awake through a PTY gap.
|
||||
* Whether a workspace is asleep: no live terminal, no browser tab, no live
|
||||
* agent holding it awake through a PTY gap, and no structured chat.
|
||||
*
|
||||
* Why not `status === 'inactive'`: a slept workspace keeps its retained done
|
||||
* rows, so its status still reads 'done' — keying the sleeping glyph on status
|
||||
@@ -71,7 +74,8 @@ export function useIsSleepingWorktree(worktreeId: string): boolean {
|
||||
state.tabsByWorktree,
|
||||
state.ptyIdsByTabId,
|
||||
state.browserTabsByWorktree,
|
||||
selectWorktreeIdsWithLiveAgent(state)
|
||||
selectWorktreeIdsWithLiveAgent(state),
|
||||
getWorktreeIdsWithStructuredChat(state.unifiedTabsByWorktree)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { BrowserWorkspace } from '../../../../shared/browser-workspace-types'
|
||||
import type { Tab } from '../../../../shared/tab-types'
|
||||
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
|
||||
import { getStructuredAgentSessionTabs } from '@/components/native-chat/structured-agent-session-tabs'
|
||||
import { createWorktreeTabBucketProjection } from '@/lib/worktree-tab-bucket-projection'
|
||||
|
||||
export type TerminalActivityTab = Pick<TerminalTab, 'id'>
|
||||
@@ -33,3 +35,33 @@ export function getVisibleWorktreeBrowserActivityTabs(
|
||||
): Record<string, BrowserActivityTab[]> {
|
||||
return browserProjection.project(browserTabsByWorktree)
|
||||
}
|
||||
|
||||
const EMPTY_WORKTREE_IDS: ReadonlySet<string> = new Set()
|
||||
const structuredChatWorktreeIds = new WeakMap<Record<string, Tab[]>, ReadonlySet<string>>()
|
||||
|
||||
/**
|
||||
* 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<string, Tab[]> | null | undefined
|
||||
): ReadonlySet<string> {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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<typeof useAppStore.getState>,
|
||||
repoMap: Map<string, Repo>
|
||||
): 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
|
||||
}
|
||||
}
|
||||
@@ -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<string, Pick<TerminalTab, 'id'>[]> | null
|
||||
ptyIdsByTabId: Record<string, string[]> | null
|
||||
browserTabsByWorktree?: Record<string, { id: string }[]> | null
|
||||
worktreeIdsWithLiveAgent: ReadonlySet<string>
|
||||
worktreeIdsWithStructuredChat?: ReadonlySet<string>
|
||||
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<typeof useAppStore.getState>,
|
||||
repoMap: Map<string, Repo>
|
||||
): 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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<typeof useSidebarWorktreeFilters>
|
||||
@@ -133,7 +134,8 @@ export function useSidebarWorktreeFilters() {
|
||||
tabsByWorktree,
|
||||
state.ptyIdsByTabId,
|
||||
browserTabsByWorktree,
|
||||
liveAgentWorktrees
|
||||
liveAgentWorktrees,
|
||||
getWorktreeIdsWithStructuredChat(state.unifiedTabsByWorktree)
|
||||
)
|
||||
) {
|
||||
state.setShowSleepingWorkspaces(true)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -14,6 +14,8 @@ type PtyIdsByTabId = Record<string, string[]>
|
||||
type BrowserTabsByWorktree = Record<string, readonly BrowserLikeTab[]>
|
||||
export type LiveAgentWorktreeStatus = 'working' | 'monitoring' | 'permission'
|
||||
|
||||
const EMPTY_WORKTREE_IDS: ReadonlySet<string> = 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<string>
|
||||
worktreeIdsWithLiveAgent: ReadonlySet<string>,
|
||||
worktreeIdsWithStructuredChat: ReadonlySet<string> = 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<string>
|
||||
worktreeIdsWithLiveAgent: ReadonlySet<string>,
|
||||
worktreeIdsWithStructuredChat: ReadonlySet<string> = EMPTY_WORKTREE_IDS
|
||||
): boolean {
|
||||
return !hasActiveWorkspaceActivity(
|
||||
worktreeId,
|
||||
tabsByWorktree,
|
||||
ptyIdsByTabId,
|
||||
browserTabsByWorktree,
|
||||
worktreeIdsWithLiveAgent
|
||||
worktreeIdsWithLiveAgent,
|
||||
worktreeIdsWithStructuredChat
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user