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
This commit is contained in:
Brennan Benson
2026-10-26 10:44:34 -07:00
committed by GitHub
parent 2a15a50691
commit dff068fe46
12 changed files with 296 additions and 5 deletions
-1
View File
@@ -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
@@ -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<Worktree, 'id'>
}): 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
@@ -348,6 +348,8 @@ function setAgentLineageState(options: {
dismissRetainedAgent: vi.fn(),
sendPromptToSidebarAgentTarget: vi.fn(),
tabsByWorktree: { parent: [makeParentTab()] },
unifiedTabsByWorktree: {},
structuredChatSleptAtByPaneKey: {},
agentStatusByPaneKey
}
}
@@ -326,6 +326,8 @@ function setLineageState(
sshConnectionStates: new Map(),
sshTargetLabels: new Map(),
tabsByWorktree: {},
unifiedTabsByWorktree: {},
structuredChatSleptAtByPaneKey: {},
terminalLayoutsByTabId: {},
toggleCollapsedGroup: vi.fn(),
updateRepo: vi.fn(),
@@ -293,6 +293,8 @@ function setStatusLaneState(): void {
sshConnectionStates: new Map(),
sshTargetLabels: new Map(),
tabsByWorktree: {},
unifiedTabsByWorktree: {},
structuredChatSleptAtByPaneKey: {},
terminalLayoutsByTabId: {},
toggleCollapsedGroup: vi.fn(),
updateRepo: vi.fn(),
@@ -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<string, NonNullable<AgentStatusEntry['orchestration']>>
migrationUnsupportedByPtyId: Record<string, never>
retainedAgentsByPaneKey: Record<string, unknown>
unifiedTabsByWorktree: Record<string, Tab[]>
structuredChatSleptAtByPaneKey: Record<string, number | null>
}
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(<StatusProbe worktreeId={worktreeId} />)).toBe(
'<span>active</span>'
)
mockState = {
...mockState,
structuredChatSleptAtByPaneKey: {
[structuredAgentSessionPaneKey(chatTab.id, chatTab.entityId)]: 1_000
}
}
expect(renderToStaticMarkup(<StatusProbe worktreeId={worktreeId} />)).toBe(
'<span>inactive</span>'
)
})
afterEach(() => {
vi.restoreAllMocks()
})
@@ -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,
@@ -8,6 +8,8 @@ function makeStatusState(): StatusState {
return {
tabsByWorktree: {},
browserTabsByWorktree: {},
unifiedTabsByWorktree: {},
structuredChatSleptAtByPaneKey: {},
runtimePaneTitlesByTabId: {},
ptyIdsByTabId: {},
terminalLayoutsByTabId: {},
@@ -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,
@@ -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<typeof StructuredAgentSessionProjectionModule>()
return {
...actual,
structuredAgentSessionPaneKey: (
...args: Parameters<typeof actual.structuredAgentSessionPaneKey>
) => {
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 <span data-chat-status>{useWorktreeActivityStatus(worktreeId)}</span>
}
function ChatParentPickerProbe({ ids }: { ids: readonly string[] }): React.JSX.Element {
const statuses = useWorktreeActivityStatuses(ids)
return (
<>
{ids.map((id) => (
<span key={id} data-chat-status>
{statuses.get(id)}
</span>
))}
</>
)
}
function mountChatConsumers(
consumer: ChatStatusConsumer,
readerCount: number,
tabsPerWorkspace: number
): void {
if (root) {
act(() => root?.unmount())
}
container?.remove()
useAppStore.setState(originalState, true)
const sleptAtByPaneKey: Record<string, number | null> = {}
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' ? (
<ChatParentPickerProbe ids={ids} />
) : consumer === 'sidebar' ? (
ids.map((id) => <ChatSidebarProbe key={id} worktreeId={id} />)
) : (
<PaletteLiveStatusProvider active>
{ids.map((id) => (
<span key={id} data-chat-status>
<PaletteWorktreeStatusDot worktree={{ id }} />
</span>
))}
</PaletteLiveStatusProvider>
)
act(() => root?.render(<TooltipProvider>{content}</TooltipProvider>))
}
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<ChatStatusConsumer>(['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'])
})
}
)
+6 -2
View File
@@ -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<string, ReadonlySet<string>>
/** Stale rows suppress Orca's generated permission labels; native title fallback stays live. */
stalePaneIdsByTabId?: Record<string, ReadonlySet<string>>
@@ -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<TerminalTab, 'id' | 'title' | 'launchAgent'>[]
browserTabs: readonly { id: string }[]
hasStructuredChat?: boolean
ptyIdsByTabId: Record<string, string[]>
runtimePaneTitlesByTabId?: Record<string, Record<number, string>>
agentStatusPaneIdsByTabId?: Record<string, ReadonlySet<string>>
@@ -196,6 +199,7 @@ export function resolveWorktreeStatus(args: {
args.ptyIdsByTabId,
args.runtimePaneTitlesByTabId ?? {},
{
hasStructuredChat: args.hasStructuredChat,
agentStatusPaneIdsByTabId: args.agentStatusPaneIdsByTabId,
stalePaneIdsByTabId: args.stalePaneIdsByTabId,
terminalLayoutsByTabId: args.terminalLayoutsByTabId,
@@ -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,
[],