mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 08:37:49 +00:00
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:
@@ -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
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+216
-1
@@ -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'])
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
[],
|
||||
|
||||
Reference in New Issue
Block a user