mirror of
https://github.com/stablyai/orca.git
synced 2026-10-15 08:03:08 +00:00
Preserve agent lineage across status replay and worktree cleanup
- Enrich agent status snapshots with runtime terminal and orchestration metadata - Share lineage tree building between dashboard and sidebar, including coordinator fallback - Avoid applying stale runtime dispatch metadata to current hook statuses - Use worktree attribution for PR refresh and live status removal before tabs mount
This commit is contained in:
@@ -15,6 +15,7 @@ const handleHandlers = new Map<string, (event: unknown, ...args: unknown[]) => u
|
||||
const removeHandler = vi.fn()
|
||||
const removeAllListeners = vi.fn()
|
||||
const PANE_KEY = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111')
|
||||
const CHILD_PANE_KEY = makePaneKey('tab-2', '22222222-2222-4222-8222-222222222222')
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
@@ -114,6 +115,68 @@ describe('agentStatus:getSnapshot IPC', () => {
|
||||
expect(handler).toBeDefined()
|
||||
expect(handler!({})).toEqual(snapshot)
|
||||
})
|
||||
|
||||
it('enriches the hook cache snapshot with runtime lineage metadata', async () => {
|
||||
const snapshot = [
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
state: 'done',
|
||||
prompt: 'parent',
|
||||
agentType: 'codex',
|
||||
connectionId: null,
|
||||
receivedAt: 1_700_000_000_000,
|
||||
stateStartedAt: 1_699_999_999_000
|
||||
},
|
||||
{
|
||||
paneKey: CHILD_PANE_KEY,
|
||||
state: 'done',
|
||||
prompt: 'child',
|
||||
agentType: 'codex',
|
||||
connectionId: null,
|
||||
receivedAt: 1_700_000_001_000,
|
||||
stateStartedAt: 1_700_000_000_500
|
||||
}
|
||||
]
|
||||
getStatusSnapshot.mockReturnValue(snapshot)
|
||||
const runtime = {
|
||||
getAgentStatusTerminalHandleForPaneKey: vi.fn((paneKey: string) =>
|
||||
paneKey === PANE_KEY ? 'term-parent' : paneKey === CHILD_PANE_KEY ? 'term-child' : undefined
|
||||
),
|
||||
getAgentStatusOrchestrationContextForPaneKey: vi.fn((paneKey: string) =>
|
||||
paneKey === CHILD_PANE_KEY
|
||||
? {
|
||||
taskId: 'task-child',
|
||||
dispatchId: 'dispatch-child',
|
||||
parentTerminalHandle: 'term-parent',
|
||||
parentPaneKey: PANE_KEY,
|
||||
coordinatorHandle: 'term-parent'
|
||||
}
|
||||
: undefined
|
||||
)
|
||||
}
|
||||
const { registerAgentHookHandlers } = await import('./agent-hooks')
|
||||
registerAgentHookHandlers(runtime)
|
||||
|
||||
const handler = handleHandlers.get('agentStatus:getSnapshot')
|
||||
expect(handler).toBeDefined()
|
||||
expect(handler!({})).toEqual([
|
||||
{
|
||||
...snapshot[0],
|
||||
terminalHandle: 'term-parent'
|
||||
},
|
||||
{
|
||||
...snapshot[1],
|
||||
terminalHandle: 'term-child',
|
||||
orchestration: {
|
||||
taskId: 'task-child',
|
||||
dispatchId: 'dispatch-child',
|
||||
parentTerminalHandle: 'term-parent',
|
||||
parentPaneKey: PANE_KEY,
|
||||
coordinatorHandle: 'term-parent'
|
||||
}
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('agentHooks:antigravityStatus IPC', () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
MigrationUnsupportedPtyEntry
|
||||
} from '../../shared/agent-status-types'
|
||||
import type { AgentInterruptInferenceRequest } from '../../shared/agent-interrupt-intent'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
import { agentHookServer, isValidPaneKey } from '../agent-hooks/server'
|
||||
import { ampHookService } from '../amp/hook-service'
|
||||
import {
|
||||
@@ -23,11 +24,32 @@ import { copilotHookService } from '../copilot/hook-service'
|
||||
import { hermesHookService } from '../hermes/hook-service'
|
||||
import { openClaudeHookService } from '../openclaude/hook-service'
|
||||
|
||||
type AgentStatusRuntimeEnrichment = Pick<
|
||||
OrcaRuntimeService,
|
||||
'getAgentStatusTerminalHandleForPaneKey' | 'getAgentStatusOrchestrationContextForPaneKey'
|
||||
>
|
||||
|
||||
function enrichAgentStatusIpcPayload(
|
||||
data: AgentStatusIpcPayload,
|
||||
runtime: AgentStatusRuntimeEnrichment | undefined
|
||||
): AgentStatusIpcPayload {
|
||||
if (!runtime) {
|
||||
return data
|
||||
}
|
||||
const terminalHandle = runtime.getAgentStatusTerminalHandleForPaneKey(data.paneKey)
|
||||
const orchestration = runtime.getAgentStatusOrchestrationContextForPaneKey(data.paneKey)
|
||||
return {
|
||||
...data,
|
||||
...(terminalHandle ? { terminalHandle } : {}),
|
||||
...(orchestration ? { orchestration } : {})
|
||||
}
|
||||
}
|
||||
|
||||
// Why: install/remove are intentionally not exposed to the renderer. Orca
|
||||
// auto-installs managed hooks at app startup (see src/main/index.ts), so a
|
||||
// renderer-triggered remove would be silently reverted on the next launch
|
||||
// and mislead the user.
|
||||
export function registerAgentHookHandlers(): void {
|
||||
export function registerAgentHookHandlers(runtime?: AgentStatusRuntimeEnrichment): void {
|
||||
// Why: matches the defensive pattern in src/main/ipc/pty.ts so re-registration
|
||||
// never throws "Attempted to register a second handler..." if this function is
|
||||
// ever invoked more than once (e.g. the macOS app re-activation path that
|
||||
@@ -71,8 +93,11 @@ export function registerAgentHookHandlers(): void {
|
||||
})
|
||||
ipcMain.handle('agentStatus:getSnapshot', (): AgentStatusIpcPayload[] => {
|
||||
// Why: the renderer pulls this after workspace hydration, so startup cannot
|
||||
// lose replayed statuses while its local store is still empty.
|
||||
return agentHookServer.getStatusSnapshot()
|
||||
// lose replayed statuses while its local store is still empty. Match the
|
||||
// live push enrichment in main/index.ts so parent/child rows survive replay.
|
||||
return agentHookServer
|
||||
.getStatusSnapshot()
|
||||
.map((entry) => enrichAgentStatusIpcPayload(entry, runtime))
|
||||
})
|
||||
ipcMain.handle('agentStatus:inferInterrupt', (_event, request: unknown): boolean => {
|
||||
if (typeof request !== 'object' || request === null) {
|
||||
|
||||
@@ -357,7 +357,7 @@ describe('registerCoreHandlers', () => {
|
||||
expect(registerOpenCodeUsageHandlersMock).toHaveBeenCalledWith(openCodeUsage)
|
||||
expect(registerAppHandlersMock).toHaveBeenCalledWith(store, { onBeforeRelaunch })
|
||||
expect(registerCodexAccountHandlersMock).toHaveBeenCalledWith(codexAccounts)
|
||||
expect(registerAgentHookHandlersMock).toHaveBeenCalled()
|
||||
expect(registerAgentHookHandlersMock).toHaveBeenCalledWith(runtime)
|
||||
expect(registerPetHandlersMock).toHaveBeenCalled()
|
||||
expect(registerClaudeAccountHandlersMock).toHaveBeenCalledWith(claudeAccounts)
|
||||
expect(registerRateLimitHandlersMock).toHaveBeenCalledWith(rateLimits)
|
||||
|
||||
@@ -103,7 +103,7 @@ export function registerCoreHandlers(
|
||||
registerCodexUsageHandlers(codexUsage)
|
||||
registerOpenCodeUsageHandlers(openCodeUsage)
|
||||
registerCodexAccountHandlers(codexAccounts)
|
||||
registerAgentHookHandlers()
|
||||
registerAgentHookHandlers(runtime)
|
||||
registerAgentTrustHandlers()
|
||||
registerClaudeAccountHandlers(claudeAccounts)
|
||||
registerRateLimitHandlers(rateLimits)
|
||||
|
||||
@@ -7869,6 +7869,62 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('does not attach an unrelated active coordinator run to a completed dispatch', () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const workerLeafId = '55555555-5555-4555-8555-555555555555'
|
||||
const workerPaneKey = makePaneKey('tab-worker', workerLeafId)
|
||||
const workerHandle = runtime.preAllocateHandleForPty('pty-worker')
|
||||
runtime.setOrchestrationDb({
|
||||
getActiveDispatchForTerminal: vi.fn(() => undefined),
|
||||
getLatestDispatchForTerminal: vi.fn((handle: string) =>
|
||||
handle === workerHandle
|
||||
? {
|
||||
id: 'ctx-done',
|
||||
task_id: 'task-done',
|
||||
assignee_handle: workerHandle,
|
||||
status: 'completed',
|
||||
completed_at: new Date(Date.now()).toISOString()
|
||||
}
|
||||
: undefined
|
||||
),
|
||||
getTask: vi.fn(() => ({
|
||||
id: 'task-done'
|
||||
})),
|
||||
getActiveCoordinatorRun: vi.fn(() => ({
|
||||
id: 'run-unrelated',
|
||||
coordinator_handle: 'term_unrelated'
|
||||
}))
|
||||
} as never)
|
||||
runtime.attachWindow(1)
|
||||
|
||||
const result = runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId: 'tab-worker',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: 'Claude Code',
|
||||
activeLeafId: workerLeafId,
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId: 'tab-worker',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId: workerLeafId,
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'pty-worker',
|
||||
paneTitle: null
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
expect(result.agentOrchestrationByPaneKey?.[workerPaneKey]).toEqual({
|
||||
taskId: 'task-done',
|
||||
dispatchId: 'ctx-done'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not return stale completed orchestration context for renderer-synced terminal leaves', () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const workerLeafId = '77777777-7777-4777-8777-777777777777'
|
||||
|
||||
@@ -11561,7 +11561,7 @@ export class OrcaRuntimeService {
|
||||
return undefined
|
||||
}
|
||||
const task = db?.getTask?.(dispatch.task_id)
|
||||
const activeRun = db?.getActiveCoordinatorRun?.()
|
||||
const activeRun = dispatch.status === 'completed' ? undefined : db?.getActiveCoordinatorRun?.()
|
||||
const parentTerminalHandle =
|
||||
task?.created_by_terminal_handle ??
|
||||
(activeRun?.coordinator_handle && activeRun.coordinator_handle !== handle
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
|
||||
import type { TerminalTab } from '../../../../shared/types'
|
||||
import type { DashboardAgentRow } from './useDashboardData'
|
||||
import { buildAgentRowLineageTree } from './agent-row-lineage-model'
|
||||
|
||||
function makeTab(id: string): TerminalTab {
|
||||
return {
|
||||
id,
|
||||
worktreeId: 'wt-1',
|
||||
ptyId: null,
|
||||
title: 'Agent',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 0
|
||||
}
|
||||
}
|
||||
|
||||
function makeRow(
|
||||
paneKey: string,
|
||||
options: {
|
||||
terminalHandle?: string
|
||||
parentPaneKey?: string
|
||||
parentTerminalHandle?: string
|
||||
coordinatorHandle?: string
|
||||
} = {}
|
||||
): DashboardAgentRow {
|
||||
const orchestration =
|
||||
options.parentPaneKey || options.parentTerminalHandle || options.coordinatorHandle
|
||||
? {
|
||||
taskId: `${paneKey}-task`,
|
||||
dispatchId: `${paneKey}-dispatch`,
|
||||
...(options.parentPaneKey ? { parentPaneKey: options.parentPaneKey } : {}),
|
||||
...(options.parentTerminalHandle
|
||||
? { parentTerminalHandle: options.parentTerminalHandle }
|
||||
: {}),
|
||||
...(options.coordinatorHandle ? { coordinatorHandle: options.coordinatorHandle } : {})
|
||||
}
|
||||
: undefined
|
||||
const entry: AgentStatusEntry = {
|
||||
paneKey,
|
||||
state: 'done',
|
||||
prompt: paneKey,
|
||||
updatedAt: 1000,
|
||||
stateStartedAt: 1000,
|
||||
stateHistory: [],
|
||||
agentType: 'codex',
|
||||
...(options.terminalHandle ? { terminalHandle: options.terminalHandle } : {}),
|
||||
...(orchestration ? { orchestration } : {})
|
||||
}
|
||||
|
||||
return {
|
||||
paneKey,
|
||||
entry,
|
||||
tab: makeTab(paneKey.split(':')[0] ?? paneKey),
|
||||
agentType: 'codex',
|
||||
state: 'done',
|
||||
startedAt: 1000
|
||||
}
|
||||
}
|
||||
|
||||
describe('buildAgentRowLineageTree', () => {
|
||||
it('groups children by explicit parent pane key', () => {
|
||||
const parent = makeRow('parent:1')
|
||||
const child = makeRow('child:1', { parentPaneKey: 'parent:1' })
|
||||
|
||||
const tree = buildAgentRowLineageTree([parent, child])
|
||||
|
||||
expect(tree.rootRows.map((row) => row.paneKey)).toEqual(['parent:1'])
|
||||
expect(tree.childrenByParentPaneKey.get('parent:1')?.map((row) => row.paneKey)).toEqual([
|
||||
'child:1'
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to the parent terminal handle when the parent pane key is absent', () => {
|
||||
const parent = makeRow('parent:1', { terminalHandle: 'term-parent' })
|
||||
const child = makeRow('child:1', { parentTerminalHandle: 'term-parent' })
|
||||
|
||||
const tree = buildAgentRowLineageTree([parent, child])
|
||||
|
||||
expect(tree.rootRows.map((row) => row.paneKey)).toEqual(['parent:1'])
|
||||
expect(tree.childrenByParentPaneKey.get('parent:1')?.map((row) => row.paneKey)).toEqual([
|
||||
'child:1'
|
||||
])
|
||||
})
|
||||
|
||||
it('uses the coordinator handle as the last visible parent fallback', () => {
|
||||
const parent = makeRow('parent:1', { terminalHandle: 'term-coordinator' })
|
||||
const child = makeRow('child:1', { coordinatorHandle: 'term-coordinator' })
|
||||
|
||||
const tree = buildAgentRowLineageTree([parent, child])
|
||||
|
||||
expect(tree.rootRows.map((row) => row.paneKey)).toEqual(['parent:1'])
|
||||
expect(tree.childrenByParentPaneKey.get('parent:1')?.map((row) => row.paneKey)).toEqual([
|
||||
'child:1'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps cyclic lineage rows visible as flat roots', () => {
|
||||
const root = makeRow('root:1')
|
||||
const firstCycleRow = makeRow('cycle-a:1', { parentPaneKey: 'cycle-b:1' })
|
||||
const secondCycleRow = makeRow('cycle-b:1', { parentPaneKey: 'cycle-a:1' })
|
||||
|
||||
const tree = buildAgentRowLineageTree([root, firstCycleRow, secondCycleRow])
|
||||
|
||||
expect(tree.rootRows.map((row) => row.paneKey)).toEqual(['root:1', 'cycle-a:1', 'cycle-b:1'])
|
||||
expect(tree.childrenByParentPaneKey.size).toBe(0)
|
||||
expect(tree.childPaneKeys.size).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { DashboardAgentRow } from './useDashboardData'
|
||||
|
||||
export type AgentRowLineageTree<T extends DashboardAgentRow> = {
|
||||
rootRows: T[]
|
||||
childrenByParentPaneKey: Map<string, T[]>
|
||||
childPaneKeys: Set<string>
|
||||
}
|
||||
|
||||
function buildPaneKeyByTerminalHandle<T extends DashboardAgentRow>(
|
||||
rows: readonly T[]
|
||||
): Map<string, string> {
|
||||
const paneKeyByTerminalHandle = new Map<string, string>()
|
||||
for (const row of rows) {
|
||||
if (row.entry.terminalHandle && !paneKeyByTerminalHandle.has(row.entry.terminalHandle)) {
|
||||
paneKeyByTerminalHandle.set(row.entry.terminalHandle, row.paneKey)
|
||||
}
|
||||
}
|
||||
return paneKeyByTerminalHandle
|
||||
}
|
||||
|
||||
export function resolveAgentRowParentPaneKey<T extends DashboardAgentRow>(
|
||||
row: T,
|
||||
rowsByPaneKey: ReadonlyMap<string, T>,
|
||||
paneKeyByTerminalHandle: ReadonlyMap<string, string>
|
||||
): string | undefined {
|
||||
const explicitParentPaneKey = row.entry.orchestration?.parentPaneKey
|
||||
if (
|
||||
explicitParentPaneKey &&
|
||||
explicitParentPaneKey !== row.paneKey &&
|
||||
rowsByPaneKey.has(explicitParentPaneKey)
|
||||
) {
|
||||
return explicitParentPaneKey
|
||||
}
|
||||
|
||||
const parentTerminalHandles = [
|
||||
row.entry.orchestration?.parentTerminalHandle,
|
||||
row.entry.orchestration?.coordinatorHandle
|
||||
]
|
||||
for (const parentTerminalHandle of parentTerminalHandles) {
|
||||
const parentPaneKey = parentTerminalHandle
|
||||
? paneKeyByTerminalHandle.get(parentTerminalHandle)
|
||||
: undefined
|
||||
if (parentPaneKey && parentPaneKey !== row.paneKey && rowsByPaneKey.has(parentPaneKey)) {
|
||||
return parentPaneKey
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function buildAgentRowLineageTree<T extends DashboardAgentRow>(
|
||||
rows: readonly T[]
|
||||
): AgentRowLineageTree<T> {
|
||||
const rowsByPaneKey = new Map<string, T>()
|
||||
for (const row of rows) {
|
||||
if (!rowsByPaneKey.has(row.paneKey)) {
|
||||
rowsByPaneKey.set(row.paneKey, row)
|
||||
}
|
||||
}
|
||||
const paneKeyByTerminalHandle = buildPaneKeyByTerminalHandle(rows)
|
||||
const childrenByParentPaneKey = new Map<string, T[]>()
|
||||
const childPaneKeys = new Set<string>()
|
||||
|
||||
for (const row of rows) {
|
||||
const parentPaneKey = resolveAgentRowParentPaneKey(row, rowsByPaneKey, paneKeyByTerminalHandle)
|
||||
if (!parentPaneKey) {
|
||||
continue
|
||||
}
|
||||
childPaneKeys.add(row.paneKey)
|
||||
const siblings = childrenByParentPaneKey.get(parentPaneKey)
|
||||
if (siblings) {
|
||||
siblings.push(row)
|
||||
} else {
|
||||
childrenByParentPaneKey.set(parentPaneKey, [row])
|
||||
}
|
||||
}
|
||||
|
||||
const rootRows = rows.filter((row) => !childPaneKeys.has(row.paneKey))
|
||||
if (rootRows.length === 0 && rows.length > 0) {
|
||||
// Why: malformed orchestration metadata can form a closed cycle. Keep every
|
||||
// row visible as a flat root instead of hiding all participants.
|
||||
return { rootRows: [...rows], childrenByParentPaneKey: new Map(), childPaneKeys: new Set() }
|
||||
}
|
||||
|
||||
const reachablePaneKeys = new Set<string>()
|
||||
const markReachable = (row: T, ancestorPaneKeys: ReadonlySet<string> = new Set()): void => {
|
||||
if (reachablePaneKeys.has(row.paneKey) || ancestorPaneKeys.has(row.paneKey)) {
|
||||
return
|
||||
}
|
||||
reachablePaneKeys.add(row.paneKey)
|
||||
const descendantAncestorPaneKeys = new Set(ancestorPaneKeys)
|
||||
descendantAncestorPaneKeys.add(row.paneKey)
|
||||
for (const childRow of childrenByParentPaneKey.get(row.paneKey) ?? []) {
|
||||
markReachable(childRow, descendantAncestorPaneKeys)
|
||||
}
|
||||
}
|
||||
for (const rootRow of rootRows) {
|
||||
markReachable(rootRow)
|
||||
}
|
||||
|
||||
const unreachableRows = rows.filter((row) => !reachablePaneKeys.has(row.paneKey))
|
||||
if (unreachableRows.length === 0) {
|
||||
return { rootRows, childrenByParentPaneKey, childPaneKeys }
|
||||
}
|
||||
|
||||
const normalizedChildrenByParentPaneKey = new Map(childrenByParentPaneKey)
|
||||
const normalizedChildPaneKeys = new Set(childPaneKeys)
|
||||
for (const row of unreachableRows) {
|
||||
if (!rootRows.some((rootRow) => rootRow.paneKey === row.paneKey)) {
|
||||
rootRows.push(row)
|
||||
}
|
||||
normalizedChildPaneKeys.delete(row.paneKey)
|
||||
normalizedChildrenByParentPaneKey.delete(row.paneKey)
|
||||
for (const [parentPaneKey, siblings] of normalizedChildrenByParentPaneKey) {
|
||||
const visibleSiblings = siblings.filter((sibling) => sibling.paneKey !== row.paneKey)
|
||||
if (visibleSiblings.length === 0) {
|
||||
normalizedChildrenByParentPaneKey.delete(parentPaneKey)
|
||||
} else if (visibleSiblings.length !== siblings.length) {
|
||||
normalizedChildrenByParentPaneKey.set(parentPaneKey, visibleSiblings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
rootRows,
|
||||
childrenByParentPaneKey: normalizedChildrenByParentPaneKey,
|
||||
childPaneKeys: normalizedChildPaneKeys
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { DashboardAgentRow } from './useDashboardData'
|
||||
import { buildAgentRowLineageTree } from './agent-row-lineage-model'
|
||||
|
||||
export type AgentRowLineagePresentation = {
|
||||
depth: 0 | 1
|
||||
@@ -23,35 +24,7 @@ export function applyAgentRowLineage(rows: DashboardAgentRow[]): DashboardAgentR
|
||||
return rows.map((row) => ({ ...row, lineage: ROOT_LINEAGE }))
|
||||
}
|
||||
|
||||
const rowsByPaneKey = new Map(rows.map((row) => [row.paneKey, row]))
|
||||
const paneKeyByTerminalHandle = new Map<string, string>()
|
||||
for (const row of rows) {
|
||||
if (row.entry.terminalHandle && !paneKeyByTerminalHandle.has(row.entry.terminalHandle)) {
|
||||
paneKeyByTerminalHandle.set(row.entry.terminalHandle, row.paneKey)
|
||||
}
|
||||
}
|
||||
const childrenByParentPaneKey = new Map<string, DashboardAgentRow[]>()
|
||||
const childPaneKeys = new Set<string>()
|
||||
|
||||
for (const row of rows) {
|
||||
const explicitParentPaneKey = row.entry.orchestration?.parentPaneKey
|
||||
const parentPaneKey =
|
||||
explicitParentPaneKey && rowsByPaneKey.has(explicitParentPaneKey)
|
||||
? explicitParentPaneKey
|
||||
: row.entry.orchestration?.parentTerminalHandle
|
||||
? paneKeyByTerminalHandle.get(row.entry.orchestration.parentTerminalHandle)
|
||||
: undefined
|
||||
if (!parentPaneKey || parentPaneKey === row.paneKey || !rowsByPaneKey.has(parentPaneKey)) {
|
||||
continue
|
||||
}
|
||||
childPaneKeys.add(row.paneKey)
|
||||
const siblings = childrenByParentPaneKey.get(parentPaneKey)
|
||||
if (siblings) {
|
||||
siblings.push(row)
|
||||
} else {
|
||||
childrenByParentPaneKey.set(parentPaneKey, [row])
|
||||
}
|
||||
}
|
||||
const { rootRows, childrenByParentPaneKey, childPaneKeys } = buildAgentRowLineageTree(rows)
|
||||
|
||||
if (childPaneKeys.size === 0) {
|
||||
return rows.map((row) => ({ ...row, lineage: ROOT_LINEAGE }))
|
||||
@@ -86,10 +59,7 @@ export function applyAgentRowLineage(rows: DashboardAgentRow[]): DashboardAgentR
|
||||
})
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
if (childPaneKeys.has(row.paneKey)) {
|
||||
continue
|
||||
}
|
||||
for (const row of rootRows) {
|
||||
emitSubtree(row, ROOT_LINEAGE)
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,11 @@ type MockAgentOptions = {
|
||||
lastAssistantMessage?: string
|
||||
stateStartedAt?: number
|
||||
terminalHandle?: string
|
||||
orchestration?: { parentPaneKey?: string; parentTerminalHandle?: string }
|
||||
orchestration?: {
|
||||
parentPaneKey?: string
|
||||
parentTerminalHandle?: string
|
||||
coordinatorHandle?: string
|
||||
}
|
||||
lineage?: {
|
||||
depth: number
|
||||
isFirstSibling: boolean
|
||||
@@ -246,6 +250,30 @@ describe('WorktreeCardAgents', () => {
|
||||
expect(markup).toContain('aria-label="Hide 1 child agent"')
|
||||
})
|
||||
|
||||
it('shows orchestration children under a visible coordinator when parent handle is absent', async () => {
|
||||
mockAgentActivityDisplayMode = 'full'
|
||||
mockAgents = [
|
||||
mockAgent({
|
||||
paneKey: 'tab-parent:1',
|
||||
terminalHandle: 'term-coordinator'
|
||||
}),
|
||||
mockAgent({
|
||||
paneKey: 'tab-child:1',
|
||||
state: 'done',
|
||||
stateStartedAt: 1500,
|
||||
orchestration: { coordinatorHandle: 'term-coordinator' }
|
||||
})
|
||||
]
|
||||
const { default: WorktreeCardAgents } = await import('./WorktreeCardAgents')
|
||||
|
||||
const markup = renderToStaticMarkup(<WorktreeCardAgents worktreeId="wt-1" />)
|
||||
|
||||
expect(markup).toContain('role="tree"')
|
||||
expect(markup).toContain('data-pane-key="tab-parent:1"')
|
||||
expect(markup).toContain('data-pane-key="tab-child:1"')
|
||||
expect(markup).toContain('aria-label="Hide 1 child agent"')
|
||||
})
|
||||
|
||||
it('keeps partially cyclic orchestration rows visible as flat roots', async () => {
|
||||
mockAgentActivityDisplayMode = 'full'
|
||||
mockAgents = [
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
CompactAgentRow,
|
||||
CompactAgentSummaryButton
|
||||
} from './worktree-card-compact-agents'
|
||||
import { buildAgentRowLineageTree } from '@/components/dashboard/agent-row-lineage-model'
|
||||
import { DEFAULT_AGENT_ACTIVITY_DISPLAY_MODE } from '../../../../shared/constants'
|
||||
import { revealElementInScrollContainer } from './worktree-sidebar-reveal'
|
||||
|
||||
@@ -74,82 +75,6 @@ type BodyProps = {
|
||||
className?: string
|
||||
}
|
||||
|
||||
type AgentLineageModel = {
|
||||
rootAgents: DashboardAgentRowData[]
|
||||
childrenByParentPaneKey: Map<string, DashboardAgentRowData[]>
|
||||
}
|
||||
|
||||
function buildAgentLineageModel(agents: DashboardAgentRowData[]): AgentLineageModel {
|
||||
const agentPaneKeys = new Set(agents.map((agent) => agent.paneKey))
|
||||
const paneKeyByTerminalHandle = new Map<string, string>()
|
||||
for (const agent of agents) {
|
||||
if (agent.entry.terminalHandle && !paneKeyByTerminalHandle.has(agent.entry.terminalHandle)) {
|
||||
paneKeyByTerminalHandle.set(agent.entry.terminalHandle, agent.paneKey)
|
||||
}
|
||||
}
|
||||
const childrenByParentPaneKey = new Map<string, DashboardAgentRowData[]>()
|
||||
const childPaneKeys = new Set<string>()
|
||||
|
||||
for (const agent of agents) {
|
||||
const explicitParentPaneKey = agent.entry.orchestration?.parentPaneKey
|
||||
const parentPaneKey =
|
||||
explicitParentPaneKey && agentPaneKeys.has(explicitParentPaneKey)
|
||||
? explicitParentPaneKey
|
||||
: agent.entry.orchestration?.parentTerminalHandle
|
||||
? paneKeyByTerminalHandle.get(agent.entry.orchestration.parentTerminalHandle)
|
||||
: undefined
|
||||
if (!parentPaneKey || parentPaneKey === agent.paneKey || !agentPaneKeys.has(parentPaneKey)) {
|
||||
continue
|
||||
}
|
||||
childPaneKeys.add(agent.paneKey)
|
||||
const siblings = childrenByParentPaneKey.get(parentPaneKey)
|
||||
if (siblings) {
|
||||
siblings.push(agent)
|
||||
} else {
|
||||
childrenByParentPaneKey.set(parentPaneKey, [agent])
|
||||
}
|
||||
}
|
||||
|
||||
const rootAgents = agents.filter((agent) => !childPaneKeys.has(agent.paneKey))
|
||||
if (rootAgents.length === 0 && agents.length > 0) {
|
||||
// Why: malformed orchestration metadata can theoretically form a cycle.
|
||||
// Keep every row visible instead of recursing forever or hiding the list.
|
||||
return { rootAgents: agents, childrenByParentPaneKey: new Map() }
|
||||
}
|
||||
|
||||
const reachablePaneKeys = new Set<string>()
|
||||
const markReachable = (
|
||||
agent: DashboardAgentRowData,
|
||||
ancestorPaneKeys: ReadonlySet<string> = new Set()
|
||||
): void => {
|
||||
if (reachablePaneKeys.has(agent.paneKey) || ancestorPaneKeys.has(agent.paneKey)) {
|
||||
return
|
||||
}
|
||||
reachablePaneKeys.add(agent.paneKey)
|
||||
const descendantAncestorPaneKeys = new Set(ancestorPaneKeys)
|
||||
descendantAncestorPaneKeys.add(agent.paneKey)
|
||||
for (const childAgent of childrenByParentPaneKey.get(agent.paneKey) ?? []) {
|
||||
markReachable(childAgent, descendantAncestorPaneKeys)
|
||||
}
|
||||
}
|
||||
for (const rootAgent of rootAgents) {
|
||||
markReachable(rootAgent)
|
||||
}
|
||||
|
||||
for (const agent of agents) {
|
||||
if (reachablePaneKeys.has(agent.paneKey)) {
|
||||
continue
|
||||
}
|
||||
// Why: a partial cycle alongside a valid root has no true root, so it
|
||||
// would otherwise disappear. Render malformed participants as flat rows
|
||||
// and drop their child edges, matching the dashboard lineage fallback.
|
||||
rootAgents.push(agent)
|
||||
childrenByParentPaneKey.delete(agent.paneKey)
|
||||
}
|
||||
|
||||
return { rootAgents, childrenByParentPaneKey }
|
||||
}
|
||||
|
||||
const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({
|
||||
worktreeId,
|
||||
agents,
|
||||
@@ -287,8 +212,8 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({
|
||||
// never mount this component (see WorktreeCardAgents), so idle worktrees
|
||||
// don't pay any timer cost.
|
||||
const now = useNow(30_000)
|
||||
const { rootAgents, childrenByParentPaneKey } = useMemo(
|
||||
() => buildAgentLineageModel(agents),
|
||||
const { rootRows: rootAgents, childrenByParentPaneKey } = useMemo(
|
||||
() => buildAgentRowLineageTree(agents),
|
||||
[agents]
|
||||
)
|
||||
const hasLineage = childrenByParentPaneKey.size > 0
|
||||
|
||||
@@ -169,49 +169,49 @@ describe('buildWorktreeAgentRows', () => {
|
||||
expect(rows[1].entry.orchestration).toMatchObject({ parentPaneKey: PANE_KEY_1 })
|
||||
})
|
||||
|
||||
it('uses fresh runtime orchestration metadata over stale hook-reported dispatch identity', () => {
|
||||
const staleParent = makeEntry(PANE_KEY_1, 1000, {
|
||||
prompt: 'stale parent',
|
||||
it('does not override current hook dispatch identity with mismatched runtime metadata', () => {
|
||||
const currentParent = makeEntry(PANE_KEY_1, 1000, {
|
||||
prompt: 'current parent',
|
||||
state: 'done'
|
||||
})
|
||||
const currentParent = makeEntry(PANE_KEY_3, 1100, {
|
||||
prompt: 'current parent',
|
||||
const staleParent = makeEntry(PANE_KEY_3, 1100, {
|
||||
prompt: 'stale parent',
|
||||
state: 'working'
|
||||
})
|
||||
const child = makeEntry(PANE_KEY_2, 1200, {
|
||||
prompt: 'child',
|
||||
state: 'working',
|
||||
orchestration: {
|
||||
taskId: 'task-1',
|
||||
dispatchId: 'ctx-1',
|
||||
taskId: 'task-2',
|
||||
dispatchId: 'ctx-2',
|
||||
parentPaneKey: PANE_KEY_1,
|
||||
parentTerminalHandle: 'term-stale'
|
||||
parentTerminalHandle: 'term-current'
|
||||
}
|
||||
})
|
||||
const rows = applyAgentRowLineage(
|
||||
buildWorktreeAgentRows({
|
||||
tabs: [makeTab('tab-1'), makeTab('tab-2'), makeTab('tab-3')],
|
||||
entries: [staleParent, currentParent, child],
|
||||
entries: [currentParent, staleParent, child],
|
||||
retained: [],
|
||||
runtimeAgentOrchestrationByPaneKey: {
|
||||
[PANE_KEY_2]: {
|
||||
taskId: 'task-2',
|
||||
dispatchId: 'ctx-2',
|
||||
taskId: 'task-1',
|
||||
dispatchId: 'ctx-1',
|
||||
parentPaneKey: PANE_KEY_3,
|
||||
parentTerminalHandle: 'term-current'
|
||||
parentTerminalHandle: 'term-stale'
|
||||
}
|
||||
},
|
||||
now: 2000
|
||||
})
|
||||
)
|
||||
|
||||
expect(rows.map((row) => row.paneKey)).toEqual([PANE_KEY_1, PANE_KEY_3, PANE_KEY_2])
|
||||
expect(rows[1].lineage).toMatchObject({ depth: 0, childCount: 1 })
|
||||
expect(rows[2].lineage).toMatchObject({ depth: 1, childCount: 0 })
|
||||
expect(rows[2].entry.orchestration).toEqual({
|
||||
expect(rows.map((row) => row.paneKey)).toEqual([PANE_KEY_1, PANE_KEY_2, PANE_KEY_3])
|
||||
expect(rows[0].lineage).toMatchObject({ depth: 0, childCount: 1 })
|
||||
expect(rows[1].lineage).toMatchObject({ depth: 1, childCount: 0 })
|
||||
expect(rows[1].entry.orchestration).toEqual({
|
||||
taskId: 'task-2',
|
||||
dispatchId: 'ctx-2',
|
||||
parentPaneKey: PANE_KEY_3,
|
||||
parentPaneKey: PANE_KEY_1,
|
||||
parentTerminalHandle: 'term-current'
|
||||
})
|
||||
})
|
||||
|
||||
@@ -51,6 +51,9 @@ function entryWithRuntimeOrchestration(
|
||||
runtimeOrchestration &&
|
||||
entry.orchestration.taskId === runtimeOrchestration.taskId &&
|
||||
entry.orchestration.dispatchId === runtimeOrchestration.dispatchId
|
||||
if (entry.orchestration && runtimeOrchestration && !sameDispatch) {
|
||||
return entry
|
||||
}
|
||||
const orchestration =
|
||||
sameDispatch && entry.orchestration && runtimeOrchestration
|
||||
? { ...entry.orchestration, ...runtimeOrchestration }
|
||||
|
||||
@@ -69,11 +69,6 @@ export function buildSummaryAgentGroups(agents: DashboardAgentRowData[]): Summar
|
||||
})
|
||||
}
|
||||
|
||||
export function formatSummaryGroupLabel(group: SummaryAgentGroup): string {
|
||||
const label = formatSummaryStateLabel(group.state)
|
||||
return group.agents.length === 1 ? label : `${group.agents.length} ${label}`
|
||||
}
|
||||
|
||||
export function summarizeAgents(agents: DashboardAgentRowData[], subjectLabel: string): string {
|
||||
const counts = new Map<AgentDotState, number>()
|
||||
for (const agent of agents) {
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
AGENT_STATUS_STALE_AFTER_MS,
|
||||
type AgentStatusEntry
|
||||
} from '../../../../shared/agent-status-types'
|
||||
import type { TerminalTab } from '../../../../shared/types'
|
||||
import type { AppState } from '../types'
|
||||
import type { RetainedAgentEntry } from './agent-status'
|
||||
import { createTestStore, makeTab, makeWorktree } from './store-test-helpers'
|
||||
@@ -363,7 +362,7 @@ describe('agent status runtime orchestration metadata', () => {
|
||||
const retained: RetainedAgentEntry = {
|
||||
entry,
|
||||
worktreeId: 'wt-1',
|
||||
tab: { id: 'tab-child', title: 'codex' } as unknown as TerminalTab,
|
||||
tab: makeTab({ id: 'tab-child', worktreeId: 'wt-1', title: 'codex' }),
|
||||
agentType: 'codex',
|
||||
startedAt: now
|
||||
}
|
||||
@@ -696,6 +695,47 @@ describe('agent status PR refresh handoff', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('uses hook worktree attribution for PR refresh when the agent tab is not mounted', async () => {
|
||||
vi.useFakeTimers()
|
||||
const enqueuePRRefresh = stubGitHubPRRefreshApi()
|
||||
const store = createTestStore()
|
||||
seedAgentPRRefreshFixture(store, ['pr'])
|
||||
store.setState({ tabsByWorktree: { 'wt-1': [] } } as Partial<AppState>)
|
||||
const paneKey = 'tab-worker:11111111-1111-4111-8111-111111111111'
|
||||
|
||||
store
|
||||
.getState()
|
||||
.setAgentStatus(
|
||||
paneKey,
|
||||
{ state: 'working', prompt: 'create a PR', agentType: 'codex' },
|
||||
undefined,
|
||||
undefined,
|
||||
{ tabId: 'tab-worker', worktreeId: 'wt-1', terminalHandle: 'term-worker' }
|
||||
)
|
||||
store
|
||||
.getState()
|
||||
.setAgentStatus(
|
||||
paneKey,
|
||||
{ state: 'done', prompt: 'create a PR', agentType: 'codex' },
|
||||
undefined,
|
||||
undefined,
|
||||
{ tabId: 'tab-worker', worktreeId: 'wt-1', terminalHandle: 'term-worker' }
|
||||
)
|
||||
|
||||
await flushMicrotasks()
|
||||
|
||||
expect(enqueuePRRefresh).toHaveBeenCalledWith({
|
||||
candidate: expect.objectContaining({
|
||||
repoPath: '/repo',
|
||||
branch: 'feature/pr-from-agent',
|
||||
worktreeId: 'wt-1',
|
||||
linkedPRNumber: null
|
||||
}),
|
||||
reason: 'active',
|
||||
priority: 80
|
||||
})
|
||||
})
|
||||
|
||||
it('does not spend a PR refresh when no PR surface is visible', async () => {
|
||||
vi.useFakeTimers()
|
||||
const enqueuePRRefresh = stubGitHubPRRefreshApi()
|
||||
@@ -881,14 +921,14 @@ describe('agent status retention + prefix sweep', () => {
|
||||
const retainedA: RetainedAgentEntry = {
|
||||
entry: entryA,
|
||||
worktreeId: 'wt-a',
|
||||
tab: { id: 'tab-a', title: 'claude' } as unknown as TerminalTab,
|
||||
tab: makeTab({ id: 'tab-a', worktreeId: 'wt-a', title: 'claude' }),
|
||||
agentType: 'claude',
|
||||
startedAt: now
|
||||
}
|
||||
const retainedB: RetainedAgentEntry = {
|
||||
entry: entryB,
|
||||
worktreeId: 'wt-b',
|
||||
tab: { id: 'tab-b', title: 'claude' } as unknown as TerminalTab,
|
||||
tab: makeTab({ id: 'tab-b', worktreeId: 'wt-b', title: 'claude' }),
|
||||
agentType: 'claude',
|
||||
startedAt: now
|
||||
}
|
||||
@@ -931,14 +971,14 @@ describe('agent status retention + prefix sweep', () => {
|
||||
const retainedA: RetainedAgentEntry = {
|
||||
entry: entryA,
|
||||
worktreeId: 'wt-a',
|
||||
tab: { id: 'tab-a', title: 'claude' } as unknown as TerminalTab,
|
||||
tab: makeTab({ id: 'tab-a', worktreeId: 'wt-a', title: 'claude' }),
|
||||
agentType: 'claude',
|
||||
startedAt: now
|
||||
}
|
||||
const retainedB: RetainedAgentEntry = {
|
||||
entry: entryB,
|
||||
worktreeId: 'wt-a',
|
||||
tab: { id: 'tab-a', title: 'claude' } as unknown as TerminalTab,
|
||||
tab: makeTab({ id: 'tab-a', worktreeId: 'wt-a', title: 'claude' }),
|
||||
agentType: 'claude',
|
||||
startedAt: now
|
||||
}
|
||||
@@ -958,6 +998,35 @@ describe('agent status retention + prefix sweep', () => {
|
||||
expect(suppressed['tab-a:1']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('dropAgentStatusByWorktree removes live entries attributed before their tab exists', () => {
|
||||
vi.useFakeTimers()
|
||||
const store = createTestStore()
|
||||
const paneKey = 'tab-worker:11111111-1111-4111-8111-111111111111'
|
||||
|
||||
store
|
||||
.getState()
|
||||
.setAgentStatus(
|
||||
paneKey,
|
||||
{ state: 'working', prompt: 'worker', agentType: 'codex' },
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
tabId: 'tab-worker',
|
||||
worktreeId: 'wt-a',
|
||||
terminalHandle: 'term-worker'
|
||||
}
|
||||
)
|
||||
store.setState({
|
||||
acknowledgedAgentsByPaneKey: { [paneKey]: Date.now() }
|
||||
} as Partial<AppState>)
|
||||
|
||||
store.getState().dropAgentStatusByWorktree('wt-a')
|
||||
|
||||
expect(store.getState().agentStatusByPaneKey[paneKey]).toBeUndefined()
|
||||
expect(store.getState().acknowledgedAgentsByPaneKey[paneKey]).toBeUndefined()
|
||||
expect(store.getState().retentionSuppressedPaneKeys[paneKey]).toBe(true)
|
||||
})
|
||||
|
||||
it('pruneRetainedAgents keeps only entries whose worktreeId is in the valid set', () => {
|
||||
const store = createTestStore()
|
||||
const now = Date.now()
|
||||
@@ -980,14 +1049,14 @@ describe('agent status retention + prefix sweep', () => {
|
||||
const retainedA: RetainedAgentEntry = {
|
||||
entry: entryA,
|
||||
worktreeId: 'wt-a',
|
||||
tab: { id: 'tab-a', title: 'claude' } as unknown as TerminalTab,
|
||||
tab: makeTab({ id: 'tab-a', worktreeId: 'wt-a', title: 'claude' }),
|
||||
agentType: 'claude',
|
||||
startedAt: now
|
||||
}
|
||||
const retainedB: RetainedAgentEntry = {
|
||||
entry: entryB,
|
||||
worktreeId: 'wt-b',
|
||||
tab: { id: 'tab-b', title: 'claude' } as unknown as TerminalTab,
|
||||
tab: makeTab({ id: 'tab-b', worktreeId: 'wt-b', title: 'claude' }),
|
||||
agentType: 'claude',
|
||||
startedAt: now
|
||||
}
|
||||
|
||||
@@ -96,15 +96,9 @@ export type AgentStatusSlice = {
|
||||
* Used on worktree sleep/remove — the whole worktree surface is folding, so
|
||||
* retained rows must drop even if their original tab is no longer present.
|
||||
*
|
||||
* Note on orphan-live asymmetry: liveKeys are matched against tab prefixes
|
||||
* derived from `tabsByWorktree[worktreeId]`. Live entries belonging to the
|
||||
* same worktree but whose tab has already been pruned from `tabsByWorktree`
|
||||
* (an orphan-live race) are not swept here. The retained side has a
|
||||
* fallback (`retained.worktreeId === worktreeId`); the live side does not,
|
||||
* because live entries do not carry a worktreeId. In practice the gap is
|
||||
* bounded — `removeAgentStatus` is called on PTY exit — and dropping an
|
||||
* orphan-live entry on shutdown is best-effort, so accepting the asymmetry
|
||||
* is the simpler tradeoff. */
|
||||
* Live entries are swept by tab prefix and by main-stamped worktree
|
||||
* attribution so worker rows that arrive before their tab exists do not
|
||||
* survive sleep/remove. */
|
||||
dropAgentStatusByWorktree: (worktreeId: string) => void
|
||||
|
||||
/** Retain agent snapshots (called by the top-level retention sync effect).
|
||||
@@ -439,7 +433,7 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
|
||||
existing !== undefined &&
|
||||
!isAgentCompletionState(existing.state)
|
||||
) {
|
||||
completionRefreshWorktreeId = findAgentPaneWorktreeId(s, paneKey)
|
||||
completionRefreshWorktreeId = entry.worktreeId ?? findAgentPaneWorktreeId(s, paneKey)
|
||||
}
|
||||
// Why: broad freshness-aware subscribers only need a global tick when
|
||||
// an entry appears, changes state, crosses stale->fresh, or receives
|
||||
@@ -852,9 +846,13 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
|
||||
let hadLive = false
|
||||
set((s) => {
|
||||
const tabPrefixes = (s.tabsByWorktree[worktreeId] ?? []).map((tab) => `${tab.id}:`)
|
||||
const liveKeys = Object.keys(s.agentStatusByPaneKey).filter((k) =>
|
||||
paneKeyMatchesAnyTabPrefix(k, tabPrefixes)
|
||||
)
|
||||
const liveKeys = Object.entries(s.agentStatusByPaneKey)
|
||||
.filter(
|
||||
([paneKey, entry]) =>
|
||||
entry.worktreeId === worktreeId || paneKeyMatchesAnyTabPrefix(paneKey, tabPrefixes)
|
||||
)
|
||||
.map(([paneKey]) => paneKey)
|
||||
const liveKeySet = new Set(liveKeys)
|
||||
const retainedKeys = Object.entries(s.retainedAgentsByPaneKey)
|
||||
.filter(
|
||||
([paneKey, retained]) =>
|
||||
@@ -869,10 +867,12 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
|
||||
(entry.paneKey ? paneKeyMatchesAnyTabPrefix(entry.paneKey, tabPrefixes) : false)
|
||||
)
|
||||
// See removeAgentStatus for rationale on ack cleanup. Current tabs are
|
||||
// swept by prefix; orphan retained rows are swept by their retained key.
|
||||
// swept by prefix; attributed live rows and orphan retained rows are
|
||||
// swept by their retained/lifecycle key.
|
||||
let nextAck = s.acknowledgedAgentsByPaneKey
|
||||
const ackKeys = Object.keys(nextAck).filter(
|
||||
(k) => paneKeyMatchesAnyTabPrefix(k, tabPrefixes) || retainedKeySet.has(k)
|
||||
(k) =>
|
||||
paneKeyMatchesAnyTabPrefix(k, tabPrefixes) || liveKeySet.has(k) || retainedKeySet.has(k)
|
||||
)
|
||||
if (ackKeys.length > 0) {
|
||||
nextAck = { ...nextAck }
|
||||
|
||||
Reference in New Issue
Block a user