From dff068fe465fdacab69eb9034ed4d068f04f5e17 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:44:34 -0700 Subject: [PATCH] Show chat-only workspaces as active before the first message (#33815) * fix(sidebar): show chat-only workspaces as active before the first message A workspace whose only tab is a native chat showed the grey Inactive dot until a message was sent, because the dot counted only live terminals and browser tabs. Count a chat that is not explicitly slept, the same rule the sleeping-workspace check already uses, in the sidebar cards, the parent picker and the Cmd+J switcher. * test: bound chat status work on unrelated store writes * test: correct chat status contract formatting * test: use a fixed clock for chat status fixtures * test: prune obsolete chat status clock allowance * test: complete sidebar status store fixtures --- config/flaky-test-pattern-baseline.txt | 1 - .../components/cmd-j/palette-live-status.tsx | 5 + ....lineage-agent-expansion-coupling.test.tsx | 2 + ...ktreeList.lineage-child-real-card.test.tsx | 2 + ...treeList.status-lane-lineage-drop.test.tsx | 2 + .../use-worktree-activity-status.test.tsx | 39 +++- .../sidebar/use-worktree-activity-status.ts | 6 + .../use-worktree-activity-statuses.test.ts | 2 + .../sidebar/use-worktree-activity-statuses.ts | 7 + ...e-title-update-global-scan-budget.test.tsx | 217 +++++++++++++++++- src/renderer/src/lib/worktree-status.ts | 8 +- .../store/slices/structured-chat-slept-at.ts | 10 + 12 files changed, 296 insertions(+), 5 deletions(-) diff --git a/config/flaky-test-pattern-baseline.txt b/config/flaky-test-pattern-baseline.txt index 78125b397ce..1f1d722d403 100644 --- a/config/flaky-test-pattern-baseline.txt +++ b/config/flaky-test-pattern-baseline.txt @@ -1073,7 +1073,6 @@ 1 test-no-wall-clock:src/renderer/src/components/task-page-jira-saved-filter-load-failure.test.tsx 1 test-no-wall-clock:src/renderer/src/components/terminal-pane/cache-timer-seeding.test.ts 2 test-no-wall-clock:src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts -1 test-no-wall-clock:src/renderer/src/components/terminal-pane/pane-title-update-global-scan-budget.test.tsx 2 test-no-wall-clock:src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-host-chain.test.ts 3 test-no-wall-clock:src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts 1 test-no-wall-clock:src/renderer/src/components/terminal-pane/terminal-link-worktree-root-activation.test.ts diff --git a/src/renderer/src/components/cmd-j/palette-live-status.tsx b/src/renderer/src/components/cmd-j/palette-live-status.tsx index 8977e502ad9..cec92a617fd 100644 --- a/src/renderer/src/components/cmd-j/palette-live-status.tsx +++ b/src/renderer/src/components/cmd-j/palette-live-status.tsx @@ -1,6 +1,7 @@ import React, { createContext, useContext, useMemo } from 'react' import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '@/store' +import { hasAwakeStructuredChat } from '@/store/slices/structured-chat-slept-at' import { AgentStateDot } from '@/components/AgentStateDot' import { StateIndicatorTooltip } from '@/components/StateIndicatorTooltip' import StatusIndicator from '@/components/sidebar/StatusIndicator' @@ -199,6 +200,9 @@ export function PaletteWorktreeStatusDot({ worktree: Pick }): React.JSX.Element | null { const live = useLiveStatus() + const hasStructuredChat = useAppStore((s) => + hasAwakeStructuredChat(s.unifiedTabsByWorktree[worktree.id], s.structuredChatSleptAtByPaneKey) + ) if (!live) { return null } @@ -209,6 +213,7 @@ export function PaletteWorktreeStatusDot({ live.paneSources.runtimePaneTitlesByTabId, { liveAgentStatus: live.liveAgentStatusByWorktreeId.get(worktree.id), + hasStructuredChat, agentStatusPaneIdsByTabId: live.agentStatusPaneIdsByTabId, stalePaneIdsByTabId: live.stalePaneIdsByTabId, terminalLayoutsByTabId: live.paneSources.terminalLayoutsByTabId diff --git a/src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx b/src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx index 6217936fd0e..4cec2697ff7 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx @@ -348,6 +348,8 @@ function setAgentLineageState(options: { dismissRetainedAgent: vi.fn(), sendPromptToSidebarAgentTarget: vi.fn(), tabsByWorktree: { parent: [makeParentTab()] }, + unifiedTabsByWorktree: {}, + structuredChatSleptAtByPaneKey: {}, agentStatusByPaneKey } } diff --git a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx index 1af8aa3511e..80b1ae17b01 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx @@ -326,6 +326,8 @@ function setLineageState( sshConnectionStates: new Map(), sshTargetLabels: new Map(), tabsByWorktree: {}, + unifiedTabsByWorktree: {}, + structuredChatSleptAtByPaneKey: {}, terminalLayoutsByTabId: {}, toggleCollapsedGroup: vi.fn(), updateRepo: vi.fn(), diff --git a/src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx b/src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx index cc444238958..1de297e5928 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx @@ -293,6 +293,8 @@ function setStatusLaneState(): void { sshConnectionStates: new Map(), sshTargetLabels: new Map(), tabsByWorktree: {}, + unifiedTabsByWorktree: {}, + structuredChatSleptAtByPaneKey: {}, terminalLayoutsByTabId: {}, toggleCollapsedGroup: vi.fn(), updateRepo: vi.fn(), diff --git a/src/renderer/src/components/sidebar/use-worktree-activity-status.test.tsx b/src/renderer/src/components/sidebar/use-worktree-activity-status.test.tsx index 17ff8d18cd3..06b32e8cb46 100644 --- a/src/renderer/src/components/sidebar/use-worktree-activity-status.test.tsx +++ b/src/renderer/src/components/sidebar/use-worktree-activity-status.test.tsx @@ -1,6 +1,8 @@ import { renderToStaticMarkup } from 'react-dom/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection' +import type { Tab } from '../../../../shared/tab-types' import { makePaneKey } from '../../../../shared/stable-pane-id' import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' import { useWorktreeActivityStatus } from './use-worktree-activity-status' @@ -20,6 +22,8 @@ type MockState = { runtimeAgentOrchestrationByPaneKey: Record> migrationUnsupportedByPtyId: Record retainedAgentsByPaneKey: Record + unifiedTabsByWorktree: Record + structuredChatSleptAtByPaneKey: Record } let mockState: MockState @@ -113,10 +117,43 @@ describe('useWorktreeActivityStatus', () => { agentStatusByPaneKey: {}, runtimeAgentOrchestrationByPaneKey: {}, migrationUnsupportedByPtyId: {}, - retainedAgentsByPaneKey: {} + retainedAgentsByPaneKey: {}, + unifiedTabsByWorktree: {}, + structuredChatSleptAtByPaneKey: {} } }) + it('shows a chat-only workspace as active before its first message, until the chat sleeps', () => { + const worktreeId = 'repo1::/path/wt1' + const chatTab: Tab = { + id: 'chat-tab', + entityId: 'session-1', + groupId: 'group-1', + worktreeId, + contentType: 'agent-session', + agentSessionAgent: 'codex', + label: 'Codex', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0 + } + mockState = { ...mockState, unifiedTabsByWorktree: { [worktreeId]: [chatTab] } } + expect(renderToStaticMarkup()).toBe( + 'active' + ) + + mockState = { + ...mockState, + structuredChatSleptAtByPaneKey: { + [structuredAgentSessionPaneKey(chatTab.id, chatTab.entityId)]: 1_000 + } + } + expect(renderToStaticMarkup()).toBe( + 'inactive' + ) + }) + afterEach(() => { vi.restoreAllMocks() }) diff --git a/src/renderer/src/components/sidebar/use-worktree-activity-status.ts b/src/renderer/src/components/sidebar/use-worktree-activity-status.ts index 4186f67ad83..c5883ed3e27 100644 --- a/src/renderer/src/components/sidebar/use-worktree-activity-status.ts +++ b/src/renderer/src/components/sidebar/use-worktree-activity-status.ts @@ -1,6 +1,7 @@ import { useMemo } from 'react' import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '@/store' +import { hasAwakeStructuredChat } from '@/store/slices/structured-chat-slept-at' import { resolveWorktreeStatus, type WorktreeStatus } from '@/lib/worktree-status' import { EMPTY_BROWSER_TABS, EMPTY_TABS } from './WorktreeCardHelpers' import { @@ -13,6 +14,9 @@ import { selectWorktreeAgentActivitySummary } from './worktree-agent-activity-su export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus { const tabs = useAppStore((s) => s.tabsByWorktree[worktreeId] ?? EMPTY_TABS) const browserTabs = useAppStore((s) => s.browserTabsByWorktree[worktreeId] ?? EMPTY_BROWSER_TABS) + const hasStructuredChat = useAppStore((s) => + hasAwakeStructuredChat(s.unifiedTabsByWorktree[worktreeId], s.structuredChatSleptAtByPaneKey) + ) const runtimePaneTitlesForWorktree = useAppStore( useShallow((s) => selectRuntimePaneTitlesForWorktree(s, worktreeId)) ) @@ -42,6 +46,7 @@ export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus { resolveWorktreeStatus({ tabs, browserTabs, + hasStructuredChat, ptyIdsByTabId: ptyIdsForWorktree, runtimePaneTitlesByTabId: runtimePaneTitlesForWorktree, agentStatusPaneIdsByTabId, @@ -60,6 +65,7 @@ export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus { [ tabs, browserTabs, + hasStructuredChat, ptyIdsForWorktree, runtimePaneTitlesForWorktree, agentStatusPaneIdsByTabId, diff --git a/src/renderer/src/components/sidebar/use-worktree-activity-statuses.test.ts b/src/renderer/src/components/sidebar/use-worktree-activity-statuses.test.ts index b481a35cde7..c53558f3962 100644 --- a/src/renderer/src/components/sidebar/use-worktree-activity-statuses.test.ts +++ b/src/renderer/src/components/sidebar/use-worktree-activity-statuses.test.ts @@ -8,6 +8,8 @@ function makeStatusState(): StatusState { return { tabsByWorktree: {}, browserTabsByWorktree: {}, + unifiedTabsByWorktree: {}, + structuredChatSleptAtByPaneKey: {}, runtimePaneTitlesByTabId: {}, ptyIdsByTabId: {}, terminalLayoutsByTabId: {}, diff --git a/src/renderer/src/components/sidebar/use-worktree-activity-statuses.ts b/src/renderer/src/components/sidebar/use-worktree-activity-statuses.ts index d979219a275..d5a4f6268b7 100644 --- a/src/renderer/src/components/sidebar/use-worktree-activity-statuses.ts +++ b/src/renderer/src/components/sidebar/use-worktree-activity-statuses.ts @@ -1,6 +1,7 @@ import { useCallback } from 'react' import { useShallow } from 'zustand/react/shallow' import { useAppStore, type AppState } from '@/store' +import { hasAwakeStructuredChat } from '@/store/slices/structured-chat-slept-at' import { resolveWorktreeStatus, type WorktreeStatus } from '@/lib/worktree-status' import { EMPTY_BROWSER_TABS, EMPTY_TABS } from './WorktreeCardHelpers' import { @@ -14,6 +15,8 @@ type WorktreeActivityStatusState = Pick< AppState, | 'tabsByWorktree' | 'browserTabsByWorktree' + | 'unifiedTabsByWorktree' + | 'structuredChatSleptAtByPaneKey' | 'runtimePaneTitlesByTabId' | 'ptyIdsByTabId' | 'terminalLayoutsByTabId' @@ -48,6 +51,10 @@ export function selectWorktreeActivityStatuses( resolveWorktreeStatus({ tabs: statusInputs.tabsByWorktree[worktreeId] ?? EMPTY_TABS, browserTabs: statusInputs.browserTabsByWorktree[worktreeId] ?? EMPTY_BROWSER_TABS, + hasStructuredChat: hasAwakeStructuredChat( + statusInputs.unifiedTabsByWorktree[worktreeId], + statusInputs.structuredChatSleptAtByPaneKey + ), ptyIdsByTabId: selectLivePtyIdsForWorktree(statusInputs, worktreeId), runtimePaneTitlesByTabId: selectRuntimePaneTitlesForWorktree(statusInputs, worktreeId), agentStatusPaneIdsByTabId, diff --git a/src/renderer/src/components/terminal-pane/pane-title-update-global-scan-budget.test.tsx b/src/renderer/src/components/terminal-pane/pane-title-update-global-scan-budget.test.tsx index 424592c1af4..3351f41f3ef 100644 --- a/src/renderer/src/components/terminal-pane/pane-title-update-global-scan-budget.test.tsx +++ b/src/renderer/src/components/terminal-pane/pane-title-update-global-scan-budget.test.tsx @@ -7,17 +7,25 @@ import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '@/store' import { selectTabBarAgentProjections } from '@/components/tab-bar/tab-agent-types-by-tab-id' import { useWorktreeActivityStatus } from '@/components/sidebar/use-worktree-activity-status' +import { useWorktreeActivityStatuses } from '@/components/sidebar/use-worktree-activity-statuses' +import { + PaletteLiveStatusProvider, + PaletteWorktreeStatusDot +} from '@/components/cmd-j/palette-live-status' import { WorktreeCardStatusSlot } from '@/components/sidebar/WorktreeCardStatusSlot' import { TooltipProvider } from '@/components/ui/tooltip' import { readStoreListenerCount } from '@/store/store-listener-census' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import type { Tab } from '../../../../shared/tab-types' +import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection' import { useTerminalTabColdParking } from './use-terminal-tab-cold-parking' // Hoisted mock factories need erased namespace types because typeof import() annotations are banned. import type * as SleepingRecordParkExemptionModule from './sleeping-record-park-exemption' import type * as WorktreeAgentActivitySummaryModule from '@/components/sidebar/worktree-agent-activity-summary' import type * as WorktreeCardStatusInputsModule from '@/components/sidebar/worktree-card-status-inputs' +import type * as StructuredAgentSessionProjectionModule from '../../../../shared/structured-agent-session-projection' Reflect.set(globalThis, 'IS_REACT_ACT_ENVIRONMENT', true) @@ -28,7 +36,27 @@ const AGENT_STATUS_COUNT = 177 /** "Live or mounted panes: 20–28" in the capture. */ const MOUNTED_WORKTREE_COUNT = 20 -const reads = { sleepingRecords: 0, agentStatusRows: 0, workspaceTabBuckets: 0 } +const reads = { + sleepingRecords: 0, + agentStatusRows: 0, + workspaceTabBuckets: 0, + chatTabBuckets: 0, + chatTabs: 0 +} +const chatWork = vi.hoisted(() => ({ hashes: 0 })) + +vi.mock('../../../../shared/structured-agent-session-projection', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + structuredAgentSessionPaneKey: ( + ...args: Parameters + ) => { + chatWork.hashes += 1 + return actual.structuredAgentSessionPaneKey(...args) + } + } +}) // Count selector executions separately from record reads so cache hits remain visible. const selectorRuns = vi.hoisted(() => ({ @@ -274,6 +302,9 @@ function resetCounters(): void { reads.sleepingRecords = 0 reads.agentStatusRows = 0 reads.workspaceTabBuckets = 0 + reads.chatTabBuckets = 0 + reads.chatTabs = 0 + chatWork.hashes = 0 committedSidebarRows.clear() renders.retainedPanes = 0 notifications = 0 @@ -304,6 +335,7 @@ afterEach(() => { container?.remove() container = null useAppStore.setState(originalState, true) + vi.restoreAllMocks() }) describe('one pane title update at live-capture scale', () => { @@ -366,3 +398,186 @@ describe('one pane title update: fanout at live-capture scale', () => { expect([...committedSidebarRows]).toEqual([TARGET_WORKTREE_ID]) }) }) + +type ChatStatusConsumer = 'sidebar' | 'parent picker' | 'palette' + +function ChatSidebarProbe({ worktreeId }: { worktreeId: string }): React.JSX.Element { + return {useWorktreeActivityStatus(worktreeId)} +} + +function ChatParentPickerProbe({ ids }: { ids: readonly string[] }): React.JSX.Element { + const statuses = useWorktreeActivityStatuses(ids) + return ( + <> + {ids.map((id) => ( + + {statuses.get(id)} + + ))} + + ) +} + +function mountChatConsumers( + consumer: ChatStatusConsumer, + readerCount: number, + tabsPerWorkspace: number +): void { + if (root) { + act(() => root?.unmount()) + } + container?.remove() + useAppStore.setState(originalState, true) + const sleptAtByPaneKey: Record = {} + const entries = worktreeIds.map((worktreeId, workspaceIndex) => { + const tabs: Tab[] = [] + // Keep offscreen buckets populated to expose a whole-store scan in any mounted reader. + const count = workspaceIndex < readerCount ? tabsPerWorkspace : 1 + for (let index = 0; index < count; index += 1) { + const tab: Tab = { + id: `chat-${workspaceIndex}-${index}`, + entityId: `session-${workspaceIndex}-${index}`, + worktreeId, + groupId: 'group-1', + contentType: 'agent-session', + agentSessionAgent: 'codex', + label: 'Codex', + customLabel: null, + color: null, + sortOrder: index, + createdAt: 0 + } + // All slept forces a full local scan, rather than a first-awake-tab short circuit. + sleptAtByPaneKey[structuredAgentSessionPaneKey(tab.id, tab.entityId)] = 1 + Object.defineProperty(tabs, index, { + enumerable: true, + get: () => { + reads.chatTabs += 1 + return tab + } + }) + } + return [worktreeId, tabs] as const + }) + useAppStore.setState({ + tabsByWorktree: {}, + browserTabsByWorktree: {}, + agentStatusByPaneKey: {}, + retainedAgentsByPaneKey: {}, + unifiedTabsByWorktree: countingRecord(entries, 'chatTabBuckets'), + structuredChatSleptAtByPaneKey: sleptAtByPaneKey, + runtimePaneTitlesByTabId: { [TARGET_TAB_ID]: { [TARGET_PANE_ID]: 'initial title' } } + }) + const ids = worktreeIds.slice(0, readerCount) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + const content = + consumer === 'parent picker' ? ( + + ) : consumer === 'sidebar' ? ( + ids.map((id) => ) + ) : ( + + {ids.map((id) => ( + + + + ))} + + ) + act(() => root?.render({content})) +} + +function chatStatusLabels(): string[] { + return Array.from( + container?.querySelectorAll('[data-chat-status]') ?? [], + (node) => node.textContent?.trim().toLowerCase() ?? '' + ) +} + +function measureChatTitleUpdate(): { buckets: number; tabs: number; hashes: number } { + resetCounters() + const stop = useAppStore.subscribe(() => { + notifications += 1 + }) + try { + applyOnePaneTitleUpdate('next unrelated title') + expect(notifications).toBe(1) + return { buckets: reads.chatTabBuckets, tabs: reads.chatTabs, hashes: chatWork.hashes } + } finally { + stop() + } +} + +describe.each(['sidebar', 'parent picker', 'palette'])( + '%s chat-only status subscriptions', + (consumer) => { + it.each(['tabs', 'readers'] as const)( + 'bounds %s work at N and 10N on real title writes', + (axis) => { + const samples = [1, 10].map((scale) => { + const readerCount = axis === 'readers' ? 87 * scale : MOUNTED_WORKTREE_COUNT + const tabsPerWorkspace = axis === 'tabs' ? 10 * scale : 2 + mountChatConsumers(consumer, readerCount, tabsPerWorkspace) + expect(chatStatusLabels()).toEqual(Array(readerCount).fill('inactive')) + expect(chatWork.hashes).toBeGreaterThan(0) + const measured = measureChatTitleUpdate() + // Allow React snapshot checks/context renders; never an inventory scan per row. + expect(measured.buckets).toBeLessThanOrEqual(readerCount * 6) + expect(measured.tabs).toBeLessThanOrEqual(readerCount * tabsPerWorkspace * 6) + expect(measured.hashes).toBeLessThanOrEqual(readerCount * tabsPerWorkspace * 6) + return measured + }) + for (const operation of ['buckets', 'tabs', 'hashes'] as const) { + expect(samples[1][operation]).toBeLessThanOrEqual(Math.max(1, samples[0][operation]) * 10) + } + } + ) + + it('observes chat evidence live while preserving agent priority', () => { + const now = 1_000 + vi.spyOn(Date, 'now').mockReturnValue(now) + mountChatConsumers(consumer, 1, 1) + expect(chatStatusLabels()).toEqual(['inactive']) + const tab = useAppStore.getState().unifiedTabsByWorktree[TARGET_WORKTREE_ID][0] + const paneKey = structuredAgentSessionPaneKey(tab.id, tab.entityId) + act(() => useAppStore.getState().setStructuredChatSleptAt(paneKey, undefined)) + expect(chatStatusLabels()).toEqual(['active']) + act(() => useAppStore.getState().setStructuredChatSleptAt(paneKey, 1)) + expect(chatStatusLabels()).toEqual(['inactive']) + act(() => useAppStore.getState().setStructuredChatSleptAt(paneKey, null)) + expect(chatStatusLabels()).toEqual(['active']) + const working: AgentStatusEntry = { + paneKey, + worktreeId: TARGET_WORKTREE_ID, + state: 'working', + prompt: '', + updatedAt: now, + stateStartedAt: now, + stateHistory: [] + } + act(() => + useAppStore.setState({ agentStatusByPaneKey: { [paneKey]: working }, agentStatusEpoch: 1 }) + ) + expect(chatStatusLabels()).toEqual(['working']) + if (consumer !== 'palette') { + act(() => + useAppStore.setState({ + agentStatusByPaneKey: { [paneKey]: { ...working, state: 'done' } }, + agentStatusEpoch: 2 + }) + ) + expect(chatStatusLabels()).toEqual(['done']) + } + act(() => + useAppStore.setState({ + unifiedTabsByWorktree: {}, + agentStatusByPaneKey: {}, + agentStatusEpoch: 3 + }) + ) + expect(chatStatusLabels()).toEqual(['inactive']) + }) + } +) diff --git a/src/renderer/src/lib/worktree-status.ts b/src/renderer/src/lib/worktree-status.ts index 26b2a4161ee..4c446c94165 100644 --- a/src/renderer/src/lib/worktree-status.ts +++ b/src/renderer/src/lib/worktree-status.ts @@ -25,6 +25,8 @@ export type WorktreeStatus = type WorktreeStatusHeuristicOptions = { liveAgentStatus?: LiveAgentWorktreeStatus + /** An awake chat tab has no PTY until its first message, but the user sees an open session. */ + hasStructuredChat?: boolean agentStatusPaneIdsByTabId?: Record> /** Stale rows suppress Orca's generated permission labels; native title fallback stays live. */ stalePaneIdsByTabId?: Record> @@ -67,8 +69,8 @@ export function getWorktreeStatus( if (options.liveAgentStatus === 'monitoring') { return 'monitoring' } - if (liveTabs.length > 0 || browserTabs.length > 0) { - // Why: browser-only worktrees (no PTY) are still active from the user's point of view. + if (liveTabs.length > 0 || browserTabs.length > 0 || options.hasStructuredChat) { + // Why: browser-only and chat-only worktrees (no PTY) are still active from the user's point of view. return 'active' } return 'inactive' @@ -174,6 +176,7 @@ export function getWorktreeStatusLabel(status: WorktreeStatus): string { export function resolveWorktreeStatus(args: { tabs: readonly Pick[] browserTabs: readonly { id: string }[] + hasStructuredChat?: boolean ptyIdsByTabId: Record runtimePaneTitlesByTabId?: Record> agentStatusPaneIdsByTabId?: Record> @@ -196,6 +199,7 @@ export function resolveWorktreeStatus(args: { args.ptyIdsByTabId, args.runtimePaneTitlesByTabId ?? {}, { + hasStructuredChat: args.hasStructuredChat, agentStatusPaneIdsByTabId: args.agentStatusPaneIdsByTabId, stalePaneIdsByTabId: args.stalePaneIdsByTabId, terminalLayoutsByTabId: args.terminalLayoutsByTabId, diff --git a/src/renderer/src/store/slices/structured-chat-slept-at.ts b/src/renderer/src/store/slices/structured-chat-slept-at.ts index 4082ef7a21b..1c6fa8dc210 100644 --- a/src/renderer/src/store/slices/structured-chat-slept-at.ts +++ b/src/renderer/src/store/slices/structured-chat-slept-at.ts @@ -32,6 +32,16 @@ export function hasUnknownStructuredChat( ) } +/** Any chat not explicitly slept, unknown included, so the dot agrees with the sleeping-workspace check. */ +export function hasAwakeStructuredChat( + tabs: readonly Tab[] | undefined, + sleptAtByPaneKey: StructuredChatSleptAtByPaneKey = {} +): boolean { + return (tabs ?? []).some( + (tab) => isStructuredTab(tab) && !isStructuredChatSlept(sleptAtByPaneKey, tab) + ) +} + export const createStructuredChatSleptAtSlice: StateCreator< AppState, [],