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:
Brennan Benson
2026-09-21 15:34:40 -07:00
parent 73b726c64f
commit 6274bb62bd
10 changed files with 234 additions and 67 deletions
@@ -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+19 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+19 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
)
}