mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 16:03:20 +00:00
perf(agent-map): stream status updates to popout
This commit is contained in:
+21
-12
@@ -188,7 +188,10 @@ import {
|
||||
ensureAutoUpdaterConfigured
|
||||
} from './window/attach-main-window-services'
|
||||
import { createMainWindow, loadMainWindow } from './window/createMainWindow'
|
||||
import { zoomDashboardPopoutIfFocused } from './window/dashboard-popout-window'
|
||||
import {
|
||||
getDashboardPopoutWindow,
|
||||
zoomDashboardPopoutIfFocused
|
||||
} from './window/dashboard-popout-window'
|
||||
import {
|
||||
createSystemTray,
|
||||
destroySystemTray,
|
||||
@@ -303,6 +306,7 @@ import {
|
||||
} from '../shared/synthetic-agent-title'
|
||||
import type { AgentStatusState } from '../shared/agent-status-types'
|
||||
import { resolveTuiAgentPermissionMode } from '../shared/tui-agent-permissions'
|
||||
import { isAskUserQuestionTool } from '../shared/agent-question-answered-intent'
|
||||
import type { TerminalSideEffectBatch } from '../shared/terminal-side-effect-facts'
|
||||
import {
|
||||
HEADLESS_RUNTIME_WINDOW_ID,
|
||||
@@ -1450,7 +1454,16 @@ function openMainWindow(): BrowserWindow {
|
||||
maybeAutoRenameBranchOnFirstWorkFromHook({ paneKey, tabId, worktreeId, payload, isReplay })
|
||||
const orchestration = runtime?.getAgentStatusOrchestrationContextForPaneKey(paneKey)
|
||||
const terminalHandle = runtime?.getAgentStatusTerminalHandleForPaneKey(paneKey)
|
||||
mainWindow?.webContents.send('agentStatus:set', {
|
||||
const suppressSyntheticCodexAutoApprovalTitle =
|
||||
payload.agentType === 'codex' &&
|
||||
(payload.state === 'waiting' || payload.state === 'blocked')
|
||||
? shouldSuppressCodexAutoApprovalSyntheticTitleFromHook({
|
||||
agentType: payload.agentType,
|
||||
state: payload.state,
|
||||
launchConfig: runtime?.getAgentStatusLaunchConfigForPaneKey(paneKey, { launchToken })
|
||||
})
|
||||
: false
|
||||
const statusEvent = {
|
||||
...payload,
|
||||
paneKey,
|
||||
...(launchToken ? { launchToken } : {}),
|
||||
@@ -1464,19 +1477,14 @@ function openMainWindow(): BrowserWindow {
|
||||
...(promptInteractionKey ? { promptInteractionKey } : {}),
|
||||
...(restoredUnconfirmed ? { restoredUnconfirmed: true } : {}),
|
||||
...(orchestration ? { orchestration } : {})
|
||||
})
|
||||
}
|
||||
mainWindow?.webContents.send('agentStatus:set', statusEvent)
|
||||
if (!suppressSyntheticCodexAutoApprovalTitle || isAskUserQuestionTool(payload.toolName)) {
|
||||
getDashboardPopoutWindow()?.webContents.send('agentStatus:set', statusEvent)
|
||||
}
|
||||
recordAgentStateCrashBreadcrumb(payload.agentType ?? 'unknown', payload.state)
|
||||
// Why: native OSC titles miss some idle/permission frames, so inject hook-derived ones to keep the renderer title tracker in sync.
|
||||
const profile = getSyntheticAgentTitleProfile(payload.agentType)
|
||||
const suppressSyntheticCodexAutoApprovalTitle =
|
||||
payload.agentType === 'codex' &&
|
||||
(payload.state === 'waiting' || payload.state === 'blocked')
|
||||
? shouldSuppressCodexAutoApprovalSyntheticTitleFromHook({
|
||||
agentType: payload.agentType,
|
||||
state: payload.state,
|
||||
launchConfig: runtime?.getAgentStatusLaunchConfigForPaneKey(paneKey, { launchToken })
|
||||
})
|
||||
: false
|
||||
if (
|
||||
profile &&
|
||||
shouldDriveSyntheticAgentTitleFromHook(payload.agentType, payload.state) &&
|
||||
@@ -1491,6 +1499,7 @@ function openMainWindow(): BrowserWindow {
|
||||
return
|
||||
}
|
||||
mainWindow?.webContents.send('agentStatus:clear', clear)
|
||||
getDashboardPopoutWindow()?.webContents.send('agentStatus:clear', clear)
|
||||
})
|
||||
setMigrationUnsupportedPtyListener((event) => {
|
||||
if (mainWindow?.isDestroyed()) {
|
||||
|
||||
@@ -284,6 +284,7 @@ function isDashboardCard(value: unknown): boolean {
|
||||
isFiniteNumber(card.startedAt) &&
|
||||
(card.finishedAt === null || isFiniteNumber(card.finishedAt)) &&
|
||||
isFiniteNumber(card.stateChangedAt) &&
|
||||
(card.statusUpdatedAt === undefined || isFiniteNumber(card.statusUpdatedAt)) &&
|
||||
typeof card.unseen === 'boolean' &&
|
||||
isOptionalBoundedString(card.askSummary, AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH) &&
|
||||
isOptionalBoundedString(card.conversationName, MAX_LABEL_LENGTH) &&
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentStatusIpcPayload } from '../../../../shared/agent-status-types'
|
||||
import type { DashboardCard, DashboardSnapshot } from '../../../../shared/dashboard-snapshot'
|
||||
import { patchDashboardSnapshotFromAgentStatus } from './dashboard-agent-status-patch'
|
||||
|
||||
function card(overrides: Partial<DashboardCard> = {}): DashboardCard {
|
||||
return {
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
ptyId: 'pty-1',
|
||||
agentType: 'codex',
|
||||
bucket: 'working',
|
||||
dotState: 'working',
|
||||
task: 'old task',
|
||||
repoId: 'repo-1',
|
||||
worktreeId: 'worktree-1',
|
||||
tabId: 'tab-1',
|
||||
leafId: 'leaf-1',
|
||||
repoName: 'Repo',
|
||||
worktreeName: 'Worktree',
|
||||
startedAt: 100,
|
||||
finishedAt: null,
|
||||
stateChangedAt: 100,
|
||||
statusUpdatedAt: 150,
|
||||
unseen: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function event(overrides: Partial<AgentStatusIpcPayload> = {}): AgentStatusIpcPayload {
|
||||
return {
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
state: 'blocked',
|
||||
prompt: 'Need a decision',
|
||||
connectionId: null,
|
||||
receivedAt: 300,
|
||||
stateStartedAt: 250,
|
||||
interactivePrompt: '{"question":"Continue?"}',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function snapshot(cards: DashboardCard[] = [card()]): DashboardSnapshot {
|
||||
return { generatedAt: 200, cards }
|
||||
}
|
||||
|
||||
describe('patchDashboardSnapshotFromAgentStatus', () => {
|
||||
it('patches one known card without rebuilding the dashboard topology', () => {
|
||||
const original = snapshot([card(), card({ paneKey: 'tab-2:leaf-2' })])
|
||||
const result = patchDashboardSnapshotFromAgentStatus(original, event())
|
||||
|
||||
expect(result.matched).toBe(true)
|
||||
expect(result.snapshot.cards[0]).toMatchObject({
|
||||
bucket: 'attention',
|
||||
dotState: 'blocked',
|
||||
task: 'Need a decision',
|
||||
lastUserMessage: 'Need a decision',
|
||||
askSummary: '{"question":"Continue?"}',
|
||||
stateChangedAt: 250,
|
||||
statusUpdatedAt: 300,
|
||||
unseen: true
|
||||
})
|
||||
expect(result.snapshot.cards[1]).toBe(original.cards[1])
|
||||
})
|
||||
|
||||
it('preserves cached fields omitted from same-state hook pings', () => {
|
||||
const original = snapshot([
|
||||
card({
|
||||
bucket: 'attention',
|
||||
dotState: 'waiting',
|
||||
askSummary: 'Pick one',
|
||||
lastAgentMessage: 'Waiting',
|
||||
stateChangedAt: 250,
|
||||
unseen: true
|
||||
})
|
||||
])
|
||||
const result = patchDashboardSnapshotFromAgentStatus(
|
||||
original,
|
||||
event({ state: 'waiting', prompt: '', interactivePrompt: undefined, stateStartedAt: 250 })
|
||||
)
|
||||
|
||||
expect(result.snapshot.cards[0]).toMatchObject({
|
||||
task: 'old task',
|
||||
askSummary: 'Pick one',
|
||||
lastAgentMessage: 'Waiting',
|
||||
unseen: true
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores stale, wrong-workspace, and session-only events', () => {
|
||||
const original = snapshot()
|
||||
expect(
|
||||
patchDashboardSnapshotFromAgentStatus(original, event({ receivedAt: 150 })).snapshot
|
||||
).toBe(original)
|
||||
expect(
|
||||
patchDashboardSnapshotFromAgentStatus(original, event({ worktreeId: 'other' })).snapshot
|
||||
).toBe(original)
|
||||
expect(
|
||||
patchDashboardSnapshotFromAgentStatus(original, event({ providerSessionOnly: true })).snapshot
|
||||
).toBe(original)
|
||||
})
|
||||
|
||||
it('asks the caller for topology only when the pane is unknown', () => {
|
||||
const original = snapshot()
|
||||
const result = patchDashboardSnapshotFromAgentStatus(
|
||||
original,
|
||||
event({ paneKey: 'tab-new:leaf-new' })
|
||||
)
|
||||
|
||||
expect(result).toEqual({ matched: false, snapshot: original })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { AgentStatusIpcPayload } from '../../../../shared/agent-status-types'
|
||||
import {
|
||||
dashboardCardDisplayState,
|
||||
type DashboardCard,
|
||||
type DashboardCardSubagent,
|
||||
type DashboardSnapshot
|
||||
} from '../../../../shared/dashboard-snapshot'
|
||||
import { dashboardBucketForDotState } from '../dashboard/dashboard-card-bucket'
|
||||
|
||||
export type DashboardAgentStatusPatchResult = {
|
||||
matched: boolean
|
||||
snapshot: DashboardSnapshot
|
||||
}
|
||||
|
||||
function patchedSubagents(
|
||||
card: DashboardCard,
|
||||
event: AgentStatusIpcPayload
|
||||
): DashboardCardSubagent[] | undefined {
|
||||
if (event.subagents === undefined) {
|
||||
return card.subagents
|
||||
}
|
||||
return event.subagents.map((subagent) => ({
|
||||
id: `${card.paneKey}\u0000subagent:${subagent.id}`,
|
||||
name: subagent.description || subagent.agentType || 'unknown',
|
||||
dotState: subagent.state
|
||||
}))
|
||||
}
|
||||
|
||||
/** Applies an already bounded hook event inside the pop-out renderer. */
|
||||
export function patchDashboardSnapshotFromAgentStatus(
|
||||
snapshot: DashboardSnapshot,
|
||||
event: AgentStatusIpcPayload
|
||||
): DashboardAgentStatusPatchResult {
|
||||
if (event.providerSessionOnly) {
|
||||
return { matched: true, snapshot }
|
||||
}
|
||||
const index = snapshot.cards.findIndex((card) => card.paneKey === event.paneKey)
|
||||
if (index < 0) {
|
||||
return { matched: false, snapshot }
|
||||
}
|
||||
const card = snapshot.cards[index]
|
||||
if (
|
||||
event.receivedAt <= (card.statusUpdatedAt ?? 0) ||
|
||||
(event.worktreeId !== undefined && event.worktreeId !== card.worktreeId)
|
||||
) {
|
||||
return { matched: true, snapshot }
|
||||
}
|
||||
|
||||
const stateChanged = event.stateStartedAt > card.stateChangedAt
|
||||
const unseen = stateChanged ? card.startedAt !== 0 : card.unseen
|
||||
const dotState = event.state
|
||||
const bucket = dashboardBucketForDotState(dashboardCardDisplayState({ dotState, unseen }))
|
||||
const nextCard: DashboardCard = {
|
||||
...card,
|
||||
...(event.agentType ? { agentType: event.agentType } : {}),
|
||||
...(event.prompt ? { task: event.prompt, lastUserMessage: event.prompt } : {}),
|
||||
...(event.lastAssistantMessage !== undefined
|
||||
? { lastAgentMessage: event.lastAssistantMessage || undefined }
|
||||
: {}),
|
||||
...(event.orchestration?.parentPaneKey
|
||||
? { parentPaneKey: event.orchestration.parentPaneKey }
|
||||
: {}),
|
||||
bucket,
|
||||
dotState,
|
||||
unseen,
|
||||
stateChangedAt: stateChanged ? event.stateStartedAt : card.stateChangedAt,
|
||||
statusUpdatedAt: event.receivedAt,
|
||||
finishedAt: dotState === 'done' && stateChanged ? event.stateStartedAt : card.finishedAt,
|
||||
askSummary:
|
||||
bucket === 'attention'
|
||||
? event.interactivePrompt !== undefined
|
||||
? event.interactivePrompt || undefined
|
||||
: card.askSummary
|
||||
: undefined,
|
||||
subagents: patchedSubagents(card, event)
|
||||
}
|
||||
const cards = snapshot.cards.slice()
|
||||
cards[index] = nextCard
|
||||
return {
|
||||
matched: true,
|
||||
snapshot: { ...snapshot, generatedAt: Math.max(snapshot.generatedAt, event.receivedAt), cards }
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,10 @@ import { act } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import type { DashboardCard, DashboardSnapshot } from '../../../../shared/dashboard-snapshot'
|
||||
import type {
|
||||
AgentStatusClearIpcPayload,
|
||||
AgentStatusIpcPayload
|
||||
} from '../../../../shared/agent-status-types'
|
||||
import { useDashboardSnapshot } from './useDashboardSnapshot'
|
||||
|
||||
;(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
||||
@@ -35,6 +39,9 @@ function snapshot(cards: DashboardCard[]): DashboardSnapshot {
|
||||
}
|
||||
|
||||
let apply: (next: DashboardSnapshot) => void
|
||||
let applyStatus: (next: AgentStatusIpcPayload) => void
|
||||
let applyClear: (next: AgentStatusClearIpcPayload) => void
|
||||
const requestSnapshot = vi.fn(async () => {})
|
||||
const startViewTransition = vi.fn((cb: () => void) => {
|
||||
cb()
|
||||
return {
|
||||
@@ -62,7 +69,17 @@ describe('useDashboardSnapshot', () => {
|
||||
apply = cb
|
||||
return () => {}
|
||||
},
|
||||
requestSnapshot: vi.fn(async () => {})
|
||||
requestSnapshot
|
||||
},
|
||||
agentStatus: {
|
||||
onSet: (cb: (next: AgentStatusIpcPayload) => void) => {
|
||||
applyStatus = cb
|
||||
return () => {}
|
||||
},
|
||||
onClear: (cb: (next: AgentStatusClearIpcPayload) => void) => {
|
||||
applyClear = cb
|
||||
return () => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
;(document as unknown as { startViewTransition: unknown }).startViewTransition =
|
||||
@@ -140,4 +157,85 @@ describe('useDashboardSnapshot', () => {
|
||||
act(() => apply({ ...snapshot([card({})]), repoIconsByRepoId: {} }))
|
||||
expect(result.current.repoIconsByRepoId).toEqual({})
|
||||
})
|
||||
|
||||
it('applies known hook status without requesting a rebuilt snapshot', () => {
|
||||
const { result } = renderHook(() => useDashboardSnapshot())
|
||||
act(() => apply(snapshot([card({ statusUpdatedAt: 100 })])))
|
||||
requestSnapshot.mockClear()
|
||||
|
||||
act(() =>
|
||||
applyStatus({
|
||||
paneKey: 'pk',
|
||||
state: 'blocked',
|
||||
prompt: 'Choose',
|
||||
connectionId: null,
|
||||
receivedAt: 300,
|
||||
stateStartedAt: 250
|
||||
})
|
||||
)
|
||||
|
||||
expect(requestSnapshot).not.toHaveBeenCalled()
|
||||
expect(result.current.cards[0]).toMatchObject({
|
||||
bucket: 'attention',
|
||||
task: 'Choose',
|
||||
statusUpdatedAt: 300
|
||||
})
|
||||
})
|
||||
|
||||
it('trailing-debounces an unknown-pane burst into one topology refresh', () => {
|
||||
vi.useFakeTimers()
|
||||
renderHook(() => useDashboardSnapshot())
|
||||
act(() => apply(snapshot([card({})])))
|
||||
requestSnapshot.mockClear()
|
||||
|
||||
act(() => {
|
||||
for (let index = 0; index < 10; index += 1) {
|
||||
applyStatus({
|
||||
paneKey: `unknown-${index}`,
|
||||
state: 'working',
|
||||
prompt: '',
|
||||
connectionId: null,
|
||||
receivedAt: 300 + index,
|
||||
stateStartedAt: 250 + index
|
||||
})
|
||||
vi.advanceTimersByTime(40)
|
||||
}
|
||||
applyClear({ paneKey: 'pk' })
|
||||
vi.advanceTimersByTime(249)
|
||||
})
|
||||
|
||||
expect(requestSnapshot).not.toHaveBeenCalled()
|
||||
act(() => vi.advanceTimersByTime(1))
|
||||
expect(requestSnapshot).toHaveBeenCalledTimes(1)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('refreshes within the max wait while unknown events continue', () => {
|
||||
vi.useFakeTimers()
|
||||
renderHook(() => useDashboardSnapshot())
|
||||
act(() => apply(snapshot([card({})])))
|
||||
requestSnapshot.mockClear()
|
||||
|
||||
const unknown = (index: number): void =>
|
||||
applyStatus({
|
||||
paneKey: `unknown-${index}`,
|
||||
state: 'working',
|
||||
prompt: '',
|
||||
connectionId: null,
|
||||
receivedAt: 300 + index,
|
||||
stateStartedAt: 250 + index
|
||||
})
|
||||
act(() => {
|
||||
unknown(0)
|
||||
for (let index = 1; index <= 4; index += 1) {
|
||||
vi.advanceTimersByTime(200)
|
||||
unknown(index)
|
||||
}
|
||||
})
|
||||
expect(requestSnapshot).not.toHaveBeenCalled()
|
||||
|
||||
act(() => vi.advanceTimersByTime(200))
|
||||
expect(requestSnapshot).toHaveBeenCalledTimes(1)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { flushSync } from 'react-dom'
|
||||
import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status-types'
|
||||
import {
|
||||
EMPTY_DASHBOARD_SNAPSHOT,
|
||||
type DashboardSnapshot
|
||||
} from '../../../../shared/dashboard-snapshot'
|
||||
import { patchDashboardSnapshotFromAgentStatus } from './dashboard-agent-status-patch'
|
||||
|
||||
const TOPOLOGY_REFRESH_DEBOUNCE_MS = 250
|
||||
const TOPOLOGY_REFRESH_MAX_WAIT_MS = 1_000
|
||||
|
||||
/** Which column each card sits in — the only thing a view transition should
|
||||
* animate on. Content-only updates (such as new messages) must not. */
|
||||
@@ -36,8 +41,58 @@ export function useDashboardSnapshot(): DashboardSnapshot {
|
||||
const [snapshot, setSnapshot] = useState<DashboardSnapshot>(EMPTY_DASHBOARD_SNAPSHOT)
|
||||
const columnSignatureRef = useRef('')
|
||||
const retainedRepoIconsRef = useRef<DashboardSnapshot['repoIconsByRepoId']>(undefined)
|
||||
const snapshotRef = useRef(snapshot)
|
||||
|
||||
useEffect(() => {
|
||||
let topologyRefreshTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let topologyRefreshStartedAt: number | null = null
|
||||
let staleRefreshTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const transientClearWatermarks = new Map<string, number>()
|
||||
|
||||
const requestTopologyRefresh = (): void => {
|
||||
if (topologyRefreshTimer) {
|
||||
clearTimeout(topologyRefreshTimer)
|
||||
}
|
||||
const now = Date.now()
|
||||
topologyRefreshStartedAt ??= now
|
||||
const remainingMaxWait = Math.max(
|
||||
0,
|
||||
TOPOLOGY_REFRESH_MAX_WAIT_MS - (now - topologyRefreshStartedAt)
|
||||
)
|
||||
topologyRefreshTimer = setTimeout(
|
||||
() => {
|
||||
topologyRefreshTimer = null
|
||||
topologyRefreshStartedAt = null
|
||||
void window.api.dashboard.requestSnapshot()
|
||||
},
|
||||
Math.min(TOPOLOGY_REFRESH_DEBOUNCE_MS, remainingMaxWait)
|
||||
)
|
||||
}
|
||||
|
||||
const scheduleStaleRefresh = (next: DashboardSnapshot): void => {
|
||||
if (staleRefreshTimer) {
|
||||
clearTimeout(staleRefreshTimer)
|
||||
staleRefreshTimer = null
|
||||
}
|
||||
let nextDeadline = Number.POSITIVE_INFINITY
|
||||
for (const card of next.cards) {
|
||||
if (
|
||||
card.statusUpdatedAt !== undefined &&
|
||||
(card.dotState === 'working' ||
|
||||
card.dotState === 'blocked' ||
|
||||
card.dotState === 'waiting')
|
||||
) {
|
||||
nextDeadline = Math.min(nextDeadline, card.statusUpdatedAt + AGENT_STATUS_STALE_AFTER_MS)
|
||||
}
|
||||
}
|
||||
if (Number.isFinite(nextDeadline)) {
|
||||
staleRefreshTimer = setTimeout(
|
||||
requestTopologyRefresh,
|
||||
Math.max(0, nextDeadline - Date.now() + 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const apply = (incoming: DashboardSnapshot): void => {
|
||||
// The bridge omits repoIconsByRepoId on throttled republishes when it has
|
||||
// not changed, rather than re-sending data URLs 4x/sec. Retain the last
|
||||
@@ -50,6 +105,8 @@ export function useDashboardSnapshot(): DashboardSnapshot {
|
||||
if (next.repoIconsByRepoId !== undefined) {
|
||||
retainedRepoIconsRef.current = next.repoIconsByRepoId
|
||||
}
|
||||
snapshotRef.current = next
|
||||
scheduleStaleRefresh(next)
|
||||
const nextSignature = columnSignature(next)
|
||||
const layoutChanged = nextSignature !== columnSignatureRef.current
|
||||
columnSignatureRef.current = nextSignature
|
||||
@@ -72,8 +129,45 @@ export function useDashboardSnapshot(): DashboardSnapshot {
|
||||
}
|
||||
|
||||
const unsubscribe = window.api.dashboard.onSnapshot(apply)
|
||||
const unsubscribeStatus = window.api.agentStatus.onSet((event) => {
|
||||
if (
|
||||
typeof event.connectionId === 'string' &&
|
||||
event.receivedAt <= (transientClearWatermarks.get(event.connectionId) ?? -1)
|
||||
) {
|
||||
return
|
||||
}
|
||||
const result = patchDashboardSnapshotFromAgentStatus(snapshotRef.current, event)
|
||||
if (!result.matched) {
|
||||
if (snapshotRef.current.generatedAt !== 0) {
|
||||
requestTopologyRefresh()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (result.snapshot !== snapshotRef.current) {
|
||||
apply(result.snapshot)
|
||||
}
|
||||
})
|
||||
const unsubscribeClear = window.api.agentStatus.onClear((event) => {
|
||||
if ('transient' in event && event.transient) {
|
||||
transientClearWatermarks.set(
|
||||
event.connectionId,
|
||||
Math.max(transientClearWatermarks.get(event.connectionId) ?? -1, event.clearedAt)
|
||||
)
|
||||
}
|
||||
requestTopologyRefresh()
|
||||
})
|
||||
void window.api.dashboard.requestSnapshot()
|
||||
return unsubscribe
|
||||
return () => {
|
||||
unsubscribe()
|
||||
unsubscribeStatus()
|
||||
unsubscribeClear()
|
||||
if (topologyRefreshTimer) {
|
||||
clearTimeout(topologyRefreshTimer)
|
||||
}
|
||||
if (staleRefreshTimer) {
|
||||
clearTimeout(staleRefreshTimer)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return snapshot
|
||||
|
||||
@@ -285,6 +285,7 @@ export function buildDashboardSnapshot(
|
||||
startedAt: row.startedAt,
|
||||
finishedAt,
|
||||
stateChangedAt: row.entry.stateStartedAt || row.startedAt,
|
||||
statusUpdatedAt: row.entry.updatedAt,
|
||||
// Same derivation as WorktreeCardAgents' unvisitedByPaneKey, so the
|
||||
// board and the sidebar bold/mute the same agents at the same time.
|
||||
unseen,
|
||||
|
||||
@@ -180,7 +180,6 @@ describe('useDashboardPopoutBridge', () => {
|
||||
'repos',
|
||||
'worktreesByRepo',
|
||||
'tabsByWorktree',
|
||||
'agentStatusByPaneKey',
|
||||
'retainedAgentsByPaneKey',
|
||||
'migrationUnsupportedByPtyId',
|
||||
'runtimeAgentOrchestrationByPaneKey',
|
||||
@@ -199,9 +198,15 @@ describe('useDashboardPopoutBridge', () => {
|
||||
key
|
||||
).toBe(true)
|
||||
}
|
||||
expect(
|
||||
dashboardSnapshotInputsChanged(
|
||||
{ ...previousState, agentStatusByPaneKey: { ...previousState.agentStatusByPaneKey } },
|
||||
previousState
|
||||
)
|
||||
).toBe(false)
|
||||
expect(
|
||||
dashboardSnapshotInputsChanged({ ...previousState, agentStatusEpoch: 1 }, previousState)
|
||||
).toBe(true)
|
||||
).toBe(false)
|
||||
|
||||
// Why: each card's preview terminal keys against a host-input profile
|
||||
// derived from these. Not republishing leaves the pop-out encoding bytes
|
||||
@@ -313,7 +318,7 @@ describe('useDashboardPopoutBridge repo icon publishing', () => {
|
||||
const previousState = makeSnapshotWatchState()
|
||||
act(() =>
|
||||
mocks.subscribeStore.mock.calls[0][0](
|
||||
{ ...previousState, agentStatusEpoch: 1 },
|
||||
{ ...previousState, acknowledgedAgentsByPaneKey: { pane: 1 } },
|
||||
previousState
|
||||
)
|
||||
)
|
||||
@@ -322,6 +327,19 @@ describe('useDashboardPopoutBridge repo icon publishing', () => {
|
||||
const previousState = makeSnapshotWatchState()
|
||||
act(() => mocks.subscribeStore.mock.calls[0][0]({ ...previousState }, previousState))
|
||||
}
|
||||
const notifyStatusChurn = (): void => {
|
||||
const previousState = makeSnapshotWatchState()
|
||||
act(() =>
|
||||
mocks.subscribeStore.mock.calls[0][0](
|
||||
{
|
||||
...previousState,
|
||||
agentStatusByPaneKey: { ...previousState.agentStatusByPaneKey },
|
||||
agentStatusEpoch: previousState.agentStatusEpoch + 1
|
||||
},
|
||||
previousState
|
||||
)
|
||||
)
|
||||
}
|
||||
const mountAndOpen = async (): Promise<void> => {
|
||||
await act(async () => root.render(<Harness enabled />))
|
||||
setPopoutOpen(true)
|
||||
@@ -352,6 +370,19 @@ describe('useDashboardPopoutBridge repo icon publishing', () => {
|
||||
expect(mocks.publishSnapshot).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does no snapshot work during live status churn', async () => {
|
||||
await mountAndOpen()
|
||||
mocks.buildDashboardSnapshot.mockClear()
|
||||
mocks.publishSnapshot.mockClear()
|
||||
|
||||
for (let index = 0; index < 100; index += 1) {
|
||||
notifyStatusChurn()
|
||||
}
|
||||
|
||||
expect(mocks.buildDashboardSnapshot).not.toHaveBeenCalled()
|
||||
expect(mocks.publishSnapshot).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// A burst collapses onto the trailing timer, so that edge carries most of the
|
||||
// republishes this PR exists to slim down.
|
||||
it('omits the icon map on the throttled trailing republish', async () => {
|
||||
|
||||
@@ -46,7 +46,6 @@ export function dashboardSnapshotInputsChanged(
|
||||
state.repos !== previousState.repos ||
|
||||
state.worktreesByRepo !== previousState.worktreesByRepo ||
|
||||
state.tabsByWorktree !== previousState.tabsByWorktree ||
|
||||
state.agentStatusByPaneKey !== previousState.agentStatusByPaneKey ||
|
||||
state.retainedAgentsByPaneKey !== previousState.retainedAgentsByPaneKey ||
|
||||
state.migrationUnsupportedByPtyId !== previousState.migrationUnsupportedByPtyId ||
|
||||
state.runtimeAgentOrchestrationByPaneKey !== previousState.runtimeAgentOrchestrationByPaneKey ||
|
||||
@@ -62,8 +61,8 @@ export function dashboardSnapshotInputsChanged(
|
||||
state.detectedAgentIds !== previousState.detectedAgentIds ||
|
||||
state.remoteDetectedAgentIds !== previousState.remoteDetectedAgentIds ||
|
||||
state.runtimeDetectedAgentIds !== previousState.runtimeDetectedAgentIds ||
|
||||
// Why: freshness can change a bucket without replacing any backing map.
|
||||
state.agentStatusEpoch !== previousState.agentStatusEpoch ||
|
||||
// Live hook status is relayed straight from main to the pop-out. Rebuilding
|
||||
// every card here would put map refresh work on the main renderer's hot path.
|
||||
// Why: each card carries the host-input profile its preview terminal keys
|
||||
// against, and the pop-out cannot re-derive it. Every slice that resolves
|
||||
// an execution host must republish or the preview keeps encoding bytes for
|
||||
|
||||
@@ -121,6 +121,9 @@ export type DashboardCard = {
|
||||
/** When the agent entered its current state — column ordering key (cards
|
||||
* that moved into a bucket most recently sort first). 0 when unknown. */
|
||||
stateChangedAt: number
|
||||
/** Last accepted hook update. Optional for mixed-version snapshots; the
|
||||
* pop-out uses it to request one refresh when a live state becomes stale. */
|
||||
statusUpdatedAt?: number
|
||||
/** Mirrors the sidebar's unvisited signal: the agent changed state since the
|
||||
* user last acknowledged it (visited its tab / opened its dashboard dialog).
|
||||
* Derived from the app-wide ack map so both surfaces mute in lockstep. */
|
||||
|
||||
Reference in New Issue
Block a user