From d20a8cf9a77e31d9f0f3759f35ef31449f80f3b1 Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Wed, 2 Sep 2026 06:41:58 +0800 Subject: [PATCH 1/5] fix(desktop): keep the Agent Graph panel visibly alive while operators run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fan-out graph in its early window shows a static header (`Running · 0/7 settled`) and seven static dots, so the panel reads as frozen even though scheduling is correct and child sessions are working (#3878). Three presentation-layer liveness signals, no protocol change: - Running and runnable operator dots pulse (CSS only, disabled under prefers-reduced-motion). - The header shows a subtle spinner for as long as the snapshot status is active, waiting, or closing. - A per-epoch elapsed clock (`· 00:23`) trails the settled counter. The client snapshot carries no start timestamp, so the clock measures this view's observation of the live graph and resets when the selection moves to another epoch or the graph settles; it is aria-hidden because status and progress are already announced. Fixes #3878 Generated-by: GLM-5.3-Flash (ZCode) --- .../src/renderer/agent-graph-panel.tsx | 28 +++- .../src/renderer/styles/agent-graph.css | 27 ++++ docs/astryx-surface-file-inventory.paths | 1 + .../agent-graph-live-status.test.tsx | 145 ++++++++++++++++++ packages/ui/src/agent-graph-live-status.tsx | 85 ++++++++++ packages/ui/src/index.ts | 1 + 6 files changed, 280 insertions(+), 7 deletions(-) create mode 100644 packages/ui/src/__tests__/agent-graph-live-status.test.tsx create mode 100644 packages/ui/src/agent-graph-live-status.tsx diff --git a/apps/desktop/src/renderer/agent-graph-panel.tsx b/apps/desktop/src/renderer/agent-graph-panel.tsx index 32e8a38810..abaa6cd115 100644 --- a/apps/desktop/src/renderer/agent-graph-panel.tsx +++ b/apps/desktop/src/renderer/agent-graph-panel.tsx @@ -25,7 +25,7 @@ import type { } from '@maka/runtime/stream-graph-read-model'; import type { AgentGraphEpochDirectory } from '@maka/runtime-host/client'; import type { AgentGraphEpochSummary } from '@maka/runtime-host/protocol'; -import { IconButton, Selector, type SelectorOptionType } from '@maka/ui'; +import { AgentGraphLiveStatus, IconButton, Selector, type SelectorOptionType } from '@maka/ui'; import { ICON_SIZE, ChevronDown, X } from '@maka/ui/icons'; import { Banner } from '@astryxdesign/core/Banner'; import { Button } from '@astryxdesign/core/Button'; @@ -50,6 +50,13 @@ const noopAgentGraphRefreshScheduler: AgentGraphRefreshScheduler = { dispose() {}, }; +/** Snapshot statuses during which the panel must keep signaling liveness. */ +const GRAPH_LIVE_STATUSES: ReadonlySet = new Set([ + 'active', + 'waiting', + 'closing', +]); + type GraphPanelCopy = { title: string; loading: string; @@ -204,6 +211,7 @@ export function AgentGraphPanel(props: { stopState.rootSessionId === props.rootSessionId && stopState.graphId === selectedGraphId; const stopPending = stopFeedbackMatchesSelection && stopState.pending; const stopError = stopFeedbackMatchesSelection && stopState.error; + const graphLive = snapshot !== undefined && GRAPH_LIVE_STATUSES.has(snapshot.status); useEffect(() => { setSnapshot(undefined); @@ -393,12 +401,18 @@ export function AgentGraphPanel(props: { ) : null} {snapshot ? ( - {copy.status(snapshot.status)} ·{' '} - {copy.progress( - progress.settled, - progress.total, - snapshot.omitted.operators > 0, - )} + + {copy.status(snapshot.status)} ·{' '} + {copy.progress( + progress.settled, + progress.total, + snapshot.omitted.operators > 0, + )} + ) : null} diff --git a/apps/desktop/src/renderer/styles/agent-graph.css b/apps/desktop/src/renderer/styles/agent-graph.css index cd4e0640a9..d71d3b07f7 100644 --- a/apps/desktop/src/renderer/styles/agent-graph.css +++ b/apps/desktop/src/renderer/styles/agent-graph.css @@ -102,6 +102,15 @@ color: var(--muted-foreground); } +.maka-agent-graph-heartbeat { + flex: 0 0 auto; +} + +.maka-agent-graph-elapsed { + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + .maka-agent-graph-empty { margin: var(--space-2) 0 0; } @@ -143,9 +152,21 @@ background: var(--muted-foreground); } +@keyframes maka-agent-graph-dot-pulse { + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.35; + } +} + .maka-agent-graph-operators li[data-status="running"] .maka-agent-graph-status-dot, .maka-agent-graph-operators li[data-status="runnable"] .maka-agent-graph-status-dot { background: var(--status-running); + animation: maka-agent-graph-dot-pulse 1.6s ease-in-out infinite; } .maka-agent-graph-operators li[data-status="completed"] .maka-agent-graph-status-dot { @@ -223,3 +244,9 @@ display: none; } } + +@media (prefers-reduced-motion: reduce) { + .maka-agent-graph-operators .maka-agent-graph-status-dot { + animation: none; + } +} diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths index e256ca9acb..d72ff00dfa 100644 --- a/docs/astryx-surface-file-inventory.paths +++ b/docs/astryx-surface-file-inventory.paths @@ -180,6 +180,7 @@ apps/desktop/src/renderer/styles/workbar/terminal.css apps/desktop/src/renderer/styles/workhub.css apps/desktop/src/renderer/work-board-panel.tsx apps/desktop/src/renderer/workhub-surface.tsx +packages/ui/src/agent-graph-live-status.tsx packages/ui/src/astryx-chat-reasoning.tsx packages/ui/src/astryx-i18n.tsx packages/ui/src/attachment-image.tsx diff --git a/packages/ui/src/__tests__/agent-graph-live-status.test.tsx b/packages/ui/src/__tests__/agent-graph-live-status.test.tsx new file mode 100644 index 0000000000..150688a5f7 --- /dev/null +++ b/packages/ui/src/__tests__/agent-graph-live-status.test.tsx @@ -0,0 +1,145 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { afterEach, mock, test } from 'node:test'; +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { parseHTML } from 'linkedom'; +import { AgentGraphLiveStatus } from '../agent-graph-live-status.js'; + +const originalGlobals = { + document: globalThis.document, + matchMedia: globalThis.matchMedia, + requestAnimationFrame: globalThis.requestAnimationFrame, + cancelAnimationFrame: globalThis.cancelAnimationFrame, + window: globalThis.window, +}; +const originalActEnvironment = (globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; +}).IS_REACT_ACT_ENVIRONMENT; +const mountedRoots: ReturnType[] = []; + +afterEach(async () => { + mock.timers.reset(); + for (const root of mountedRoots.splice(0)) await act(() => root.unmount()); + Object.assign(globalThis, { + ...originalGlobals, + IS_REACT_ACT_ENVIRONMENT: originalActEnvironment, + }); +}); + +function domRoot() { + const { document, window } = parseHTML('
'); + Object.assign(globalThis, { + document, + window, + matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }), + requestAnimationFrame: () => 1, + cancelAnimationFrame() {}, + IS_REACT_ACT_ENVIRONMENT: true, + }); + const container = document.querySelector('#root'); + assert.ok(container); + const root = createRoot(container); + mountedRoots.push(root); + return { container, root }; +} + +type LiveStatusInput = { + readonly live: boolean; + readonly resetKey: string; + readonly label: string; +}; + +async function renderLiveStatus(input: LiveStatusInput, children = 'running · 3/7 settled') { + const { container, root } = domRoot(); + await act(async () => { + root.render( + + {children} + , + ); + }); + return { container, root }; +} + +function rerenderLiveStatus( + root: ReturnType, + input: LiveStatusInput, + children = 'running · 3/7 settled', +) { + return act(async () => { + root.render( + + {children} + , + ); + }); +} + +test('idle graphs render only their own status content', async () => { + mock.timers.enable({ apis: ['Date', 'setInterval'] }); + const { container } = await renderLiveStatus({ live: false, resetKey: 'g1', label: 'running' }); + assert.equal(container.querySelector('.maka-agent-graph-heartbeat'), null); + assert.equal(container.querySelector('.maka-agent-graph-elapsed'), null); + assert.match(container.textContent ?? '', /running · 3\/7 settled/u); +}); + +test('live graphs gain a labelled heartbeat and a ticking stopwatch', async () => { + mock.timers.enable({ apis: ['Date', 'setInterval'] }); + const { container, root } = await renderLiveStatus({ live: true, resetKey: 'g1', label: 'running' }); + const heartbeat = container.querySelector('.maka-agent-graph-heartbeat'); + assert.ok(heartbeat); + assert.equal(heartbeat.getAttribute('aria-label'), 'running'); + assert.match(container.querySelector('.maka-agent-graph-elapsed')?.textContent ?? '', /00:00/u); + + await act(async () => { + mock.timers.tick(2500); + }); + assert.match(container.querySelector('.maka-agent-graph-elapsed')?.textContent ?? '', /00:02/u); + await rerenderLiveStatus(root, { live: true, resetKey: 'g1', label: 'running' }); + assert.match(container.querySelector('.maka-agent-graph-elapsed')?.textContent ?? '', /00:02/u); +}); + +test('the stopwatch resets when the selection moves to another graph', async () => { + mock.timers.enable({ apis: ['Date', 'setInterval'] }); + const { container, root } = await renderLiveStatus({ live: true, resetKey: 'g1', label: 'running' }); + await act(async () => { + mock.timers.tick(6500); + }); + assert.match(container.querySelector('.maka-agent-graph-elapsed')?.textContent ?? '', /00:06/u); + + await rerenderLiveStatus(root, { live: true, resetKey: 'g2', label: 'waiting' }); + assert.match(container.querySelector('.maka-agent-graph-elapsed')?.textContent ?? '', /00:00/u); + const heartbeat = container.querySelector('.maka-agent-graph-heartbeat'); + assert.equal(heartbeat?.getAttribute('aria-label'), 'waiting'); +}); + +test('going idle retires the heartbeat and the clock but keeps the status content', async () => { + mock.timers.enable({ apis: ['Date', 'setInterval'] }); + const { container, root } = await renderLiveStatus({ live: true, resetKey: 'g1', label: 'running' }); + await act(async () => { + mock.timers.tick(1000); + }); + await rerenderLiveStatus(root, { live: false, resetKey: 'g1', label: 'completed' }); + assert.equal(container.querySelector('.maka-agent-graph-heartbeat'), null); + assert.equal(container.querySelector('.maka-agent-graph-elapsed'), null); + assert.match(container.textContent ?? '', /running · 3\/7 settled/u); +}); diff --git a/packages/ui/src/agent-graph-live-status.tsx b/packages/ui/src/agent-graph-live-status.tsx new file mode 100644 index 0000000000..fc0fbb9158 --- /dev/null +++ b/packages/ui/src/agent-graph-live-status.tsx @@ -0,0 +1,85 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useEffect, useRef, useState, type JSX, type ReactNode } from 'react'; +import { Spinner } from '@astryxdesign/core/Spinner'; + +/** `mm:ss`, or `h:mm:ss` once an hour is on the clock. Locale-neutral digits. */ +function formatElapsed(totalSeconds: number): string { + const seconds = totalSeconds % 60; + const minutes = Math.floor(totalSeconds / 60) % 60; + const hours = Math.floor(totalSeconds / 3600); + const mm = String(minutes).padStart(2, '0'); + const ss = String(seconds).padStart(2, '0'); + return hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}`; +} + +/** + * Live badge for a long-running graph view: a heartbeat spinner plus an + * elapsed stopwatch wrapped around the row's own status content. The stopwatch + * measures this view's observation of the live graph — the carried snapshot + * has no start timestamp — and resets whenever `resetKey` changes or `live` + * drops, so a stale clock never survives a selection change. + */ +export function AgentGraphLiveStatus(props: { + readonly live: boolean; + readonly resetKey: string; + readonly label: string; + readonly children?: ReactNode; +}): JSX.Element { + const [elapsedSeconds, setElapsedSeconds] = useState(); + const liveSinceRef = useRef<{ resetKey: string; at: number } | undefined>(undefined); + + useEffect(() => { + if (!props.live) { + liveSinceRef.current = undefined; + setElapsedSeconds(undefined); + return; + } + if (liveSinceRef.current?.resetKey !== props.resetKey) { + liveSinceRef.current = { resetKey: props.resetKey, at: Date.now() }; + } + const startedAt = liveSinceRef.current.at; + setElapsedSeconds(0); + const timer = setInterval(() => { + setElapsedSeconds(Math.floor((Date.now() - startedAt) / 1000)); + }, 1000); + return () => clearInterval(timer); + }, [props.live, props.resetKey]); + + return ( + <> + {props.live ? ( + + ) : null} + {props.children} + {props.live && elapsedSeconds !== undefined ? ( + + ) : null} + + ); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 2ebac40575..a359a8af00 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -66,6 +66,7 @@ export * from './tool-output-stream.js'; export * from './ui.js'; export * from './utils.js'; export * from './platform-shortcut-text.js'; +export * from './agent-graph-live-status.js'; // Maka-owned product assets and compositions remain public only where they do // not duplicate a published Astryx component authority. From 2307ba18e12a2ceaa64e2a4496ddb94ed8f2a653 Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Thu, 3 Sep 2026 05:45:57 +0800 Subject: [PATCH 2/5] fix(desktop): address review on the Agent Graph liveness change Per review by Astro-Han on #4514: - The stopwatch now runs from the selected epoch's `createdAt` instead of the mount time, so the header reports how long the graph has run, not how long this view has been watching it. `AgentGraphLiveStatus` takes `startedAt` and keeps only the 1 Hz ticker; `liveSinceRef`, `resetKey`, and the reset branch are gone. - `GRAPH_LIVE_STATUSES` moves to `agent-graph-panel-visibility.ts` as `isAgentGraphLive`, shared by the heartbeat gate and `stopAvailable` so "can stop" and "shows as running" cannot drift; covered in the visibility suite (14 tests). - The heartbeat spinner is `aria-hidden` (it duplicated the adjacent visible status text on a `role="status"` element), the progress line is an inline flex with `gap` so the spinner has real spacing, the dead `flex: 0 0 auto` rule is removed, and the per-file reduced-motion block is dropped in favour of the global rule in `base.css` which also covers the e2e reduced-motion fixture path. Verified: ui 320/320 (5 live-status tests updated for the `startedAt` shape), desktop typecheck 0 errors, renderer architecture check green against the merge base, surface inventory ok, format and ASF headers clean. Generated-by: GLM-5.3-Flash (ZCode) --- .../renderer/agent-graph-panel-visibility.ts | 11 ++++ .../src/renderer/agent-graph-panel.tsx | 18 ++----- .../src/renderer/styles/agent-graph.css | 14 +++--- .../agent-graph-live-status.test.tsx | 50 +++++++++++-------- packages/ui/src/agent-graph-live-status.tsx | 45 +++++++---------- 5 files changed, 68 insertions(+), 70 deletions(-) diff --git a/apps/desktop/src/renderer/agent-graph-panel-visibility.ts b/apps/desktop/src/renderer/agent-graph-panel-visibility.ts index dca3b68b18..c80584c94b 100644 --- a/apps/desktop/src/renderer/agent-graph-panel-visibility.ts +++ b/apps/desktop/src/renderer/agent-graph-panel-visibility.ts @@ -40,6 +40,17 @@ export function isAgentGraphPanelDismissible( return status !== undefined && DISMISSIBLE_STATUSES.has(status); } +const LIVE_STATUSES = new Set([ + 'active', + 'waiting', + 'closing', +]); + +/** A graph in one of these statuses can be stopped and must keep signaling liveness. */ +export function isAgentGraphLive(status: AgentGraphPanelStatus | undefined): boolean { + return status !== undefined && LIVE_STATUSES.has(status); +} + export function dismissAgentGraphPanel( dismissedBySession: AgentGraphPanelDismissals, sessionId: string, diff --git a/apps/desktop/src/renderer/agent-graph-panel.tsx b/apps/desktop/src/renderer/agent-graph-panel.tsx index abaa6cd115..7e00979fc7 100644 --- a/apps/desktop/src/renderer/agent-graph-panel.tsx +++ b/apps/desktop/src/renderer/agent-graph-panel.tsx @@ -33,6 +33,7 @@ import { EmptyState } from '@astryxdesign/core/EmptyState'; import { Spinner } from '@astryxdesign/core/Spinner'; import { dismissAgentGraphPanel, + isAgentGraphLive, isAgentGraphPanelDismissible, reconcileAgentGraphPanelDismissals, shouldShowAgentGraphPanel, @@ -50,13 +51,6 @@ const noopAgentGraphRefreshScheduler: AgentGraphRefreshScheduler = { dispose() {}, }; -/** Snapshot statuses during which the panel must keep signaling liveness. */ -const GRAPH_LIVE_STATUSES: ReadonlySet = new Set([ - 'active', - 'waiting', - 'closing', -]); - type GraphPanelCopy = { title: string; loading: string; @@ -211,7 +205,7 @@ export function AgentGraphPanel(props: { stopState.rootSessionId === props.rootSessionId && stopState.graphId === selectedGraphId; const stopPending = stopFeedbackMatchesSelection && stopState.pending; const stopError = stopFeedbackMatchesSelection && stopState.error; - const graphLive = snapshot !== undefined && GRAPH_LIVE_STATUSES.has(snapshot.status); + const graphLive = snapshot !== undefined && isAgentGraphLive(snapshot.status); useEffect(() => { setSnapshot(undefined); @@ -358,7 +352,7 @@ export function AgentGraphPanel(props: { !loading && snapshot !== undefined && snapshot.graphId === selectedGraphId && - ['active', 'waiting', 'closing'].includes(snapshot.status); + isAgentGraphLive(snapshot.status); const dismissAvailable = selectedEpoch?.current === true && !loading && @@ -401,11 +395,7 @@ export function AgentGraphPanel(props: { ) : null} {snapshot ? ( - + {copy.status(snapshot.status)} ·{' '} {copy.progress( progress.settled, diff --git a/apps/desktop/src/renderer/styles/agent-graph.css b/apps/desktop/src/renderer/styles/agent-graph.css index d71d3b07f7..ceb42589bb 100644 --- a/apps/desktop/src/renderer/styles/agent-graph.css +++ b/apps/desktop/src/renderer/styles/agent-graph.css @@ -102,8 +102,12 @@ color: var(--muted-foreground); } -.maka-agent-graph-heartbeat { - flex: 0 0 auto; +/* Inline flex so the heartbeat spinner stays on the status line and spacing is + gap-driven; the element also holds plain text nodes. */ +.maka-agent-graph-progress { + display: inline-flex; + align-items: center; + gap: var(--space-1); } .maka-agent-graph-elapsed { @@ -244,9 +248,3 @@ display: none; } } - -@media (prefers-reduced-motion: reduce) { - .maka-agent-graph-operators .maka-agent-graph-status-dot { - animation: none; - } -} diff --git a/packages/ui/src/__tests__/agent-graph-live-status.test.tsx b/packages/ui/src/__tests__/agent-graph-live-status.test.tsx index 150688a5f7..fb189f348f 100644 --- a/packages/ui/src/__tests__/agent-graph-live-status.test.tsx +++ b/packages/ui/src/__tests__/agent-graph-live-status.test.tsx @@ -64,15 +64,14 @@ function domRoot() { type LiveStatusInput = { readonly live: boolean; - readonly resetKey: string; - readonly label: string; + readonly startedAt?: number; }; async function renderLiveStatus(input: LiveStatusInput, children = 'running · 3/7 settled') { const { container, root } = domRoot(); await act(async () => { root.render( - + {children} , ); @@ -87,7 +86,7 @@ function rerenderLiveStatus( ) { return act(async () => { root.render( - + {children} , ); @@ -96,50 +95,61 @@ function rerenderLiveStatus( test('idle graphs render only their own status content', async () => { mock.timers.enable({ apis: ['Date', 'setInterval'] }); - const { container } = await renderLiveStatus({ live: false, resetKey: 'g1', label: 'running' }); + const { container } = await renderLiveStatus({ live: false, startedAt: Date.now() - 60_000 }); assert.equal(container.querySelector('.maka-agent-graph-heartbeat'), null); assert.equal(container.querySelector('.maka-agent-graph-elapsed'), null); assert.match(container.textContent ?? '', /running · 3\/7 settled/u); }); -test('live graphs gain a labelled heartbeat and a ticking stopwatch', async () => { +test('live graphs gain a decorative heartbeat and a stopwatch running from startedAt', async () => { mock.timers.enable({ apis: ['Date', 'setInterval'] }); - const { container, root } = await renderLiveStatus({ live: true, resetKey: 'g1', label: 'running' }); + const startedAt = Date.now() - 2_000; + const { container } = await renderLiveStatus({ live: true, startedAt }); const heartbeat = container.querySelector('.maka-agent-graph-heartbeat'); assert.ok(heartbeat); - assert.equal(heartbeat.getAttribute('aria-label'), 'running'); - assert.match(container.querySelector('.maka-agent-graph-elapsed')?.textContent ?? '', /00:00/u); + assert.equal(heartbeat.getAttribute('aria-hidden'), 'true'); + assert.match( + container.querySelector('.maka-agent-graph-elapsed')?.textContent ?? '', + /00:02/u, + ); await act(async () => { mock.timers.tick(2500); }); - assert.match(container.querySelector('.maka-agent-graph-elapsed')?.textContent ?? '', /00:02/u); - await rerenderLiveStatus(root, { live: true, resetKey: 'g1', label: 'running' }); - assert.match(container.querySelector('.maka-agent-graph-elapsed')?.textContent ?? '', /00:02/u); + assert.match(container.querySelector('.maka-agent-graph-elapsed')?.textContent ?? '', /00:04/u); }); -test('the stopwatch resets when the selection moves to another graph', async () => { +test('re-rendering the same live graph keeps the clock running from startedAt', async () => { mock.timers.enable({ apis: ['Date', 'setInterval'] }); - const { container, root } = await renderLiveStatus({ live: true, resetKey: 'g1', label: 'running' }); + const startedAt = Date.now(); + const { container, root } = await renderLiveStatus({ live: true, startedAt }); await act(async () => { mock.timers.tick(6500); }); assert.match(container.querySelector('.maka-agent-graph-elapsed')?.textContent ?? '', /00:06/u); - await rerenderLiveStatus(root, { live: true, resetKey: 'g2', label: 'waiting' }); - assert.match(container.querySelector('.maka-agent-graph-elapsed')?.textContent ?? '', /00:00/u); - const heartbeat = container.querySelector('.maka-agent-graph-heartbeat'); - assert.equal(heartbeat?.getAttribute('aria-label'), 'waiting'); + await rerenderLiveStatus(root, { live: true, startedAt }); + assert.match(container.querySelector('.maka-agent-graph-elapsed')?.textContent ?? '', /00:06/u); }); test('going idle retires the heartbeat and the clock but keeps the status content', async () => { mock.timers.enable({ apis: ['Date', 'setInterval'] }); - const { container, root } = await renderLiveStatus({ live: true, resetKey: 'g1', label: 'running' }); + const { container, root } = await renderLiveStatus({ live: true, startedAt: Date.now() }); await act(async () => { mock.timers.tick(1000); }); - await rerenderLiveStatus(root, { live: false, resetKey: 'g1', label: 'completed' }); + await rerenderLiveStatus(root, { live: false, startedAt: Date.now() }); assert.equal(container.querySelector('.maka-agent-graph-heartbeat'), null); assert.equal(container.querySelector('.maka-agent-graph-elapsed'), null); assert.match(container.textContent ?? '', /running · 3\/7 settled/u); }); + +test('a clock skew that puts the start in the future clamps to zero', async () => { + mock.timers.enable({ apis: ['Date', 'setInterval'] }); + const startedAt = Date.now() + 30_000; + const { container } = await renderLiveStatus({ live: true, startedAt }); + assert.match( + container.querySelector('.maka-agent-graph-elapsed')?.textContent ?? '', + /00:00/u, + ); +}); diff --git a/packages/ui/src/agent-graph-live-status.tsx b/packages/ui/src/agent-graph-live-status.tsx index fc0fbb9158..b5785d37d4 100644 --- a/packages/ui/src/agent-graph-live-status.tsx +++ b/packages/ui/src/agent-graph-live-status.tsx @@ -17,7 +17,7 @@ * under the License. */ -import { useEffect, useRef, useState, type JSX, type ReactNode } from 'react'; +import { useEffect, useState, type JSX, type ReactNode } from 'react'; import { Spinner } from '@astryxdesign/core/Spinner'; /** `mm:ss`, or `h:mm:ss` once an hour is on the clock. Locale-neutral digits. */ @@ -32,49 +32,38 @@ function formatElapsed(totalSeconds: number): string { /** * Live badge for a long-running graph view: a heartbeat spinner plus an - * elapsed stopwatch wrapped around the row's own status content. The stopwatch - * measures this view's observation of the live graph — the carried snapshot - * has no start timestamp — and resets whenever `resetKey` changes or `live` - * drops, so a stale clock never survives a selection change. + * elapsed stopwatch measured from the graph epoch's `createdAt`, wrapped + * around the row's own status content. Decorative throughout — the status + * text beside it carries the announcement, so nothing here is labelled. */ export function AgentGraphLiveStatus(props: { readonly live: boolean; - readonly resetKey: string; - readonly label: string; + readonly startedAt?: number; readonly children?: ReactNode; }): JSX.Element { - const [elapsedSeconds, setElapsedSeconds] = useState(); - const liveSinceRef = useRef<{ resetKey: string; at: number } | undefined>(undefined); - + const [now, setNow] = useState(); useEffect(() => { if (!props.live) { - liveSinceRef.current = undefined; - setElapsedSeconds(undefined); + setNow(undefined); return; } - if (liveSinceRef.current?.resetKey !== props.resetKey) { - liveSinceRef.current = { resetKey: props.resetKey, at: Date.now() }; - } - const startedAt = liveSinceRef.current.at; - setElapsedSeconds(0); - const timer = setInterval(() => { - setElapsedSeconds(Math.floor((Date.now() - startedAt) / 1000)); - }, 1000); + setNow(Date.now()); + const timer = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(timer); - }, [props.live, props.resetKey]); + }, [props.live]); + + const elapsedSeconds = + props.startedAt === undefined || now === undefined + ? undefined + : Math.max(0, Math.floor((now - props.startedAt) / 1000)); return ( <> {props.live ? ( - +