From 2a7e7b6b5922312d0617a5506e1becc764cbe191 Mon Sep 17 00:00:00 2001 From: NekoPunch Date: Sat, 5 Sep 2026 01:05:09 -0700 Subject: [PATCH] feat(desktop): visualize agent graph topology Render the agent graph as a topology view with operator inspection, isolated behind a feature seam with its own services adapter. Secondary metadata text uses the ink ladder's --muted-foreground, since --foreground-secondary is retired. Generated-by: Claude Code Claude-Session: https://claude.ai/code/session_01AqdSkg56F2x55wEGRWvzcB --- apps/desktop/e2e/agent-graph-layout.spec.ts | 1 + apps/desktop/e2e/agent-graph.spec.ts | 180 +++ apps/desktop/e2e/fixtures.ts | 10 + apps/desktop/renderer-architecture.json | 56 +- .../__tests__/agent-graph-panel-copy.test.ts | 2 +- .../agent-graph-panel-visibility.test.ts | 2 +- .../main/__tests__/agent-graph-panel.test.ts | 447 ++++++- .../__tests__/agent-graph-refresh.test.ts | 2 +- .../agent-graph-services-adapter.test.ts | 74 + .../__tests__/agent-graph-topology.test.ts | 231 ++++ apps/desktop/src/main/e2e-fixture.ts | 16 +- .../main/e2e-fixture/scenarios-agent-graph.ts | 280 ++++ .../src/renderer/agent-graph-panel.tsx | 629 --------- apps/desktop/src/renderer/app-shell.tsx | 2 +- .../composition/desktop-feature-services.tsx | 5 + .../controller}/agent-graph-refresh.ts | 0 .../renderer/features/agent-graph/index.ts | 22 + .../model}/agent-graph-panel-visibility.ts | 0 .../renderer/features/agent-graph/ports.ts | 50 + .../features/agent-graph/services-context.tsx | 40 + .../renderer/features/agent-graph/stories.ts | 22 + .../renderer/features/agent-graph/testing.ts | 37 + .../agent-graph/ui/agent-graph-panel.tsx | 1190 +++++++++++++++++ .../agent-graph/ui/agent-graph-topology.tsx | 333 +++++ .../desktop/create-agent-graph-services.ts | 40 + .../src/renderer/styles/agent-graph.css | 205 ++- .../stories/agent-graph-panel.stories.tsx | 63 +- .../agent-graph-stream-scheduling-draft.md | 2 +- ...ent-graph-stream-scheduling-draft.zh-CN.md | 2 +- docs/astryx-surface-file-inventory.md | 10 +- docs/astryx-surface-file-inventory.paths | 4 +- docs/images/pr/agent-graph-topology-after.png | Bin 0 -> 26020 bytes .../images/pr/agent-graph-topology-before.png | Bin 0 -> 28832 bytes package.json | 2 +- packages/core/src/e2e-fixture.ts | 1 + scripts/generate-astryx-surface-inventory.mjs | 36 +- ...generate-astryx-surface-inventory.test.mjs | 39 + 37 files changed, 3250 insertions(+), 785 deletions(-) create mode 100644 apps/desktop/e2e/agent-graph.spec.ts create mode 100644 apps/desktop/src/main/__tests__/agent-graph-services-adapter.test.ts create mode 100644 apps/desktop/src/main/__tests__/agent-graph-topology.test.ts create mode 100644 apps/desktop/src/main/e2e-fixture/scenarios-agent-graph.ts delete mode 100644 apps/desktop/src/renderer/agent-graph-panel.tsx rename apps/desktop/src/renderer/{ => features/agent-graph/controller}/agent-graph-refresh.ts (100%) create mode 100644 apps/desktop/src/renderer/features/agent-graph/index.ts rename apps/desktop/src/renderer/{ => features/agent-graph/model}/agent-graph-panel-visibility.ts (100%) create mode 100644 apps/desktop/src/renderer/features/agent-graph/ports.ts create mode 100644 apps/desktop/src/renderer/features/agent-graph/services-context.tsx create mode 100644 apps/desktop/src/renderer/features/agent-graph/stories.ts create mode 100644 apps/desktop/src/renderer/features/agent-graph/testing.ts create mode 100644 apps/desktop/src/renderer/features/agent-graph/ui/agent-graph-panel.tsx create mode 100644 apps/desktop/src/renderer/features/agent-graph/ui/agent-graph-topology.tsx create mode 100644 apps/desktop/src/renderer/platform/desktop/create-agent-graph-services.ts create mode 100644 docs/images/pr/agent-graph-topology-after.png create mode 100644 docs/images/pr/agent-graph-topology-before.png diff --git a/apps/desktop/e2e/agent-graph-layout.spec.ts b/apps/desktop/e2e/agent-graph-layout.spec.ts index 8739520196..3d478d70c0 100644 --- a/apps/desktop/e2e/agent-graph-layout.spec.ts +++ b/apps/desktop/e2e/agent-graph-layout.spec.ts @@ -22,6 +22,7 @@ import { expect, test } from './fixtures'; test('production AgentGraphPanel keeps its heading visible without covering scrolled content', async ({ agentGraphWindow: page }) => { const panel = page.getByRole('region', { name: 'Agent Graph', exact: true }); await expect(panel).toBeVisible(); + await panel.getByRole('radio', { name: '列表' }).click(); await expect(panel.locator('.maka-agent-graph-operators > li')).toHaveCount(24); const content = panel.locator('.maka-agent-graph-content'); const before = await panel.evaluate((element) => { diff --git a/apps/desktop/e2e/agent-graph.spec.ts b/apps/desktop/e2e/agent-graph.spec.ts new file mode 100644 index 0000000000..dd6ae2ad2c --- /dev/null +++ b/apps/desktop/e2e/agent-graph.spec.ts @@ -0,0 +1,180 @@ +/* + * 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 type { Locator } from '@playwright/test'; +import { expect, test } from './fixtures'; + +async function expectInsideViewport(target: Locator, viewport: Locator): Promise { + await expect + .poll(async () => { + const targetBox = await target.evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom }; + }); + const viewportBox = await viewport.evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { + left: rect.left + element.clientLeft, + top: rect.top + element.clientTop, + right: rect.left + element.clientLeft + element.clientWidth, + bottom: rect.top + element.clientTop + element.clientHeight, + }; + }); + const tolerance = 1; + return Boolean( + targetBox.left >= viewportBox.left - tolerance && + targetBox.top >= viewportBox.top - tolerance && + targetBox.right <= viewportBox.right + tolerance && + targetBox.bottom <= viewportBox.bottom + tolerance, + ); + }) + .toBe(true); +} + +function metadataValue(scope: Locator, label: string): Locator { + return scope + .locator('dt') + .filter({ hasText: label }) + .locator('xpath=following-sibling::dd[1]'); +} + +async function expectMetadata( + scope: Locator, + label: string, + value: string | RegExp, +): Promise { + await expect(metadataValue(scope, label)).toHaveText(value); +} + +test('inspects and follows an operator across graph views', async ({ + agentGraphTopologyWindow: page, +}) => { + const panel = page.getByRole('region', { name: 'Agent Graph', exact: true }); + const topology = page.getByTestId('agent-graph-topology'); + + await expect + .poll(() => + topology.evaluate((element) => ({ + horizontal: element.scrollWidth > element.clientWidth, + vertical: element.scrollHeight > element.clientHeight, + })), + ) + .toEqual({ horizontal: true, vertical: true }); + + const initialPublisherNode = topology.getByRole('button', { name: /^publisher\./u }); + await initialPublisherNode.click(); + await expect(initialPublisherNode).toBeFocused(); + await expectInsideViewport(initialPublisherNode, topology); + await expectInsideViewport(initialPublisherNode, panel); + await expect(page.getByRole('region', { name: 'Operator details: publisher' })).toBeAttached(); + await initialPublisherNode.click(); + + await panel.getByRole('radio', { name: 'List' }).click(); + const publisherRow = panel + .getByTestId('agent-graph-list') + .locator(':scope > li') + .filter({ has: page.getByText('publisher', { exact: true }) }); + await expect(publisherRow.getByText('Completed', { exact: true })).toBeVisible(); + await expect(publisherRow).toContainText('1 more work item omitted'); + + const detailsButton = publisherRow.getByRole('button', { + name: 'View publisher details', + }); + await detailsButton.click(); + await expect(detailsButton).toBeFocused(); + await expect(detailsButton).toHaveAttribute('aria-expanded', 'true'); + const details = page.getByRole('region', { name: 'Operator details: publisher' }); + await expect(details).toHaveAttribute('aria-busy', 'false'); + const collection = (name: string) => + details + .locator('.maka-agent-graph-details-collection') + .filter({ has: page.getByText(name, { exact: true }) }); + const publisherSessionId = /^\["[a-f0-9]{64}","child-publisher"\]$/u; + const activations = collection('Activations'); + const activation = activations.locator('li'); + await expect( + metadataValue(activation, 'firstEventTime').locator( + 'time[datetime="2026-05-22T02:59:57.000Z"]', + ), + ).toBeVisible(); + await expect( + metadataValue(activation, 'lastEventTime').locator( + 'time[datetime="2026-05-22T02:59:59.000Z"]', + ), + ).toBeVisible(); + await expectMetadata(activation, 'lastRecordId', 'record-publisher-terminal'); + await expectMetadata(activation, 'terminalRecordId', 'record-publisher-terminal'); + await expectMetadata(activation, 'run.sessionId', publisherSessionId); + await expectMetadata(activation, 'run.agentRunId', 'run-publisher'); + await expectMetadata(activation, 'run.turnId', 'turn-publisher'); + + const claims = collection('Claims'); + const claim = claims.locator('li').filter({ hasText: 'claim-publisher' }); + await expectMetadata(claim, 'intentId', 'intent-publisher'); + await expectMetadata(claim, 'childSessionId', publisherSessionId); + await expect( + metadataValue(claim, 'claimedAt').locator('time[datetime="2026-05-22T02:59:56.000Z"]'), + ).toBeVisible(); + await expectMetadata(claim, 'run.sessionId', publisherSessionId); + await expectMetadata(claim, 'run.agentRunId', 'run-publisher'); + await expectMetadata(claim, 'run.turnId', 'turn-publisher'); + + const activity = collection('Recent activity'); + const permissionRecord = activity.locator('li').filter({ hasText: 'record-publisher-permission' }); + await expectMetadata(permissionRecord, 'activationId', 'activation-publisher'); + await expectMetadata(permissionRecord, 'signals', 'attention: permission request'); + await expect( + metadataValue(permissionRecord, 'eventTime').locator( + 'time[datetime="2026-05-22T02:59:57.000Z"]', + ), + ).toBeVisible(); + await expectMetadata(permissionRecord, 'run.sessionId', publisherSessionId); + await expectMetadata(permissionRecord, 'run.agentRunId', 'run-publisher'); + await expectMetadata(permissionRecord, 'run.turnId', 'turn-publisher'); + const terminalRecord = activity.locator('li').filter({ hasText: 'record-publisher-terminal' }); + await expectMetadata(terminalRecord, 'activationId', 'activation-publisher'); + await expectMetadata(terminalRecord, 'signals', 'terminal: completed'); + await expect( + metadataValue(terminalRecord, 'eventTime').locator( + 'time[datetime="2026-05-22T02:59:59.000Z"]', + ), + ).toBeVisible(); + await expectMetadata(terminalRecord, 'run.sessionId', publisherSessionId); + await expectMetadata(terminalRecord, 'run.agentRunId', 'run-publisher'); + await expectMetadata(terminalRecord, 'run.turnId', 'turn-publisher'); + await expectInsideViewport(detailsButton, panel); + await expectInsideViewport(details.locator('.maka-agent-graph-details-heading'), panel); + + await panel.getByRole('radio', { name: 'Topology' }).click(); + const selectedNode = topology.locator('.maka-agent-graph-node[data-selected="true"]'); + await expect(selectedNode).toContainText('publisher'); + await expect(selectedNode).toContainText('1 more work item omitted'); + await expectInsideViewport(selectedNode, topology); + await expectInsideViewport(selectedNode, panel); + await expect.poll(() => topology.evaluate((element) => element.scrollLeft)).toBeGreaterThan(0); + + await panel.getByRole('button', { name: 'Collapse Agent Graph' }).click(); + await panel.getByRole('button', { name: 'Expand Agent Graph' }).click(); + await expectInsideViewport(selectedNode, topology); + await expectInsideViewport(selectedNode, panel); + + await panel.getByRole('radio', { name: 'List' }).click(); + await expectInsideViewport(detailsButton, panel); + await expectInsideViewport(details.locator('.maka-agent-graph-details-heading'), panel); +}); diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index c62b9d4ae0..245d59de32 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -570,6 +570,7 @@ type E2eTestFixtures = { projectSidebarWindow: Page; parentRemovalWindow: Page; railRenderWindow: Page; + agentGraphTopologyWindow: Page; promptRailWindow: Page; threadSearchWindow: Page; partialHistoryWindow: Page; @@ -720,6 +721,15 @@ export const test = base.extend({ use, ); }, + agentGraphTopologyWindow: async ({}, use) => { + await withE2eWindow({ + seed: false, + readinessSelector: '[data-testid="agent-graph-topology"]', + e2eFixtureScenario: 'agent-graph-topology', + locale: 'en', + showWindow: true, + }, use); + }, // Keep this scenario's real Electron + Host composition warm for the worker, // while the test-scoped wrapper below restores Host and renderer state // between tests. Tests on it may run a Turn, so the reset is not read-only. diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index c4724663db..0a780a3417 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -1,9 +1,6 @@ { "version": 1, "legacyRendererFiles": [ - "src/renderer/agent-graph-panel-visibility.ts", - "src/renderer/agent-graph-panel.tsx", - "src/renderer/agent-graph-refresh.ts", "src/renderer/app-shell-app-update.ts", "src/renderer/app-shell-chat-actions.ts", "src/renderer/app-shell-chrome-actions.tsx", @@ -713,7 +710,7 @@ "nonTriviaTokens": 1408 }, "src/renderer/app-shell.tsx": { - "importDeclarations": 81, + "importDeclarations": 80, "bridgePaths": { "window.maka.app.installUpdate": 1, "window.maka.app.retryUpdateDownload": 1, @@ -803,7 +800,6 @@ "actionFactories": [], "dependencyPaths": { "../preload/transcript-contract.js": 1, - "./agent-graph-panel": 1, "./app-shell-app-update": 1, "./app-shell-chat-actions": 1, "./app-shell-chrome-actions": 1, @@ -830,6 +826,7 @@ "./desktop-execution-boundary-surface": 1, "./desktop-slash-command": 1, "./error-boundary": 1, + "./features/agent-graph": 1, "./features/conversation": 1, "./features/goals": 1, "./features/module-hub": 1, @@ -895,7 +892,7 @@ "@maka/ui/icons": 1, "react": 1 }, - "importSpecifiers": 124, + "importSpecifiers": 123, "nonTriviaTokens": 15588 }, "src/renderer/use-app-shell-composer-quotes.ts": { @@ -1038,53 +1035,6 @@ "actionFactories": [], "dependencyPaths": {} }, - "src/renderer/agent-graph-panel-visibility.ts": { - "bridgePaths": {}, - "environmentCapabilities": {}, - "hookCalls": {}, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": {} - }, - "src/renderer/agent-graph-panel.tsx": { - "bridgePaths": { - "window.maka.graphs.getSnapshot": 1, - "window.maka.graphs.listCurrentEpochs": 1, - "window.maka.graphs.listEpochs": 2, - "window.maka.graphs.stop": 1, - "window.maka.graphs.subscribe": 1 - }, - "environmentCapabilities": {}, - "hookCalls": { - "useEffect": 2, - "useRef": 4, - "useState": 9 - }, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": { - "./agent-graph-panel-visibility.js": 1, - "./agent-graph-refresh.js": 1, - "@astryxdesign/core/Banner": 1, - "@astryxdesign/core/Button": 1, - "@astryxdesign/core/EmptyState": 1, - "@astryxdesign/core/Spinner": 1, - "@maka/ui": 1, - "@maka/ui/icons": 1, - "react": 1 - } - }, - "src/renderer/agent-graph-refresh.ts": { - "bridgePaths": {}, - "environmentCapabilities": {}, - "hookCalls": {}, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": {} - }, "src/renderer/app-update-install.ts": { "bridgePaths": {}, "environmentCapabilities": {}, diff --git a/apps/desktop/src/main/__tests__/agent-graph-panel-copy.test.ts b/apps/desktop/src/main/__tests__/agent-graph-panel-copy.test.ts index 0ef254444c..faab9de4a2 100644 --- a/apps/desktop/src/main/__tests__/agent-graph-panel-copy.test.ts +++ b/apps/desktop/src/main/__tests__/agent-graph-panel-copy.test.ts @@ -19,7 +19,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { getAgentGraphPanelCopy } from '../../renderer/agent-graph-panel.js'; +import { getAgentGraphPanelCopy } from '../../renderer/features/agent-graph/testing.js'; test('Traditional Chinese Agent Graph copy does not use Simplified fallbacks', () => { const copy = getAgentGraphPanelCopy('zh-TW'); diff --git a/apps/desktop/src/main/__tests__/agent-graph-panel-visibility.test.ts b/apps/desktop/src/main/__tests__/agent-graph-panel-visibility.test.ts index 8a5e0bb041..7e7d31289e 100644 --- a/apps/desktop/src/main/__tests__/agent-graph-panel-visibility.test.ts +++ b/apps/desktop/src/main/__tests__/agent-graph-panel-visibility.test.ts @@ -24,7 +24,7 @@ import { isAgentGraphPanelDismissible, reconcileAgentGraphPanelDismissals, shouldShowAgentGraphPanel, -} from '../../renderer/agent-graph-panel-visibility.js'; +} from '../../renderer/features/agent-graph/testing.js'; describe('isAgentGraphPanelDismissible', () => { it('allows hiding a graph that no longer has active work', () => { diff --git a/apps/desktop/src/main/__tests__/agent-graph-panel.test.ts b/apps/desktop/src/main/__tests__/agent-graph-panel.test.ts index 6cbfb66ed8..f73c3a01cc 100644 --- a/apps/desktop/src/main/__tests__/agent-graph-panel.test.ts +++ b/apps/desktop/src/main/__tests__/agent-graph-panel.test.ts @@ -24,7 +24,11 @@ import { act, createElement } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import type { AgentGraphClientSnapshot } from '@maka/runtime/stream-graph-read-model'; import type { AgentGraphEpochSummary } from '@maka/runtime-host/protocol'; -import { AgentGraphPanel } from '../../renderer/agent-graph-panel.js'; +import { + AgentGraphPanel, + AgentGraphServicesProvider, + type AgentGraphServices, +} from '../../renderer/features/agent-graph/testing.js'; type GraphListener = () => void; @@ -112,10 +116,13 @@ function installGraphRenderer( renderSession(sessionId: string): Promise; holdNextEpochList(sessionId: string): DeferredRead; holdNextSnapshot(graphId: string): DeferredRead; + holdNextInspection(operatorId: string): DeferredRead; holdNextStop(sessionId: string): DeferredRead; setCurrentWithoutNotification(next: AgentGraphClientSnapshot): void; notify(): void; epochReadCounts(): { full: number; current: number }; + inspectionReadCount(operatorId: string): number; + scrollIntoViewTargets(): readonly string[]; stopCalls: Array<{ sessionId: string; expectedGraphId: string }>; } { const { document, window } = parseHTML('
'); @@ -140,6 +147,12 @@ function installGraphRenderer( cancelAnimationFrame: (handle: number) => clearTimeout(handle), IS_REACT_ACT_ENVIRONMENT: true, }); + const scrollIntoViewTargets: string[] = []; + window.HTMLElement.prototype.scrollIntoView = function () { + scrollIntoViewTargets.push( + `${this.getAttribute('class') ?? this.tagName}:${this.textContent ?? ''}`, + ); + }; const snapshots = new Map( [initial, ...historical].map((entry) => [entry.graphId, entry] as const), @@ -153,10 +166,12 @@ function installGraphRenderer( const listeners = new Set(); const epochListGates = new Map(); const snapshotGates = new Map(); + const inspectionGates = new Map(); const stopGates = new Map(); const stopCalls: Array<{ sessionId: string; expectedGraphId: string }> = []; let fullEpochReads = 0; let currentEpochReads = 0; + const inspectionReads = new Map(); const epochDirectory = (sessionId: string) => { const currentGraphId = currentGraphIds.get(sessionId); const entries = [...snapshots.values()] @@ -175,7 +190,7 @@ function installGraphRenderer( truncated: false, }; }; - (window as unknown as { maka: unknown }).maka = { + const services: AgentGraphServices = { graphs: { listEpochs: async (sessionId: string) => { fullEpochReads += 1; @@ -205,8 +220,51 @@ function installGraphRenderer( } return next; }, - inspectOperator: async () => { - throw new Error('inspectOperator is unused by AgentGraphPanel'); + inspectOperator: async (sessionId: string, operatorId: string, graphId?: string) => { + inspectionReads.set(operatorId, (inspectionReads.get(operatorId) ?? 0) + 1); + const gate = inspectionGates.get(operatorId); + if (gate) { + inspectionGates.delete(operatorId); + gate.markStarted(); + await gate.waitForRelease; + } + const snapshot = graphId ? snapshots.get(graphId) : undefined; + const operator = snapshot?.operators.find((candidate) => candidate.operatorId === operatorId); + if (!snapshot || snapshot.rootSessionId !== sessionId || !operator) { + throw new Error(`missing graph operator ${operatorId}`); + } + return { + schemaVersion: 1, + rootSessionId: sessionId, + graphId: snapshot.graphId, + snapshotVersion: snapshot.snapshotVersion, + operator, + inboundEdges: snapshot.edges.filter((edge) => edge.toOperatorId === operatorId), + outboundEdges: snapshot.edges.filter((edge) => edge.fromOperatorId === operatorId), + work: snapshot.work.filter((work) => operator.scheduledWorkIds.includes(work.workId)), + claims: snapshot.claims.filter((claim) => claim.operatorId === operatorId), + activations: operator.currentActivation + ? [ + { + ...operator.currentActivation, + lastRecordId: + operator.currentActivation.terminalRecordId ?? + `record-${operator.currentActivation.activationId}`, + }, + ] + : [], + recentRecords: snapshot.recentActivity.filter( + (record) => record.operatorId === operatorId, + ), + omitted: { + inboundEdges: operator.omitted.inboundEdgeIds, + outboundEdges: operator.omitted.outboundEdgeIds, + work: operator.omitted.scheduledWorkIds, + claims: 0, + activations: 0, + records: 0, + }, + }; }, subscribe: (_sessionId: string, listener: GraphListener) => { listeners.add(listener); @@ -229,6 +287,7 @@ function installGraphRenderer( }, }, }; + (window as unknown as { maka: unknown }).maka = services; const container = document.querySelector('#root'); assert.ok(container); @@ -257,15 +316,25 @@ function installGraphRenderer( epochReadCounts() { return { full: fullEpochReads, current: currentEpochReads }; }, + inspectionReadCount(operatorId) { + return inspectionReads.get(operatorId) ?? 0; + }, + scrollIntoViewTargets() { + return [...scrollIntoViewTargets]; + }, async renderSession(sessionId) { await act(async () => { root.render( - createElement(AgentGraphPanel, { - rootSessionId: sessionId, - enabled: true, - locale: 'en', - onOpenSession: () => undefined, - }), + createElement( + AgentGraphServicesProvider, + { services }, + createElement(AgentGraphPanel, { + rootSessionId: sessionId, + enabled: true, + locale: 'en', + onOpenSession: () => undefined, + }), + ), ); await Promise.resolve(); }); @@ -280,6 +349,11 @@ function installGraphRenderer( snapshotGates.set(graphId, gate); return gate; }, + holdNextInspection(operatorId) { + const gate = deferredReadGate(); + inspectionGates.set(operatorId, gate); + return gate; + }, holdNextStop(sessionId) { const gate = deferredReadGate(); stopGates.set(sessionId, gate); @@ -295,12 +369,16 @@ async function renderPanel( const harness = installGraphRenderer(initial); await act(async () => { harness.root.render( - createElement(AgentGraphPanel, { - rootSessionId: 'session-1', - enabled: true, - locale: 'en', - onOpenSession: () => undefined, - }), + createElement( + AgentGraphServicesProvider, + { services: (window as unknown as { maka: AgentGraphServices }).maka }, + createElement(AgentGraphPanel, { + rootSessionId: 'session-1', + enabled: true, + locale: 'en', + onOpenSession: () => undefined, + }), + ), ); await Promise.resolve(); }); @@ -308,6 +386,277 @@ async function renderPanel( } describe('AgentGraphPanel dismiss', () => { + it('renders dependency topology, inspects a node, and keeps the list alternative', async () => { + const graph = snapshot({ + graphId: 'graph-topology', + status: 'active', + operators: [ + { + ...graphOperator('a', ['work-a', 'work-snapshot-omitted']), + currentActivation: { + activationId: 'activation-a', + status: 'running' as const, + recordCount: 2, + firstEventTime: 1, + lastEventTime: 2, + run: { sessionId: 'session-a', agentRunId: 'run-a', turnId: 'turn-a' }, + }, + omitted: { + ...graphOperator('a', ['work-a', 'work-snapshot-omitted']).omitted, + scheduledWorkIds: 2, + }, + }, + { + ...graphOperator('b', ['work-b']), + status: 'waiting', + readiness: [ + { + readinessId: 'wait-b', + status: 'waiting', + waitingFor: [ + { kind: 'input_route', upstreamOperatorIds: ['a'] }, + { kind: 'activation_missing', operatorId: 'c', activationId: 'activation-c' }, + ], + omittedWaitingFor: 2, + }, + ], + omitted: { + ...graphOperator('b', ['work-b']).omitted, + readiness: 1, + readinessWaits: 2, + }, + }, + ], + edges: [{ edgeId: 'edge-a-b', fromOperatorId: 'a', toOperatorId: 'b' }], + work: [ + graphWork('work-a', 'a', 'Collect inputs'), + graphWork('work-b', 'b', 'Synthesize result'), + ], + claims: [ + { + claimId: 'claim-a', + intentId: 'intent-a', + operatorId: 'a', + childSessionId: 'session-a', + run: { sessionId: 'session-a', agentRunId: 'run-a', turnId: 'turn-a' }, + admissionState: 'executing', + claimedAt: 1, + }, + ], + recentActivity: [ + { + recordId: 'record-a', + operatorId: 'a', + activationId: 'activation-a', + eventTime: 2, + facets: ['tool_call'], + signals: [{ kind: 'attention', reason: 'permission_request' }], + run: { sessionId: 'session-a', agentRunId: 'run-a', turnId: 'turn-a' }, + }, + ], + }); + const harness = await renderPanel(graph); + + assert.ok(harness.container.querySelector('[data-testid="agent-graph-topology"]')); + assert.equal(harness.container.querySelectorAll('.maka-agent-graph-edge').length, 1); + const node = harness.container.querySelector('.maka-agent-graph-node'); + assert.ok(node); + assert.equal(node.tagName, 'BUTTON'); + assert.equal(node.children[0]?.getAttribute('class'), 'maka-agent-graph-node-heading'); + assert.equal(node.querySelector('[role="img"]')?.getAttribute('aria-hidden'), 'true'); + assert.match(node.textContent ?? '', /Running/); + assert.equal(node.getAttribute('aria-pressed'), 'false'); + assert.match(node.getAttribute('aria-label') ?? '', /agent-a\. Running\. Collect inputs/); + assert.match(node.getAttribute('aria-label') ?? '', /3 more work items omitted/); + const waitingNode = harness.container.querySelectorAll('.maka-agent-graph-node')[1]; + assert.match(waitingNode?.getAttribute('aria-label') ?? '', /Waiting for input from a/); + assert.match(waitingNode?.getAttribute('aria-label') ?? '', /1 more wait/); + assert.match(waitingNode?.getAttribute('aria-label') ?? '', /2 waits omitted/); + assert.match(waitingNode?.getAttribute('aria-label') ?? '', /1 readiness check omitted/); + assert.match(waitingNode?.textContent ?? '', /Waiting for input from a/); + await act(async () => { + (node as HTMLElement).click(); + await Promise.resolve(); + }); + assert.equal(node.getAttribute('aria-pressed'), 'true'); + assert.ok(harness.container.querySelector('[aria-label="Operator details: agent-a"]')); + assert.match(harness.container.textContent ?? '', /0 inbound · 1 outbound/); + assert.match(harness.container.textContent ?? '', /Work/); + assert.match(harness.container.textContent ?? '', /Dependencies/); + assert.match(harness.container.textContent ?? '', /To agent-b/); + assert.match(harness.container.textContent ?? '', /activation-a/); + assert.match(harness.container.textContent ?? '', /firstEventTime/); + assert.match(harness.container.textContent ?? '', /lastRecordId/); + assert.match(harness.container.textContent ?? '', /claim-a/); + assert.match(harness.container.textContent ?? '', /intent-a/); + assert.match(harness.container.textContent ?? '', /run\.agentRunIdrun-a/); + assert.match(harness.container.textContent ?? '', /run\.turnIdturn-a/); + assert.match(harness.container.textContent ?? '', /tool call · record-a/); + assert.match(harness.container.textContent ?? '', /attention: permission request/); + const eventTime = harness.container.querySelector('time'); + assert.equal( + eventTime?.getAttribute('datetime') ?? eventTime?.getAttribute('dateTime'), + '1970-01-01T00:00:00.001Z', + ); + assert.match(harness.container.textContent ?? '', /2 more omitted/); + assert.equal(harness.inspectionReadCount('a'), 1); + const viewport = harness.container.querySelector('.maka-agent-graph-topology-viewport') as + | (HTMLElement & { scrollLeft: number }) + | null; + assert.ok(viewport); + Object.defineProperty(viewport, 'clientWidth', { value: 240 }); + viewport.scrollLeft = 10; + + await harness.setSnapshot({ + ...graph, + snapshotVersion: '2', + operators: [graphOperator('upstream', []), ...graph.operators], + edges: [ + ...graph.edges, + { edgeId: 'edge-upstream-a', fromOperatorId: 'upstream', toOperatorId: 'a' }, + ], + }); + assert.equal(harness.inspectionReadCount('a'), 2); + assert.match(harness.container.textContent ?? '', /1 inbound · 1 outbound/); + assert.equal(viewport.scrollLeft, 268); + + await act(async () => { + (node as HTMLElement).click(); + await Promise.resolve(); + }); + assert.equal(node.getAttribute('aria-pressed'), 'false'); + assert.equal(harness.container.querySelector('[aria-label="Operator details: agent-a"]'), null); + + const listButton = [...harness.container.querySelectorAll('button')].find( + (button) => button.textContent === 'List', + ); + assert.ok(listButton); + await act(async () => (listButton as HTMLElement).click()); + assert.ok(harness.container.querySelector('[data-testid="agent-graph-list"]')); + assert.match(harness.container.textContent ?? '', /Feeds agent-b/); + assert.match(harness.container.textContent ?? '', /Depends on agent-a/); + const detailsButton = [...harness.container.querySelectorAll('button')].find( + (button) => button.getAttribute('aria-label') === 'View agent-a details', + ); + assert.ok(detailsButton); + assert.equal(detailsButton.getAttribute('aria-expanded'), 'false'); + await act(async () => { + (detailsButton as HTMLElement).click(); + await Promise.resolve(); + }); + assert.equal(detailsButton.getAttribute('aria-expanded'), 'true'); + const details = harness.container.querySelector( + 'section[aria-label="Operator details: agent-a"]', + ); + assert.ok(details); + assert.equal(detailsButton.getAttribute('aria-controls'), details.id); + await act(async () => { + (detailsButton as HTMLElement).click(); + await Promise.resolve(); + }); + assert.equal(detailsButton.getAttribute('aria-expanded'), 'false'); + assert.equal( + harness.container.querySelector('section[aria-label="Operator details: agent-a"]'), + null, + ); + await act(async () => harness.root.unmount()); + }); + + it('keeps inspected details visible while a live refresh is pending', async () => { + const graph = snapshot({ + graphId: 'graph-inspection-refresh', + status: 'active', + operators: [graphOperator('a', ['work-a'])], + work: [graphWork('work-a', 'a', 'Initial inspection detail')], + }); + const harness = await renderPanel(graph); + const node = harness.container.querySelector('.maka-agent-graph-node'); + assert.ok(node); + await act(async () => { + (node as HTMLElement).click(); + await Promise.resolve(); + }); + const details = harness.container.querySelector( + '[aria-label="Operator details: agent-a"]', + ); + assert.ok(details); + assert.match(details.textContent ?? '', /Initial inspection detail/); + + const inspection = harness.holdNextInspection('a'); + await harness.setSnapshot({ + ...graph, + snapshotVersion: '2', + work: [graphWork('work-a', 'a', 'Refreshed inspection detail')], + }); + await inspection.started; + + assert.equal(details.getAttribute('aria-busy'), 'true'); + assert.match(details.textContent ?? '', /Initial inspection detail/); + assert.doesNotMatch(details.textContent ?? '', /Refreshed inspection detail/); + + await act(async () => { + inspection.release(); + await Promise.resolve(); + }); + assert.equal(details.getAttribute('aria-busy'), 'false'); + assert.match(details.textContent ?? '', /Refreshed inspection detail/); + await act(async () => harness.root.unmount()); + }); + + it('keeps keyboard order visual and reveals a retained selection', async () => { + const harness = await renderPanel( + snapshot({ + graphId: 'graph-selection', + status: 'active', + operators: [graphOperator('c', []), graphOperator('b', []), graphOperator('a', [])], + edges: [ + { edgeId: 'edge-a-b', fromOperatorId: 'a', toOperatorId: 'b' }, + { edgeId: 'edge-b-c', fromOperatorId: 'b', toOperatorId: 'c' }, + ], + }), + ); + assert.deepEqual( + [...harness.container.querySelectorAll('.maka-agent-graph-node strong')].map( + (node) => node.textContent, + ), + ['agent-a', 'agent-b', 'agent-c'], + ); + + const listButton = [...harness.container.querySelectorAll('button')].find( + (button) => button.textContent === 'List', + ); + assert.ok(listButton); + await act(async () => (listButton as HTMLElement).click()); + const operatorRow = [...harness.container.querySelectorAll('.maka-agent-graph-operators li')].find( + (row) => row.querySelector('strong')?.textContent === 'agent-c', + ); + const detailsButton = [...(operatorRow?.querySelectorAll('button') ?? [])].find( + (button) => button.getAttribute('aria-label') === 'View agent-c details', + ); + assert.ok(detailsButton); + await act(async () => { + (detailsButton as HTMLElement).click(); + await Promise.resolve(); + }); + const detailsRevealCount = selectedDetailsRevealCount(harness); + + const topologyButton = [...harness.container.querySelectorAll('button')].find( + (button) => button.textContent === 'Topology', + ); + assert.ok(topologyButton); + await act(async () => (topologyButton as HTMLElement).click()); + assert.equal(selectedDetailsRevealCount(harness), detailsRevealCount); + + const collapseButton = harness.container.querySelector('[aria-label="Collapse Agent Graph"]'); + assert.ok(collapseButton); + await act(async () => (collapseButton as HTMLElement).click()); + const expandButton = harness.container.querySelector('[aria-label="Expand Agent Graph"]'); + assert.ok(expandButton); + await act(async () => (expandButton as HTMLElement).click()); + assert.equal(selectedDetailsRevealCount(harness), detailsRevealCount); + await act(async () => harness.root.unmount()); + }); + it('keeps the new session loading when a disposed read settles later', async () => { const sessionA = snapshot({ graphId: 'graph-a', status: 'active' }); const sessionB = snapshot({ @@ -388,17 +737,7 @@ describe('AgentGraphPanel dismiss', () => { const current = snapshot({ graphId: 'graph-2', status: 'active' }); const previous = snapshot({ graphId: 'graph-1', status: 'completed' }); const harness = installGraphRenderer(current, [previous]); - await act(async () => { - harness.root.render( - createElement(AgentGraphPanel, { - rootSessionId: 'session-1', - enabled: true, - locale: 'en', - onOpenSession: () => undefined, - }), - ); - await Promise.resolve(); - }); + await harness.renderSession('session-1'); const selector = harness.container.querySelector('[role="combobox"]'); assert.ok(selector); @@ -587,17 +926,7 @@ describe('AgentGraphPanel dismiss', () => { const current = snapshot({ graphId: 'graph-2', status: 'active' }); const previous = snapshot({ graphId: 'graph-1', status: 'completed' }); const harness = installGraphRenderer(current, [previous]); - await act(async () => { - harness.root.render( - createElement(AgentGraphPanel, { - rootSessionId: 'session-1', - enabled: true, - locale: 'en', - onOpenSession: () => undefined, - }), - ); - await Promise.resolve(); - }); + await harness.renderSession('session-1'); const selector = harness.container.querySelector('[role="combobox"]'); assert.ok(selector); @@ -774,3 +1103,45 @@ describe('AgentGraphPanel collapse initialization', () => { await act(async () => harness.root.unmount()); }); }); +function graphOperator(operatorId: string, scheduledWorkIds: string[]) { + return { + operatorId, + childSessionId: `session-${operatorId}`, + provisionId: `provision-${operatorId}`, + agentId: `agent-${operatorId}`, + provisionedAt: 1, + status: 'running' as const, + inboundEdgeIds: [], + outboundEdgeIds: [], + scheduledWorkIds, + readiness: [], + omitted: { + inboundEdgeIds: 0, + outboundEdgeIds: 0, + scheduledWorkIds: 0, + readiness: 0, + readinessWaits: 0, + }, + }; +} + +function graphWork(workId: string, operatorId: string, instructionPreview: string) { + return { + workId, + target: { kind: 'operator' as const, operatorId }, + inputIds: [], + status: 'requested' as const, + instructionPreview, + instructionTruncated: false, + revision: 1, + committedAt: 1, + }; +} + +function selectedDetailsRevealCount( + harness: Pick, 'scrollIntoViewTargets'>, +): number { + return harness + .scrollIntoViewTargets() + .filter((target) => target.startsWith('maka-agent-graph-details-heading:')).length; +} diff --git a/apps/desktop/src/main/__tests__/agent-graph-refresh.test.ts b/apps/desktop/src/main/__tests__/agent-graph-refresh.test.ts index 3ba86b680a..48a426f164 100644 --- a/apps/desktop/src/main/__tests__/agent-graph-refresh.test.ts +++ b/apps/desktop/src/main/__tests__/agent-graph-refresh.test.ts @@ -20,7 +20,7 @@ import { deferred } from '@maka/core/test-only/async-primitives'; import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; -import { createAgentGraphRefreshScheduler } from '../../renderer/agent-graph-refresh.js'; +import { createAgentGraphRefreshScheduler } from '../../renderer/features/agent-graph/testing.js'; async function tick(): Promise { await new Promise((resolve) => setImmediate(resolve)); } diff --git a/apps/desktop/src/main/__tests__/agent-graph-services-adapter.test.ts b/apps/desktop/src/main/__tests__/agent-graph-services-adapter.test.ts new file mode 100644 index 0000000000..947435f109 --- /dev/null +++ b/apps/desktop/src/main/__tests__/agent-graph-services-adapter.test.ts @@ -0,0 +1,74 @@ +/* + * 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 { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; +import type { MakaBridge } from '../../preload/bridge-contract.js'; +import { createDesktopAgentGraphServices } from '../../renderer/platform/desktop/create-agent-graph-services.js'; + +describe('createDesktopAgentGraphServices', () => { + it('maps the narrow Graph contract to the Desktop bridge', async () => { + const calls: Array<{ name: string; args: unknown[] }> = []; + let changeHandler: (() => void) | undefined; + let changes = 0; + let disposed = 0; + const graphs = new Proxy( + {}, + { + get: (_target, property) => + (...args: unknown[]) => { + calls.push({ name: String(property), args }); + if (property === 'subscribe') { + changeHandler = args[1] as () => void; + return () => { + disposed += 1; + }; + } + return Promise.resolve(property); + }, + }, + ); + const services = createDesktopAgentGraphServices({ graphs } as unknown as Pick< + MakaBridge, + 'graphs' + >); + + await services.graphs.listEpochs('session-1'); + await services.graphs.listCurrentEpochs('session-1'); + await services.graphs.getSnapshot('session-1', { graphId: 'graph-1' }); + await services.graphs.inspectOperator('session-1', 'operator-1', 'graph-1'); + await services.graphs.stop('session-1', 'graph-1'); + const unsubscribe = services.graphs.subscribe('session-1', () => { + changes += 1; + }); + changeHandler?.(); + unsubscribe(); + + assert.deepEqual(calls, [ + { name: 'listEpochs', args: ['session-1'] }, + { name: 'listCurrentEpochs', args: ['session-1'] }, + { name: 'getSnapshot', args: ['session-1', { graphId: 'graph-1' }] }, + { name: 'inspectOperator', args: ['session-1', 'operator-1', 'graph-1'] }, + { name: 'stop', args: ['session-1', 'graph-1'] }, + { name: 'subscribe', args: ['session-1', changeHandler] }, + ]); + assert.equal(changes, 1); + assert.equal(disposed, 1); + }); +}); diff --git a/apps/desktop/src/main/__tests__/agent-graph-topology.test.ts b/apps/desktop/src/main/__tests__/agent-graph-topology.test.ts new file mode 100644 index 0000000000..575f027000 --- /dev/null +++ b/apps/desktop/src/main/__tests__/agent-graph-topology.test.ts @@ -0,0 +1,231 @@ +/* + * 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 { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; +import type { + AgentGraphClientEdge, + AgentGraphClientOperator, +} from '@maka/runtime/stream-graph-read-model'; +import { + agentGraphEdgePath, + agentGraphStatusSemantic, + firstScheduledWorkPreview, + layoutAgentGraph, + revealAgentGraphNode, + scheduledWorkPresentation, +} from '../../renderer/features/agent-graph/testing.js'; + +describe('layoutAgentGraph', () => { + it('preserves graph operator status semantics', () => { + assert.equal(agentGraphStatusSemantic('running'), 'active'); + assert.equal(agentGraphStatusSemantic('blocked'), 'attention'); + assert.equal(agentGraphStatusSemantic('completed'), 'success'); + assert.equal(agentGraphStatusSemantic('failed'), 'error'); + }); + + it('uses the bounded preview without duplicating its truncation marker', () => { + const preview = firstScheduledWorkPreview(operator('a', ['work-a']), [ + { + workId: 'work-a', + target: { kind: 'operator', operatorId: 'a' }, + inputIds: [], + status: 'requested', + instructionPreview: 'Collect inputs…', + instructionTruncated: true, + revision: 1, + committedAt: 1, + }, + ]); + + assert.equal(preview, 'Collect inputs…'); + }); + + it('prefers the newest requested work and does not substitute an operator id', () => { + const selected = firstScheduledWorkPreview(operator('a', ['old', 'stopped', 'new']), [ + work('old', 'requested', 1, 'Old request'), + work('stopped', 'stopped', 3, 'Stopped request'), + work('new', 'requested', 2, 'New request'), + ]); + + assert.equal(selected, 'New request'); + assert.equal(firstScheduledWorkPreview(operator('b'), []), undefined); + }); + + it('counts operator work omitted by both operator and snapshot bounds', () => { + const boundedOperator = operator('a', ['visible', 'snapshot-omitted']); + boundedOperator.omitted.scheduledWorkIds = 2; + + assert.deepEqual( + scheduledWorkPresentation(boundedOperator, [ + work('visible', 'requested', 1, 'Visible request'), + ]), + { preview: 'Visible request', omitted: 3 }, + ); + }); + + it('places a dependency chain in successive columns', () => { + const layout = layoutAgentGraph( + [operator('a'), operator('b'), operator('c')], + [edge('a-b', 'a', 'b'), edge('b-c', 'b', 'c')], + ); + const positions = positionMap(layout.nodes); + + assert.ok(positions.a.x < positions.b.x); + assert.ok(positions.b.x < positions.c.x); + assert.equal(positions.a.y, positions.b.y); + assert.equal(positions.b.y, positions.c.y); + }); + + it('keeps fan-out peers together and places their join downstream', () => { + const layout = layoutAgentGraph( + [operator('a'), operator('b'), operator('c'), operator('d')], + [edge('a-b', 'a', 'b'), edge('a-c', 'a', 'c'), edge('b-d', 'b', 'd'), edge('c-d', 'c', 'd')], + ); + const positions = positionMap(layout.nodes); + + assert.equal(positions.b.x, positions.c.x); + assert.notEqual(positions.b.y, positions.c.y); + assert.ok(positions.a.x < positions.b.x); + assert.ok(positions.b.x < positions.d.x); + }); + + it('keeps depth propagated from visited ancestors when edges form a cycle', () => { + const layout = layoutAgentGraph( + [operator('a'), operator('b'), operator('c')], + [edge('a-b', 'a', 'b'), edge('b-c', 'b', 'c'), edge('c-b', 'c', 'b')], + ); + const positions = positionMap(layout.nodes); + + assert.ok(positions.a.x < positions.b.x); + }); + + it('ignores omitted edge endpoints without moving visible nodes', () => { + const operators = [operator('a'), operator('b')]; + const complete = layoutAgentGraph(operators, [edge('a-b', 'a', 'b')]); + const partial = layoutAgentGraph(operators, [ + edge('a-b', 'a', 'b'), + edge('missing-a', 'missing', 'a'), + edge('b-missing', 'b', 'missing'), + ]); + + assert.deepEqual(partial, complete); + }); + + it('keeps rows stable when the read model reorders operators by lifecycle state', () => { + const original = [operator('a', [], 1), operator('b', [], 2), operator('c', [], 3)]; + const reordered = [ + { ...original[2]!, status: 'running' as const }, + { ...original[0]!, status: 'completed' as const }, + { ...original[1]!, status: 'waiting' as const }, + ]; + + assert.deepEqual( + positionMap(layoutAgentGraph(original, []).nodes), + positionMap(layoutAgentGraph(reordered, []).nodes), + ); + }); + + it('routes skip-level edges through the gap above intervening nodes', () => { + const layout = layoutAgentGraph( + [operator('a'), operator('b'), operator('c'), operator('d')], + [edge('a-b', 'a', 'b'), edge('b-c', 'b', 'c'), edge('c-d', 'c', 'd')], + ); + const positions = positionMap(layout.nodes); + const path = agentGraphEdgePath(positions.a, positions.d); + + assert.doesNotMatch(path, / C /u); + assert.match(path, / L \d+ 8 L \d+ 8 /u); + assert.ok(8 < positions.b.y); + assert.ok(8 < positions.c.y); + }); + + it('reveals a node without scrolling an ancestor', () => { + const viewport = { + clientHeight: 168, + clientWidth: 240, + scrollLeft: 10, + scrollTop: 20, + }; + + revealAgentGraphNode(viewport, { operatorId: 'a', x: 300, y: 220 }); + + assert.deepEqual(viewport, { + clientHeight: 168, + clientWidth: 240, + scrollLeft: 268, + scrollTop: 156, + }); + }); +}); + +function operator( + operatorId: string, + scheduledWorkIds: string[] = [], + provisionedAt = 1, +): AgentGraphClientOperator { + return { + operatorId, + childSessionId: `session-${operatorId}`, + provisionId: `provision-${operatorId}`, + agentId: `agent-${operatorId}`, + provisionedAt, + status: 'not_started', + inboundEdgeIds: [], + outboundEdgeIds: [], + scheduledWorkIds, + readiness: [], + omitted: { + inboundEdgeIds: 0, + outboundEdgeIds: 0, + scheduledWorkIds: 0, + readiness: 0, + readinessWaits: 0, + }, + }; +} + +function work( + workId: string, + status: 'requested' | 'stopped' | 'superseded', + revision: number, + instructionPreview: string, +) { + return { + workId, + target: { kind: 'operator' as const, operatorId: 'a' }, + inputIds: [], + status, + instructionPreview, + instructionTruncated: false, + revision, + committedAt: revision, + }; +} + +function edge(edgeId: string, fromOperatorId: string, toOperatorId: string): AgentGraphClientEdge { + return { edgeId, fromOperatorId, toOperatorId }; +} + +function positionMap(nodes: readonly { operatorId: string; x: number; y: number }[]) { + return Object.fromEntries(nodes.map((node) => [node.operatorId, node])) as Record< + string, + { operatorId: string; x: number; y: number } + >; +} diff --git a/apps/desktop/src/main/e2e-fixture.ts b/apps/desktop/src/main/e2e-fixture.ts index 5b64d73e1d..76aa176c09 100644 --- a/apps/desktop/src/main/e2e-fixture.ts +++ b/apps/desktop/src/main/e2e-fixture.ts @@ -55,6 +55,10 @@ import { turnSession, agentGraphSession, } from './e2e-fixture/scenarios-chat.js'; +import { + agentGraphTopologySession, + seedAgentGraphTopology, +} from './e2e-fixture/scenarios-agent-graph.js'; import { seedMcpFixture, seedSkillsMarketFixture } from './e2e-fixture/scenarios-modules.js'; import { longSidebarSessions } from './e2e-fixture/scenarios-sessions.js'; import { @@ -81,6 +85,7 @@ const E2E_FIXTURE_SCENARIOS = new Set([ 'module-daily-review', 'scheduled-tasks', 'agent-graph-layout', + 'agent-graph-topology', 'sidebar-search-modal-open', ]); @@ -219,6 +224,8 @@ export function getE2eFixtureState(fixture: E2eFixture | null): E2eFixtureState return { ...state, activeSessionId: TURN_SESSION_ID, sidebarSection: 'automations', sidebarCollapsed: false }; case 'agent-graph-layout': return { ...state, activeSessionId: AGENT_GRAPH_SESSION_ID }; + case 'agent-graph-topology': + return { ...state, activeSessionId: TURN_SESSION_ID, workbarCollapsed: true }; case 'sidebar-search-modal-open': return { ...state, @@ -244,11 +251,18 @@ export async function seedE2eFixture(input: { await writeConnections(input.workspaceRoot, now, scenario); await writeSession( input.workspaceRoot, - scenario === 'agent-graph-layout' ? agentGraphSession(now) : turnSession(now), + scenario === 'agent-graph-layout' + ? agentGraphSession(now) + : scenario === 'agent-graph-topology' + ? agentGraphTopologySession(now) + : turnSession(now), turnMessages(now), ); if (scenario === 'agent-graph-layout') await seedAgentGraphLayout(input.workspaceRoot, now); + if (scenario === 'agent-graph-topology') { + await seedAgentGraphTopology(input.workspaceRoot, now); + } if (scenario === 'chat-prompt-rail') { diff --git a/apps/desktop/src/main/e2e-fixture/scenarios-agent-graph.ts b/apps/desktop/src/main/e2e-fixture/scenarios-agent-graph.ts new file mode 100644 index 0000000000..f7d39697ac --- /dev/null +++ b/apps/desktop/src/main/e2e-fixture/scenarios-agent-graph.ts @@ -0,0 +1,280 @@ +/* + * 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 type { SessionHeader } from '@maka/core/session'; +import { createAgentGraphControlStore } from '@maka/storage/agent-graph-control-store'; +import { agentGraphIdForRootSession } from '@maka/runtime/stream-graph-coordinator'; +import type { + AgentGraphClientActivity, + AgentGraphClientClaimRef, + AgentGraphClientOperator, + AgentGraphClientScheduledWork, + AgentGraphClientSnapshot, + AgentGraphOperatorInspection, +} from '@maka/runtime/stream-graph-read-model'; +import { turnSession } from './scenarios-chat.js'; +import { TURN_SESSION_ID } from './seed-helpers.js'; + +const OPERATOR_IDS = [ + 'collector', + 'planner', + 'researcher', + 'reviewer', + 'writer', + 'publisher', +] as const; +const EDGE_PAIRS = [ + ['collector', 'planner'], + ['collector', 'researcher'], + ['collector', 'reviewer'], + ['researcher', 'writer'], + ['writer', 'publisher'], +] as const; +const SNAPSHOT_VERSION = `sha256:${'a'.repeat(64)}`; +const TOPOLOGY_FINGERPRINT = `sha256:${'b'.repeat(64)}`; + +export function agentGraphTopologySession(now: number): SessionHeader { + return { + ...turnSession(now), + name: 'Agent Graph topology fixture', + orchestrationMode: 'graph', + }; +} + +export async function seedAgentGraphTopology(workspaceRoot: string, now: number): Promise { + const graphId = agentGraphIdForRootSession(TURN_SESSION_ID); + const operators = OPERATOR_IDS.map((operatorId, index) => + fixtureOperator(operatorId, index, now), + ); + const visibleWork = fixtureWork('work-publisher-visible', 'publisher', 'Publish the final report', now); + const omittedWork = fixtureWork( + 'work-publisher-snapshot-omitted', + 'publisher', + 'Notify downstream consumers', + now - 1, + ); + const edges = EDGE_PAIRS.map(([fromOperatorId, toOperatorId]) => ({ + edgeId: `edge-${fromOperatorId}-${toOperatorId}`, + fromOperatorId, + toOperatorId, + })); + const permissionActivity: AgentGraphClientActivity = { + recordId: 'record-publisher-permission', + operatorId: 'publisher', + activationId: 'activation-publisher', + eventTime: now - 3_000, + facets: ['permission_request'], + signals: [{ kind: 'attention', reason: 'permission_request' }], + run: { sessionId: 'child-publisher', agentRunId: 'run-publisher', turnId: 'turn-publisher' }, + }; + const terminalActivity: AgentGraphClientActivity = { + ...permissionActivity, + recordId: 'record-publisher-terminal', + eventTime: now - 1_000, + facets: ['completed'], + signals: [{ kind: 'terminal', status: 'completed' }], + }; + const claim: AgentGraphClientClaimRef = { + claimId: 'claim-publisher', + intentId: 'intent-publisher', + operatorId: 'publisher', + childSessionId: 'child-publisher', + run: terminalActivity.run, + admissionState: 'executing', + claimedAt: now - 4_000, + }; + const snapshot: AgentGraphClientSnapshot = { + schemaVersion: 1, + rootSessionId: TURN_SESSION_ID, + graphId, + orchestrationMode: 'graph', + snapshotVersion: SNAPSHOT_VERSION, + status: 'active', + scheduleRevision: 1, + topologyFingerprint: TOPOLOGY_FINGERPRINT, + closed: false, + latestEventTime: terminalActivity.eventTime, + operators, + edges, + work: [visibleWork], + reconciliationFailures: [], + stoppedTargets: [], + claims: [claim], + recentControlDecisions: [], + recentActivity: [permissionActivity, terminalActivity], + terminalHistory: { records: [terminalActivity] }, + omitted: { + operators: 0, + edges: 0, + work: 1, + reconciliationFailures: 0, + stoppedTargets: 0, + claims: 0, + controlDecisions: 0, + recentActivity: 0, + }, + }; + const inspections = operators.map((operator) => + fixtureInspection(snapshot, operator, [visibleWork, omittedWork]), + ); + const store = createAgentGraphControlStore(workspaceRoot); + try { + await store.commitAgentGraphClientProjection({ + schemaVersion: 1, + graphId, + rootSessionId: TURN_SESSION_ID, + expectedSnapshotVersion: null, + snapshotVersion: SNAPSHOT_VERSION, + snapshot, + replaceOperators: true, + operators: inspections.map((inspection) => ({ + operatorId: inspection.operator.operatorId, + payload: inspection, + })), + terminalActivities: [ + { + recordId: terminalActivity.recordId, + eventTime: terminalActivity.eventTime, + payload: terminalActivity, + }, + ], + activityRecords: [permissionActivity, terminalActivity].map((activity) => ({ + recordId: activity.recordId, + eventTime: activity.eventTime, + })), + }); + } finally { + store.close(); + } +} + +function fixtureOperator( + operatorId: (typeof OPERATOR_IDS)[number], + index: number, + now: number, +): AgentGraphClientOperator { + const publisher = operatorId === 'publisher'; + return { + operatorId, + childSessionId: `child-${operatorId}`, + provisionId: `provision-${operatorId}`, + agentId: operatorId, + provisionedAt: now - (OPERATOR_IDS.length - index + 4) * 1_000, + status: publisher ? 'completed' : operatorId === 'writer' ? 'waiting' : 'running', + inboundEdgeIds: EDGE_PAIRS.filter(([, target]) => target === operatorId).map( + ([source, target]) => `edge-${source}-${target}`, + ), + outboundEdgeIds: EDGE_PAIRS.filter(([source]) => source === operatorId).map( + ([source, target]) => `edge-${source}-${target}`, + ), + scheduledWorkIds: publisher + ? ['work-publisher-visible', 'work-publisher-snapshot-omitted'] + : [], + readiness: + operatorId === 'writer' + ? [ + { + readinessId: 'readiness-writer', + status: 'waiting', + waitingFor: [{ kind: 'input_route', upstreamOperatorIds: ['researcher'] }], + omittedWaitingFor: 0, + }, + ] + : [], + omitted: { + inboundEdgeIds: 0, + outboundEdgeIds: 0, + scheduledWorkIds: 0, + readiness: 0, + readinessWaits: 0, + }, + ...(publisher + ? { + currentActivation: { + activationId: 'activation-publisher', + status: 'completed' as const, + recordCount: 2, + firstEventTime: now - 3_000, + lastEventTime: now - 1_000, + terminalRecordId: 'record-publisher-terminal', + run: { + sessionId: 'child-publisher', + agentRunId: 'run-publisher', + turnId: 'turn-publisher', + }, + }, + } + : {}), + }; +} + +function fixtureWork( + workId: string, + operatorId: string, + instructionPreview: string, + committedAt: number, +): AgentGraphClientScheduledWork { + return { + workId, + target: { kind: 'operator', operatorId }, + inputIds: [], + status: 'requested', + instructionPreview, + instructionTruncated: false, + revision: 1, + committedAt, + }; +} + +function fixtureInspection( + snapshot: AgentGraphClientSnapshot, + operator: AgentGraphClientOperator, + publisherWork: readonly AgentGraphClientScheduledWork[], +): AgentGraphOperatorInspection { + const publisher = operator.operatorId === 'publisher'; + return { + schemaVersion: 1, + rootSessionId: snapshot.rootSessionId, + graphId: snapshot.graphId, + snapshotVersion: snapshot.snapshotVersion, + operator, + inboundEdges: snapshot.edges.filter((edge) => edge.toOperatorId === operator.operatorId), + outboundEdges: snapshot.edges.filter((edge) => edge.fromOperatorId === operator.operatorId), + work: publisher ? [...publisherWork] : [], + claims: publisher ? snapshot.claims : [], + activations: + publisher && operator.currentActivation + ? [ + { + ...operator.currentActivation, + lastRecordId: 'record-publisher-terminal', + }, + ] + : [], + recentRecords: publisher ? snapshot.recentActivity : [], + omitted: { + inboundEdges: 0, + outboundEdges: 0, + work: 0, + claims: 0, + activations: 0, + records: 0, + }, + }; +} diff --git a/apps/desktop/src/renderer/agent-graph-panel.tsx b/apps/desktop/src/renderer/agent-graph-panel.tsx deleted file mode 100644 index b3c2b79f9a..0000000000 --- a/apps/desktop/src/renderer/agent-graph-panel.tsx +++ /dev/null @@ -1,629 +0,0 @@ -/* - * 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, useId, useMemo, useRef, useState, type JSX } from 'react'; -import type { UiLocale } from '@maka/core/ui-locale'; -import type { - AgentGraphClientOperator, - AgentGraphClientSnapshot, -} 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 { ICON_SIZE, ChevronDown, X } from '@maka/ui/icons'; -import { Banner } from '@astryxdesign/core/Banner'; -import { Button } from '@astryxdesign/core/Button'; -import { EmptyState } from '@astryxdesign/core/EmptyState'; -import { Spinner } from '@astryxdesign/core/Spinner'; -import { - dismissAgentGraphPanel, - isAgentGraphPanelDismissible, - reconcileAgentGraphPanelDismissals, - shouldShowAgentGraphPanel, - type AgentGraphPanelDismissals, -} from './agent-graph-panel-visibility.js'; -import { - createAgentGraphRefreshScheduler, - type AgentGraphRefreshScheduler, -} from './agent-graph-refresh.js'; - -const noopAgentGraphRefreshScheduler: AgentGraphRefreshScheduler = { - requestRefresh() {}, - invalidateAndRefresh() {}, - isCurrent: () => false, - dispose() {}, -}; - -type GraphPanelCopy = { - title: string; - loading: string; - retry: string; - collapse: string; - expand: string; - dismiss: string; - stop: string; - stopping: string; - stopFailed: string; - loadFailed: string; - openSession: string; - operators: string; - selectedResults: string; - epoch: string; - currentEpoch: string; - historicalEpoch: string; - cappedEpochs(count: number): string; - noOperators: string; - hiddenOperators(count: number): string; - progress(settled: number, total: number, hasOmitted: boolean): string; - status(status: AgentGraphClientSnapshot['status']): string; - operatorStatus(status: AgentGraphClientOperator['status']): string; - wait(operator: AgentGraphClientOperator): string | undefined; -}; - -export function getAgentGraphPanelCopy(locale: UiLocale): GraphPanelCopy { - if (locale === 'zh-CN') { - return { - title: 'Agent Graph', - loading: '正在读取 Graph 状态…', - retry: '重试', - collapse: '收起 Agent Graph', - expand: '展开 Agent Graph', - dismiss: '关闭 Agent Graph', - stop: '停止 Graph', - stopping: '停止中…', - stopFailed: '停止 Graph 失败,请重试。', - loadFailed: 'Graph 状态刷新失败。', - openSession: '打开子任务', - operators: 'Operators', - selectedResults: '已选择结果', - epoch: 'Graph 运行轮次', - currentEpoch: '当前', - historicalEpoch: '历史记录(只读)', - cappedEpochs: (count) => `仅显示最近 ${count} 次运行`, - noOperators: '等待主 Agent 创建 operator…', - hiddenOperators: (count) => `另有 ${count} 个 operator`, - progress: (settled, total, hasOmitted) => - hasOmitted ? `可见 ${settled}/${total} 已结束` : `${settled}/${total} 已结束`, - status: (status) => - ({ - empty: '等待调度', - active: '运行中', - closing: '收尾中', - waiting: '等待中', - stopped: '已停止', - failed: '失败', - completed: '已完成', - })[status], - operatorStatus: (status) => - ({ - not_started: '未启动', - waiting: '等待', - runnable: '可运行', - running: '运行中', - blocked: '受阻', - completed: '完成', - failed: '失败', - aborted: '中止', - cancelled: '取消', - })[status], - wait: waitReasonZh, - }; - } - if (locale === 'zh-TW') { - return { - title: 'Agent Graph', - loading: '正在讀取 Graph 狀態…', - retry: '重試', - collapse: '收起 Agent Graph', - expand: '展開 Agent Graph', - dismiss: '關閉 Agent Graph', - stop: '停止 Graph', - stopping: '停止中…', - stopFailed: '停止 Graph 失敗,請重試。', - loadFailed: 'Graph 狀態重新整理失敗。', - openSession: '開啟子任務', - operators: 'Operators', - selectedResults: '已選取結果', - epoch: 'Graph 執行輪次', - currentEpoch: '目前', - historicalEpoch: '歷史記錄(唯讀)', - cappedEpochs: (count) => `僅顯示最近 ${count} 次執行`, - noOperators: '等待主 Agent 建立 operator…', - hiddenOperators: (count) => `另有 ${count} 個 operator`, - progress: (settled, total, hasOmitted) => - hasOmitted ? `可見項目中 ${settled}/${total} 已結束` : `${settled}/${total} 已結束`, - status: (status) => - ({ - empty: '等待排程', - active: '執行中', - closing: '收尾中', - waiting: '等待中', - stopped: '已停止', - failed: '失敗', - completed: '已完成', - })[status], - operatorStatus: (status) => - ({ - not_started: '尚未啟動', - waiting: '等待', - runnable: '可執行', - running: '執行中', - blocked: '受阻', - completed: '完成', - failed: '失敗', - aborted: '已中止', - cancelled: '已取消', - })[status], - wait: waitReasonZhTw, - }; - } - return { - title: 'Agent Graph', - loading: 'Loading graph state…', - retry: 'Retry', - collapse: 'Collapse Agent Graph', - expand: 'Expand Agent Graph', - dismiss: 'Dismiss Agent Graph', - stop: 'Stop graph', - stopping: 'Stopping…', - stopFailed: 'Could not stop the graph. Try again.', - loadFailed: 'Could not refresh graph state.', - openSession: 'Open child task', - operators: 'Operators', - selectedResults: 'Selected results', - epoch: 'Graph run', - currentEpoch: 'Current', - historicalEpoch: 'History (read-only)', - cappedEpochs: (count) => `Showing the newest ${count} runs`, - noOperators: 'Waiting for the main agent to create an operator…', - hiddenOperators: (count) => `${count} more operator${count === 1 ? '' : 's'}`, - progress: (settled, total, hasOmitted) => - hasOmitted ? `${settled}/${total} visible settled` : `${settled}/${total} settled`, - status: (status) => - ({ - empty: 'Awaiting schedule', - active: 'Running', - closing: 'Finishing', - waiting: 'Waiting', - stopped: 'Stopped', - failed: 'Failed', - completed: 'Completed', - })[status], - operatorStatus: (status) => - ({ - not_started: 'Not started', - waiting: 'Waiting', - runnable: 'Runnable', - running: 'Running', - blocked: 'Blocked', - completed: 'Completed', - failed: 'Failed', - aborted: 'Aborted', - cancelled: 'Cancelled', - })[status], - wait: waitReasonEn, - }; -} - -export function AgentGraphPanel(props: { - rootSessionId: string; - enabled: boolean; - locale: UiLocale; - onOpenSession(sessionId: string): void; -}): JSX.Element | null { - const [snapshot, setSnapshot] = useState(); - const [epochs, setEpochs] = useState([]); - const [epochsTruncated, setEpochsTruncated] = useState(false); - const [selectedGraphId, setSelectedGraphId] = useState(); - const [loading, setLoading] = useState(props.enabled); - const [error, setError] = useState(false); - const [stopState, setStopState] = useState({ - rootSessionId: props.rootSessionId, - graphId: undefined as string | undefined, - requestId: 0, - pending: false, - error: false, - }); - const [collapsed, setCollapsed] = useState(); - const [dismissedBySession, setDismissedBySession] = useState({}); - const contentId = useId(); - const refreshRef = useRef(noopAgentGraphRefreshScheduler); - const selectedGraphIdRef = useRef(undefined); - const followCurrentRef = useRef(true); - const stopRequestIdRef = useRef(0); - const copy = getAgentGraphPanelCopy(props.locale); - const stopFeedbackMatchesSelection = - stopState.rootSessionId === props.rootSessionId && stopState.graphId === selectedGraphId; - const stopPending = stopFeedbackMatchesSelection && stopState.pending; - const stopError = stopFeedbackMatchesSelection && stopState.error; - - useEffect(() => { - setSnapshot(undefined); - setEpochs([]); - setEpochsTruncated(false); - setSelectedGraphId(undefined); - selectedGraphIdRef.current = undefined; - followCurrentRef.current = true; - setError(false); - setStopState({ - rootSessionId: props.rootSessionId, - graphId: undefined, - requestId: ++stopRequestIdRef.current, - pending: false, - error: false, - }); - setCollapsed(undefined); - setLoading(props.enabled); - let cachedDirectory: AgentGraphEpochDirectory | undefined; - - const scheduler = createAgentGraphRefreshScheduler(async (fence) => { - if (!cachedDirectory) setLoading(true); - try { - let directory: AgentGraphEpochDirectory; - if (!cachedDirectory) { - directory = await window.maka.graphs.listEpochs(props.rootSessionId); - } else { - const currentPage = await window.maka.graphs.listCurrentEpochs(props.rootSessionId); - directory = sameEpochPage(cachedDirectory, currentPage) - ? cachedDirectory - : await window.maka.graphs.listEpochs(props.rootSessionId); - } - if (!scheduler.isCurrent(fence)) return; - cachedDirectory = directory; - const nextEpochs = directory.epochs; - const current = nextEpochs.find((entry) => entry.current) ?? nextEpochs[0]; - const selected = followCurrentRef.current - ? current - : nextEpochs.find((entry) => entry.graphId === selectedGraphIdRef.current); - // An evicted selection must not pin the panel on the fallback: - // resume following the current epoch so later rollovers refresh. - if (!selected && !followCurrentRef.current) { - followCurrentRef.current = true; - } - const graphId = (selected ?? current)?.graphId; - if (!graphId) throw new Error('Agent graph epoch directory is empty'); - selectedGraphIdRef.current = graphId; - const next = await window.maka.graphs.getSnapshot(props.rootSessionId, { graphId }); - if (scheduler.isCurrent(fence) && next.graphId === selectedGraphIdRef.current) { - setEpochs(nextEpochs); - setEpochsTruncated(directory.truncated); - setSelectedGraphId(graphId); - setCollapsed((current) => current ?? next.status === 'completed'); - setSnapshot(next); - setError(false); - } - } catch { - if (scheduler.isCurrent(fence)) setError(true); - } finally { - if (scheduler.isCurrent(fence)) setLoading(false); - } - }); - - refreshRef.current = scheduler; - const unsubscribe = window.maka.graphs.subscribe(props.rootSessionId, () => - scheduler.requestRefresh(), - ); - scheduler.requestRefresh(); - return () => { - scheduler.dispose(); - if (refreshRef.current === scheduler) { - refreshRef.current = noopAgentGraphRefreshScheduler; - } - unsubscribe(); - }; - }, [props.rootSessionId, props.enabled]); - - useEffect(() => { - setDismissedBySession((current) => - reconcileAgentGraphPanelDismissals( - current, - props.rootSessionId, - snapshot - ? { - rootSessionId: snapshot.rootSessionId, - graphId: snapshot.graphId, - status: snapshot.status, - } - : undefined, - ), - ); - }, [props.rootSessionId, snapshot]); - - const progress = useMemo(() => { - const settled = snapshot?.operators.filter((operator) => - ['completed', 'failed', 'aborted', 'cancelled'].includes(operator.status), - ).length ?? 0; - return { settled, total: snapshot?.operators.length ?? 0 }; - }, [snapshot]); - const selectedEpoch = epochs.find((entry) => entry.graphId === selectedGraphId); - - const hasGraphActivity = - snapshot !== undefined && - (snapshot.scheduleRevision > 0 || - snapshot.operators.length > 0 || - snapshot.omitted.operators > 0); - const hasGraphHistory = epochs.length > 1; - if ( - !shouldShowAgentGraphPanel({ - enabled: props.enabled, - hasGraphActivity: hasGraphActivity || hasGraphHistory, - error, - sessionId: props.rootSessionId, - graphId: snapshot?.graphId, - status: snapshot?.status, - dismissedBySession, - }) - ) { - return null; - } - - const stopGraph = async (expectedGraphId: string): Promise => { - if (stopPending) return; - const rootSessionId = props.rootSessionId; - const requestId = ++stopRequestIdRef.current; - setStopState({ rootSessionId, graphId: expectedGraphId, requestId, pending: true, error: false }); - try { - await window.maka.graphs.stop(rootSessionId, expectedGraphId); - } catch { - setStopState((current) => - current.rootSessionId === rootSessionId && current.requestId === requestId - ? { ...current, error: true } - : current, - ); - } finally { - setStopState((current) => - current.rootSessionId === rootSessionId && current.requestId === requestId - ? { ...current, pending: false } - : current, - ); - } - }; - const stopAvailable = - selectedEpoch?.current === true && - !loading && - snapshot !== undefined && - snapshot.graphId === selectedGraphId && - ['active', 'waiting', 'closing'].includes(snapshot.status); - const dismissAvailable = - selectedEpoch?.current === true && - !loading && - snapshot !== undefined && - snapshot.graphId === selectedGraphId && - isAgentGraphPanelDismissible(snapshot.status); - - return ( -
-
-
- {copy.title} - {epochs.length > 1 && snapshot ? ( - ({ - value: entry.graphId, - label: `#${entry.epoch} · ${entry.current ? copy.currentEpoch : copy.historicalEpoch}`, - }))} - onChange={(graphId: SelectorOptionType) => { - if (typeof graphId !== 'string') return; - selectedGraphIdRef.current = graphId; - setSelectedGraphId(graphId); - followCurrentRef.current = - epochs.find((entry) => entry.graphId === graphId)?.current === true; - refreshRef.current.invalidateAndRefresh(); - }} - /> - ) : null} - {epochsTruncated ? ( - {copy.cappedEpochs(epochs.length)} - ) : null} - {snapshot ? ( - - {copy.status(snapshot.status)} ·{' '} - {copy.progress( - progress.settled, - progress.total, - snapshot.omitted.operators > 0, - )} - - ) : null} -
-
- {stopAvailable ? ( -
-
- {!collapsed ? ( -
- {stopError ? ( - - ) : null} - - {loading && !snapshot ? ( - - ) : null} - {error ? ( - refreshRef.current.requestRefresh()} - /> - )} - /> - ) : null} - - {snapshot ? ( - <> -
{copy.operators}
- {snapshot.operators.length === 0 ? ( - - ) : ( -
    - {snapshot.operators.map((operator) => { - const wait = copy.wait(operator); - const work = snapshot.work.find((candidate) => - operator.scheduledWorkIds.includes(candidate.workId), - ); - return ( -
  • -
  • - ); - })} -
- )} - {snapshot.omitted.operators > 0 ? ( -
- {copy.hiddenOperators(snapshot.omitted.operators)} -
- ) : null} - {snapshot.finish ? ( -
- {copy.selectedResults} - {snapshot.finish.resultIds.join(', ')} -
- ) : null} - - ) : null} -
- ) : null} -
- ); -} - -function sameEpochPage( - cached: AgentGraphEpochDirectory, - currentPage: AgentGraphEpochDirectory, -): boolean { - if (!currentPage.truncated && currentPage.epochs.length !== cached.epochs.length) return false; - return currentPage.epochs.every((entry, index) => { - const previous = cached.epochs[index]; - return ( - previous?.epoch === entry.epoch && - previous.graphId === entry.graphId && - previous.current === entry.current - ); - }); -} - -function firstWait(operator: AgentGraphClientOperator) { - return operator.readiness.find((readiness) => readiness.status === 'waiting')?.waitingFor[0]; -} - -function waitReasonEn(operator: AgentGraphClientOperator): string | undefined { - const wait = firstWait(operator); - if (!wait) return undefined; - if (wait.kind === 'input_route') { - return `Waiting for input from ${wait.upstreamOperatorIds.join(', ')}`; - } - if (wait.kind === 'activation_missing') { - return `Waiting for ${wait.operatorId} activation`; - } - return `Waiting for ${wait.operatorId} to settle`; -} - -function waitReasonZh(operator: AgentGraphClientOperator): string | undefined { - const wait = firstWait(operator); - if (!wait) return undefined; - if (wait.kind === 'input_route') { - return `等待 ${wait.upstreamOperatorIds.join('、')} 的输入`; - } - if (wait.kind === 'activation_missing') { - return `等待 ${wait.operatorId} activation`; - } - return `等待 ${wait.operatorId} 结束`; -} - -function waitReasonZhTw(operator: AgentGraphClientOperator): string | undefined { - const wait = firstWait(operator); - if (!wait) return undefined; - if (wait.kind === 'input_route') { - return `等待 ${wait.upstreamOperatorIds.join('、')} 的輸入`; - } - if (wait.kind === 'activation_missing') { - return `等待 ${wait.operatorId} 啟用`; - } - return `等待 ${wait.operatorId} 結束`; -} diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index f6473dd7e7..a0670a5610 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -82,7 +82,7 @@ import { import { deriveWorkspaceReadinessRecovery } from './workspace-readiness-recovery'; import { LiveTurnReconciler } from './live-turn-reconciler'; import { useAppShellSessionUiReads } from './use-app-shell-session-ui-reads'; -import { AgentGraphPanel } from './agent-graph-panel'; +import { AgentGraphPanel } from './features/agent-graph'; import { ChatComposerRegion, selectLatestRequestUsage } from './chat-composer-region'; import { WorkbarHost, diff --git a/apps/desktop/src/renderer/composition/desktop-feature-services.tsx b/apps/desktop/src/renderer/composition/desktop-feature-services.tsx index 91755a2677..9b4196ad68 100644 --- a/apps/desktop/src/renderer/composition/desktop-feature-services.tsx +++ b/apps/desktop/src/renderer/composition/desktop-feature-services.tsx @@ -18,6 +18,7 @@ */ import type { ReactNode } from 'react'; +import { AgentGraphServicesProvider } from '../features/agent-graph'; import { ConnectionSettingsServicesProvider } from '../features/connection-settings'; import { GoalServicesProvider } from '../features/goals'; import { ModuleHubServicesProvider } from '../features/module-hub'; @@ -27,6 +28,7 @@ import { SessionNavigationServicesProvider } from '../features/session-navigatio import { SessionSettingsServicesProvider } from '../features/session-settings'; import { TaskEntryServicesProvider } from '../features/task-entry'; import { WorkbarServicesProvider } from '../features/workbar'; +import { createDesktopAgentGraphServices } from '../platform/desktop/create-agent-graph-services'; import { createDesktopGoalServices } from '../platform/desktop/create-goal-services'; import { createDesktopConnectionSettingsServices } from '../platform/desktop/create-connection-settings-services'; import { createDesktopModuleHubServices } from '../platform/desktop/create-module-hub-services'; @@ -39,6 +41,7 @@ import { createDesktopWorkbarServices } from '../platform/desktop/create-workbar export function createDesktopFeatureServices() { return { + agentGraph: createDesktopAgentGraphServices(), connectionSettings: createDesktopConnectionSettingsServices(), goal: createDesktopGoalServices(), moduleHub: createDesktopModuleHubServices(), @@ -56,6 +59,7 @@ export function DesktopFeatureServicesProvider(props: { readonly children?: ReactNode; }) { return ( + @@ -75,5 +79,6 @@ export function DesktopFeatureServicesProvider(props: { + ); } diff --git a/apps/desktop/src/renderer/agent-graph-refresh.ts b/apps/desktop/src/renderer/features/agent-graph/controller/agent-graph-refresh.ts similarity index 100% rename from apps/desktop/src/renderer/agent-graph-refresh.ts rename to apps/desktop/src/renderer/features/agent-graph/controller/agent-graph-refresh.ts diff --git a/apps/desktop/src/renderer/features/agent-graph/index.ts b/apps/desktop/src/renderer/features/agent-graph/index.ts new file mode 100644 index 0000000000..8936f792d4 --- /dev/null +++ b/apps/desktop/src/renderer/features/agent-graph/index.ts @@ -0,0 +1,22 @@ +/* + * 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. + */ + +export { AgentGraphPanel } from './ui/agent-graph-panel'; +export { AgentGraphServicesProvider } from './services-context'; +export type { AgentGraphServices } from './ports'; diff --git a/apps/desktop/src/renderer/agent-graph-panel-visibility.ts b/apps/desktop/src/renderer/features/agent-graph/model/agent-graph-panel-visibility.ts similarity index 100% rename from apps/desktop/src/renderer/agent-graph-panel-visibility.ts rename to apps/desktop/src/renderer/features/agent-graph/model/agent-graph-panel-visibility.ts diff --git a/apps/desktop/src/renderer/features/agent-graph/ports.ts b/apps/desktop/src/renderer/features/agent-graph/ports.ts new file mode 100644 index 0000000000..36593332b7 --- /dev/null +++ b/apps/desktop/src/renderer/features/agent-graph/ports.ts @@ -0,0 +1,50 @@ +/* + * 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 type { + AgentGraphClientSnapshot, + AgentGraphClientSnapshotOptions, + AgentGraphOperatorInspection, +} from '@maka/runtime/stream-graph-read-model'; +import type { AgentGraphEpochSummary } from '@maka/runtime-host/protocol'; + +export interface AgentGraphEpochDirectory { + readonly epochs: readonly AgentGraphEpochSummary[]; + readonly truncated: boolean; +} + +export interface AgentGraphService { + listEpochs(rootSessionId: string): Promise; + listCurrentEpochs(rootSessionId: string): Promise; + getSnapshot( + rootSessionId: string, + options?: AgentGraphClientSnapshotOptions & { graphId?: string }, + ): Promise; + inspectOperator( + rootSessionId: string, + operatorId: string, + graphId?: string, + ): Promise; + stop(rootSessionId: string, expectedGraphId: string): Promise; + subscribe(rootSessionId: string, handler: () => void): () => void; +} + +export interface AgentGraphServices { + readonly graphs: AgentGraphService; +} diff --git a/apps/desktop/src/renderer/features/agent-graph/services-context.tsx b/apps/desktop/src/renderer/features/agent-graph/services-context.tsx new file mode 100644 index 0000000000..643ae231ef --- /dev/null +++ b/apps/desktop/src/renderer/features/agent-graph/services-context.tsx @@ -0,0 +1,40 @@ +/* + * 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 { createContext, useContext, type ReactNode } from 'react'; +import type { AgentGraphServices } from './ports.js'; + +const AgentGraphServicesContext = createContext(null); + +export function AgentGraphServicesProvider(props: { + readonly services: AgentGraphServices; + readonly children?: ReactNode; +}) { + return ( + + {props.children} + + ); +} + +export function useAgentGraphServices(): AgentGraphServices { + const services = useContext(AgentGraphServicesContext); + if (!services) throw new Error('AgentGraphServicesProvider is missing'); + return services; +} diff --git a/apps/desktop/src/renderer/features/agent-graph/stories.ts b/apps/desktop/src/renderer/features/agent-graph/stories.ts new file mode 100644 index 0000000000..9f92222853 --- /dev/null +++ b/apps/desktop/src/renderer/features/agent-graph/stories.ts @@ -0,0 +1,22 @@ +/* + * 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. + */ + +export { AgentGraphServicesProvider } from './services-context.js'; +export { AgentGraphPanel, getAgentGraphPanelCopy } from './ui/agent-graph-panel.js'; +export type { AgentGraphServices } from './ports.js'; diff --git a/apps/desktop/src/renderer/features/agent-graph/testing.ts b/apps/desktop/src/renderer/features/agent-graph/testing.ts new file mode 100644 index 0000000000..0b7e61b43f --- /dev/null +++ b/apps/desktop/src/renderer/features/agent-graph/testing.ts @@ -0,0 +1,37 @@ +/* + * 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. + */ + +export { AgentGraphPanel, getAgentGraphPanelCopy } from './ui/agent-graph-panel.js'; +export { AgentGraphServicesProvider } from './services-context.js'; +export type { AgentGraphServices } from './ports.js'; +export { + agentGraphEdgePath, + agentGraphStatusSemantic, + firstScheduledWorkPreview, + layoutAgentGraph, + revealAgentGraphNode, + scheduledWorkPresentation, +} from './ui/agent-graph-topology.js'; +export { createAgentGraphRefreshScheduler } from './controller/agent-graph-refresh.js'; +export { + dismissAgentGraphPanel, + isAgentGraphPanelDismissible, + reconcileAgentGraphPanelDismissals, + shouldShowAgentGraphPanel, +} from './model/agent-graph-panel-visibility.js'; diff --git a/apps/desktop/src/renderer/features/agent-graph/ui/agent-graph-panel.tsx b/apps/desktop/src/renderer/features/agent-graph/ui/agent-graph-panel.tsx new file mode 100644 index 0000000000..a52ef232c1 --- /dev/null +++ b/apps/desktop/src/renderer/features/agent-graph/ui/agent-graph-panel.tsx @@ -0,0 +1,1190 @@ +/* + * 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, + useId, + useLayoutEffect, + useMemo, + useRef, + useState, + type JSX, + type ReactNode, + type RefObject, +} from 'react'; +import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; +import type { + AgentGraphClientOperator, + AgentGraphClientRunRef, + AgentGraphClientSnapshot, + AgentGraphOperatorInspection, +} from '@maka/runtime/stream-graph-read-model'; +import type { AgentGraphEpochSummary } from '@maka/runtime-host/protocol'; +import { IconButton, Selector, type SelectorOptionType } from '@maka/ui'; +import { ICON_SIZE, ChevronDown, X } from '@maka/ui/icons'; +import { Banner } from '@astryxdesign/core/Banner'; +import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl'; +import { Button } from '@astryxdesign/core/Button'; +import { EmptyState } from '@astryxdesign/core/EmptyState'; +import { Spinner } from '@astryxdesign/core/Spinner'; +import { + dismissAgentGraphPanel, + isAgentGraphPanelDismissible, + reconcileAgentGraphPanelDismissals, + shouldShowAgentGraphPanel, + type AgentGraphPanelDismissals, +} from '../model/agent-graph-panel-visibility.js'; +import { + createAgentGraphRefreshScheduler, + type AgentGraphRefreshScheduler, +} from '../controller/agent-graph-refresh.js'; +import type { AgentGraphEpochDirectory } from '../ports.js'; +import { useAgentGraphServices } from '../services-context.js'; +import { + AgentGraphTopology, + AgentGraphStatusDot, + firstScheduledWorkPreview, + scheduledWorkPresentation, +} from './agent-graph-topology.js'; + +const noopAgentGraphRefreshScheduler: AgentGraphRefreshScheduler = { + requestRefresh() {}, + invalidateAndRefresh() {}, + isCurrent: () => false, + dispose() {}, +}; + +const GRAPH_DATE_TIME_FORMATTERS: Record = { + en: new Intl.DateTimeFormat('en-US', { dateStyle: 'medium', timeStyle: 'medium' }), + 'zh-CN': new Intl.DateTimeFormat('zh-CN', { dateStyle: 'medium', timeStyle: 'medium' }), + 'zh-TW': new Intl.DateTimeFormat('zh-TW', { dateStyle: 'medium', timeStyle: 'medium' }), +}; + +function hasSameInspectionPresentation( + current: AgentGraphOperatorInspection | undefined, + next: AgentGraphOperatorInspection, +): boolean { + return ( + current?.graphId === next.graphId && + current.operator.operatorId === next.operator.operatorId && + current.snapshotVersion === next.snapshotVersion + ); +} + +type GraphPanelCopy = { + title: string; + loading: string; + retry: string; + collapse: string; + expand: string; + dismiss: string; + stop: string; + stopping: string; + stopFailed: string; + loadFailed: string; + openSession: string; + operators: string; + topology: string; + list: string; + view: string; + details: string; + inspectOperator(name: string): string; + detailsLoading: string; + detailsFailed: string; + openSessionFor(name: string): string; + workOmitted(count: number): string; + workItems: string; + dependenciesDetails: string; + activations: string; + claims: string; + recentActivity: string; + fromOperator(name: string): string; + toOperator(name: string): string; + omittedItems(count: number): string; + records(count: number): string; + activationRecords(count: number): string; + edgeSummary(inbound: number, outbound: number): string; + selectedResults: string; + epoch: string; + currentEpoch: string; + historicalEpoch: string; + cappedEpochs(count: number): string; + noOperators: string; + hiddenTopology(operators: number, edges: number, work: number): string; + dependencies(upstream: readonly string[], downstream: readonly string[]): string | undefined; + progress(settled: number, total: number, hasOmitted: boolean): string; + status(status: AgentGraphClientSnapshot['status']): string; + operatorStatus(status: AgentGraphClientOperator['status']): string; + wait(operator: AgentGraphClientOperator): string | undefined; +}; + +const AGENT_GRAPH_PANEL_COPY = { + 'zh-CN': { + title: 'Agent Graph', + loading: '正在读取 Graph 状态…', + retry: '重试', + collapse: '收起 Agent Graph', + expand: '展开 Agent Graph', + dismiss: '关闭 Agent Graph', + stop: '停止 Graph', + stopping: '停止中…', + stopFailed: '停止 Graph 失败,请重试。', + loadFailed: 'Graph 状态刷新失败。', + openSession: '打开子任务', + operators: 'Operators', + topology: '拓扑', + list: '列表', + view: 'Agent Graph 视图', + details: 'Operator 详情', + inspectOperator: (name) => `查看 ${name} 详情`, + detailsLoading: '正在读取 operator 详情…', + detailsFailed: '无法读取 operator 详情。', + openSessionFor: (name) => `打开 ${name} 子任务`, + workOmitted: (count) => `另有 ${count} 项 work 已省略`, + workItems: 'Work', + dependenciesDetails: '依赖关系', + activations: 'Activations', + claims: 'Claims', + recentActivity: '近期活动', + fromOperator: (name) => `来自 ${name}`, + toOperator: (name) => `前往 ${name}`, + omittedItems: (count) => `另有 ${count} 项已省略`, + records: (count) => `${count} 条记录`, + activationRecords: (count) => `${count} 条 activation 记录`, + edgeSummary: (inbound, outbound) => `${inbound} 条入边 · ${outbound} 条出边`, + selectedResults: '已选择结果', + epoch: 'Graph 运行轮次', + currentEpoch: '当前', + historicalEpoch: '历史记录(只读)', + cappedEpochs: (count) => `仅显示最近 ${count} 次运行`, + noOperators: '等待主 Agent 创建 operator…', + hiddenTopology: (operators, edges, work) => { + const parts = [ + ...(operators > 0 ? [`${operators} 个 operator`] : []), + ...(edges > 0 ? [`${edges} 条边`] : []), + ...(work > 0 ? [`${work} 项 work`] : []), + ]; + return `当前拓扑不完整:省略 ${parts.join('、')}`; + }, + dependencies: (upstream, downstream) => { + const parts = [ + ...(upstream.length > 0 ? [`依赖 ${upstream.join('、')}`] : []), + ...(downstream.length > 0 ? [`下游 ${downstream.join('、')}`] : []), + ]; + return parts.length > 0 ? parts.join(' · ') : undefined; + }, + progress: (settled, total, hasOmitted) => + hasOmitted ? `可见 ${settled}/${total} 已结束` : `${settled}/${total} 已结束`, + status: (status) => + ({ + empty: '等待调度', + active: '运行中', + closing: '收尾中', + waiting: '等待中', + stopped: '已停止', + failed: '失败', + completed: '已完成', + })[status], + operatorStatus: (status) => + ({ + not_started: '未启动', + waiting: '等待', + runnable: '可运行', + running: '运行中', + blocked: '受阻', + completed: '完成', + failed: '失败', + aborted: '中止', + cancelled: '取消', + })[status], + wait: waitReasonZh, + }, + 'zh-TW': { + title: 'Agent Graph', + loading: '正在讀取 Graph 狀態…', + retry: '重試', + collapse: '收起 Agent Graph', + expand: '展開 Agent Graph', + dismiss: '關閉 Agent Graph', + stop: '停止 Graph', + stopping: '停止中…', + stopFailed: '停止 Graph 失敗,請重試。', + loadFailed: 'Graph 狀態重新整理失敗。', + openSession: '開啟子任務', + operators: 'Operators', + topology: '拓撲', + list: '列表', + view: 'Agent Graph 檢視', + details: 'Operator 詳情', + inspectOperator: (name) => `檢視 ${name} 詳情`, + detailsLoading: '正在讀取 operator 詳情…', + detailsFailed: '無法讀取 operator 詳情。', + openSessionFor: (name) => `開啟 ${name} 子任務`, + workOmitted: (count) => `另有 ${count} 項 work 已省略`, + workItems: 'Work', + dependenciesDetails: '相依關係', + activations: 'Activations', + claims: 'Claims', + recentActivity: '近期活動', + fromOperator: (name) => `來自 ${name}`, + toOperator: (name) => `前往 ${name}`, + omittedItems: (count) => `另有 ${count} 項已省略`, + records: (count) => `${count} 筆記錄`, + activationRecords: (count) => `${count} 筆 activation 記錄`, + edgeSummary: (inbound, outbound) => `${inbound} 條入邊 · ${outbound} 條出邊`, + selectedResults: '已選取結果', + epoch: 'Graph 執行輪次', + currentEpoch: '目前', + historicalEpoch: '歷史記錄(唯讀)', + cappedEpochs: (count) => `僅顯示最近 ${count} 次執行`, + noOperators: '等待主 Agent 建立 operator…', + hiddenTopology: (operators, edges, work) => { + const parts = [ + ...(operators > 0 ? [`${operators} 個 operator`] : []), + ...(edges > 0 ? [`${edges} 條邊`] : []), + ...(work > 0 ? [`${work} 項 work`] : []), + ]; + return `目前拓撲不完整:省略 ${parts.join('、')}`; + }, + dependencies: (upstream, downstream) => { + const parts = [ + ...(upstream.length > 0 ? [`相依 ${upstream.join('、')}`] : []), + ...(downstream.length > 0 ? [`下游 ${downstream.join('、')}`] : []), + ]; + return parts.length > 0 ? parts.join(' · ') : undefined; + }, + progress: (settled, total, hasOmitted) => + hasOmitted ? `可見 ${settled}/${total} 已結束` : `${settled}/${total} 已結束`, + status: (status) => + ({ + empty: '等待排程', + active: '執行中', + closing: '收尾中', + waiting: '等待中', + stopped: '已停止', + failed: '失敗', + completed: '已完成', + })[status], + operatorStatus: (status) => + ({ + not_started: '尚未啟動', + waiting: '等待', + runnable: '可執行', + running: '執行中', + blocked: '受阻', + completed: '完成', + failed: '失敗', + aborted: '已中止', + cancelled: '已取消', + })[status], + wait: waitReasonZhTw, + }, + en: { + title: 'Agent Graph', + loading: 'Loading graph state…', + retry: 'Retry', + collapse: 'Collapse Agent Graph', + expand: 'Expand Agent Graph', + dismiss: 'Dismiss Agent Graph', + stop: 'Stop graph', + stopping: 'Stopping…', + stopFailed: 'Could not stop the graph. Try again.', + loadFailed: 'Could not refresh graph state.', + openSession: 'Open child task', + operators: 'Operators', + topology: 'Topology', + list: 'List', + view: 'Agent Graph view', + details: 'Operator details', + inspectOperator: (name) => `View ${name} details`, + detailsLoading: 'Loading operator details…', + detailsFailed: 'Could not load operator details.', + openSessionFor: (name) => `Open ${name} child task`, + workOmitted: (count) => `${count} more work item${count === 1 ? '' : 's'} omitted`, + workItems: 'Work', + dependenciesDetails: 'Dependencies', + activations: 'Activations', + claims: 'Claims', + recentActivity: 'Recent activity', + fromOperator: (name) => `From ${name}`, + toOperator: (name) => `To ${name}`, + omittedItems: (count) => `${count} more omitted`, + records: (count) => `${count} record${count === 1 ? '' : 's'}`, + activationRecords: (count) => `${count} activation record${count === 1 ? '' : 's'}`, + edgeSummary: (inbound, outbound) => `${inbound} inbound · ${outbound} outbound`, + selectedResults: 'Selected results', + epoch: 'Graph run', + currentEpoch: 'Current', + historicalEpoch: 'History (read-only)', + cappedEpochs: (count) => `Showing the newest ${count} runs`, + noOperators: 'Waiting for the main agent to create an operator…', + hiddenTopology: (operators, edges, work) => { + const parts = [ + ...(operators > 0 ? [`${operators} operator${operators === 1 ? '' : 's'}`] : []), + ...(edges > 0 ? [`${edges} edge${edges === 1 ? '' : 's'}`] : []), + ...(work > 0 ? [`${work} work item${work === 1 ? '' : 's'}`] : []), + ]; + return `Partial topology: ${parts.join(', ')} omitted`; + }, + dependencies: (upstream, downstream) => { + const parts = [ + ...(upstream.length > 0 ? [`Depends on ${upstream.join(', ')}`] : []), + ...(downstream.length > 0 ? [`Feeds ${downstream.join(', ')}`] : []), + ]; + return parts.length > 0 ? parts.join(' · ') : undefined; + }, + progress: (settled, total, hasOmitted) => + hasOmitted ? `${settled}/${total} visible settled` : `${settled}/${total} settled`, + status: (status) => + ({ + empty: 'Awaiting schedule', + active: 'Running', + closing: 'Finishing', + waiting: 'Waiting', + stopped: 'Stopped', + failed: 'Failed', + completed: 'Completed', + })[status], + operatorStatus: (status) => + ({ + not_started: 'Not started', + waiting: 'Waiting', + runnable: 'Runnable', + running: 'Running', + blocked: 'Blocked', + completed: 'Completed', + failed: 'Failed', + aborted: 'Aborted', + cancelled: 'Cancelled', + })[status], + wait: waitReasonEn, + }, +} satisfies UiCatalog; + +export function getAgentGraphPanelCopy(locale: UiLocale): GraphPanelCopy { + return AGENT_GRAPH_PANEL_COPY[locale]; +} + +export function AgentGraphPanel(props: { + rootSessionId: string; + enabled: boolean; + locale: UiLocale; + onOpenSession(sessionId: string): void; +}): JSX.Element | null { + const { graphs } = useAgentGraphServices(); + const [snapshot, setSnapshot] = useState(); + const [epochs, setEpochs] = useState([]); + const [epochsTruncated, setEpochsTruncated] = useState(false); + const [selectedGraphId, setSelectedGraphId] = useState(); + const [loading, setLoading] = useState(props.enabled); + const [error, setError] = useState(false); + const [stopState, setStopState] = useState({ + rootSessionId: props.rootSessionId, + graphId: undefined as string | undefined, + requestId: 0, + pending: false, + error: false, + }); + const [collapsed, setCollapsed] = useState(); + const [view, setView] = useState<'topology' | 'list'>('topology'); + const [selectedOperatorId, setSelectedOperatorId] = useState(); + const [inspection, setInspection] = useState(); + const [inspectionState, setInspectionState] = useState<'idle' | 'loading' | 'error'>('idle'); + const [dismissedBySession, setDismissedBySession] = useState({}); + const contentId = useId(); + const detailsId = useId(); + const refreshRef = useRef(noopAgentGraphRefreshScheduler); + const selectedGraphIdRef = useRef(undefined); + const followCurrentRef = useRef(true); + const stopRequestIdRef = useRef(0); + const inspectionIdentityRef = useRef(undefined); + const previousDetailsPresentationRef = useRef< + { identity: string; view: 'topology' | 'list' } | undefined + >(undefined); + const detailsHeadingRef = useRef(null); + const copy = getAgentGraphPanelCopy(props.locale); + const stopFeedbackMatchesSelection = + stopState.rootSessionId === props.rootSessionId && stopState.graphId === selectedGraphId; + const stopPending = stopFeedbackMatchesSelection && stopState.pending; + const stopError = stopFeedbackMatchesSelection && stopState.error; + + useEffect(() => { + setSnapshot(undefined); + setEpochs([]); + setEpochsTruncated(false); + setSelectedGraphId(undefined); + selectedGraphIdRef.current = undefined; + followCurrentRef.current = true; + setError(false); + setStopState({ + rootSessionId: props.rootSessionId, + graphId: undefined, + requestId: ++stopRequestIdRef.current, + pending: false, + error: false, + }); + setCollapsed(undefined); + setView('topology'); + setSelectedOperatorId(undefined); + setInspection(undefined); + setInspectionState('idle'); + setLoading(props.enabled); + let cachedDirectory: AgentGraphEpochDirectory | undefined; + + const scheduler = createAgentGraphRefreshScheduler(async (fence) => { + if (!cachedDirectory) setLoading(true); + try { + let directory: AgentGraphEpochDirectory; + if (!cachedDirectory) { + directory = await graphs.listEpochs(props.rootSessionId); + } else { + const currentPage = await graphs.listCurrentEpochs(props.rootSessionId); + directory = sameEpochPage(cachedDirectory, currentPage) + ? cachedDirectory + : await graphs.listEpochs(props.rootSessionId); + } + if (!scheduler.isCurrent(fence)) return; + cachedDirectory = directory; + const nextEpochs = directory.epochs; + const current = nextEpochs.find((entry) => entry.current) ?? nextEpochs[0]; + const selected = followCurrentRef.current + ? current + : nextEpochs.find((entry) => entry.graphId === selectedGraphIdRef.current); + // An evicted selection must not pin the panel on the fallback: + // resume following the current epoch so later rollovers refresh. + if (!selected && !followCurrentRef.current) { + followCurrentRef.current = true; + } + const graphId = (selected ?? current)?.graphId; + if (!graphId) throw new Error('Agent graph epoch directory is empty'); + selectedGraphIdRef.current = graphId; + const next = await graphs.getSnapshot(props.rootSessionId, { graphId }); + if (scheduler.isCurrent(fence) && next.graphId === selectedGraphIdRef.current) { + setEpochs(nextEpochs); + setEpochsTruncated(directory.truncated); + setSelectedGraphId(graphId); + setCollapsed((current) => current ?? next.status === 'completed'); + setSnapshot(next); + setError(false); + } + } catch { + if (scheduler.isCurrent(fence)) setError(true); + } finally { + if (scheduler.isCurrent(fence)) setLoading(false); + } + }); + + refreshRef.current = scheduler; + const unsubscribe = graphs.subscribe(props.rootSessionId, () => + scheduler.requestRefresh(), + ); + scheduler.requestRefresh(); + return () => { + scheduler.dispose(); + if (refreshRef.current === scheduler) { + refreshRef.current = noopAgentGraphRefreshScheduler; + } + unsubscribe(); + }; + }, [graphs, props.rootSessionId, props.enabled]); + + useEffect(() => { + if (!snapshot || !selectedOperatorId) { + inspectionIdentityRef.current = undefined; + setInspection(undefined); + setInspectionState('idle'); + return; + } + const identity = `${props.rootSessionId}:${snapshot.graphId}:${selectedOperatorId}`; + if (inspectionIdentityRef.current !== identity) { + inspectionIdentityRef.current = identity; + setInspection(undefined); + } + setInspectionState('loading'); + let active = true; + void graphs + .inspectOperator(props.rootSessionId, selectedOperatorId, snapshot.graphId) + .then((next) => { + if (!active) return; + setInspection((current) => (hasSameInspectionPresentation(current, next) ? current : next)); + setInspectionState('idle'); + }) + .catch(() => { + if (!active) return; + setInspectionState('error'); + }); + return () => { + active = false; + }; + }, [graphs, props.rootSessionId, selectedOperatorId, snapshot?.graphId, snapshot?.snapshotVersion]); + + useEffect(() => { + setDismissedBySession((current) => + reconcileAgentGraphPanelDismissals( + current, + props.rootSessionId, + snapshot + ? { + rootSessionId: snapshot.rootSessionId, + graphId: snapshot.graphId, + status: snapshot.status, + } + : undefined, + ), + ); + }, [props.rootSessionId, snapshot]); + + const progress = useMemo(() => { + const settled = snapshot?.operators.filter((operator) => + ['completed', 'failed', 'aborted', 'cancelled'].includes(operator.status), + ).length ?? 0; + return { settled, total: snapshot?.operators.length ?? 0 }; + }, [snapshot]); + const selectedEpoch = epochs.find((entry) => entry.graphId === selectedGraphId); + const selectedOperator = selectedOperatorId + ? snapshot?.operators.find((operator) => operator.operatorId === selectedOperatorId) + : undefined; + const selectedDetailsIdentity = + snapshot && selectedOperator + ? `${snapshot.graphId}:${selectedOperator.operatorId}` + : undefined; + const selectedInspection = + snapshot && + selectedOperator && + inspection?.operator.operatorId === selectedOperator.operatorId && + inspection.graphId === snapshot.graphId + ? inspection + : undefined; + useLayoutEffect(() => { + const previous = previousDetailsPresentationRef.current; + if ( + view === 'list' && + selectedDetailsIdentity && + (previous?.identity !== selectedDetailsIdentity || + previous.view !== 'list') + ) { + detailsHeadingRef.current?.scrollIntoView?.({ behavior: 'auto', block: 'nearest' }); + } + previousDetailsPresentationRef.current = selectedDetailsIdentity + ? { identity: selectedDetailsIdentity, view } + : undefined; + }, [selectedDetailsIdentity, view]); + const toggleOperator = (operatorId: string) => + setSelectedOperatorId((current) => (current === operatorId ? undefined : operatorId)); + const selectedDetails = + snapshot && selectedOperator && selectedDetailsIdentity ? ( + + ) : null; + + const hasGraphActivity = + snapshot !== undefined && + (snapshot.scheduleRevision > 0 || + snapshot.operators.length > 0 || + snapshot.omitted.operators > 0); + const hasGraphHistory = epochs.length > 1; + if ( + !shouldShowAgentGraphPanel({ + enabled: props.enabled, + hasGraphActivity: hasGraphActivity || hasGraphHistory, + error, + sessionId: props.rootSessionId, + graphId: snapshot?.graphId, + status: snapshot?.status, + dismissedBySession, + }) + ) { + return null; + } + + const stopGraph = async (expectedGraphId: string): Promise => { + if (stopPending) return; + const rootSessionId = props.rootSessionId; + const requestId = ++stopRequestIdRef.current; + setStopState({ rootSessionId, graphId: expectedGraphId, requestId, pending: true, error: false }); + try { + await graphs.stop(rootSessionId, expectedGraphId); + } catch { + setStopState((current) => + current.rootSessionId === rootSessionId && current.requestId === requestId + ? { ...current, error: true } + : current, + ); + } finally { + setStopState((current) => + current.rootSessionId === rootSessionId && current.requestId === requestId + ? { ...current, pending: false } + : current, + ); + } + }; + const stopAvailable = + selectedEpoch?.current === true && + !loading && + snapshot !== undefined && + snapshot.graphId === selectedGraphId && + ['active', 'waiting', 'closing'].includes(snapshot.status); + const dismissAvailable = + selectedEpoch?.current === true && + !loading && + snapshot !== undefined && + snapshot.graphId === selectedGraphId && + isAgentGraphPanelDismissible(snapshot.status); + + return ( +
+
+
+ {copy.title} + {epochs.length > 1 && snapshot ? ( + ({ + value: entry.graphId, + label: `#${entry.epoch} · ${entry.current ? copy.currentEpoch : copy.historicalEpoch}`, + }))} + onChange={(graphId: SelectorOptionType) => { + if (typeof graphId !== 'string') return; + selectedGraphIdRef.current = graphId; + setSelectedGraphId(graphId); + followCurrentRef.current = + epochs.find((entry) => entry.graphId === graphId)?.current === true; + refreshRef.current.invalidateAndRefresh(); + }} + /> + ) : null} + {epochsTruncated ? ( + {copy.cappedEpochs(epochs.length)} + ) : null} + {snapshot ? ( + + {copy.status(snapshot.status)} ·{' '} + {copy.progress( + progress.settled, + progress.total, + snapshot.omitted.operators > 0, + )} + + ) : null} +
+
+ {stopAvailable ? ( +
+
+ {!collapsed ? ( +
+ {stopError ? ( + + ) : null} + + {loading && !snapshot ? ( + + ) : null} + {error ? ( + refreshRef.current.requestRefresh()} + /> + )} + /> + ) : null} + + {snapshot ? ( + <> +
{copy.operators}
+ {snapshot.operators.length === 0 ? ( + + ) : ( + <> +
+ setView(value as 'topology' | 'list')} + > + + + +
+ {view === 'topology' ? ( + <> + + {selectedDetails} + + ) : ( +
    + {snapshot.operators.map((operator) => { + const wait = copy.wait(operator); + const work = scheduledWorkPresentation(operator, snapshot.work); + const visibleWork = work.preview + ? [work.preview, work.omitted > 0 ? copy.workOmitted(work.omitted) : undefined] + .filter(Boolean) + .join(' · ') + : work.omitted > 0 + ? copy.workOmitted(work.omitted) + : undefined; + const relations = copy.dependencies( + snapshot.edges + .filter((edge) => edge.toOperatorId === operator.operatorId) + .map((edge) => agentName(snapshot, edge.fromOperatorId)), + snapshot.edges + .filter((edge) => edge.fromOperatorId === operator.operatorId) + .map((edge) => agentName(snapshot, edge.toOperatorId)), + ); + return ( +
  • + + + + {operator.agentId} + + {copy.operatorStatus(operator.status)} + + + {visibleWork ? {visibleWork} : null} + {relations ? ( + {relations} + ) : null} + {wait ? {wait} : null} + + + + {selectedOperatorId === operator.operatorId ? selectedDetails : null} +
  • + ); + })} +
+ )} + + )} + {snapshot.omitted.operators > 0 || snapshot.omitted.edges > 0 || snapshot.omitted.work > 0 ? ( +
+ {copy.hiddenTopology(snapshot.omitted.operators, snapshot.omitted.edges, snapshot.omitted.work)} +
+ ) : null} + {snapshot.finish ? ( +
+ {copy.selectedResults} + {snapshot.finish.resultIds.join(', ')} +
+ ) : null} + + ) : null} +
+ ) : null} +
+ ); +} + +function AgentGraphOperatorDetails(props: { + id: string; + operator: AgentGraphClientOperator; + snapshot: AgentGraphClientSnapshot; + inspection: AgentGraphOperatorInspection | undefined; + state: 'idle' | 'loading' | 'error'; + copy: GraphPanelCopy; + locale: UiLocale; + headingRef: RefObject; + onOpenSession(sessionId: string): void; +}) { + const operator = props.operator; + const wait = props.copy.wait(operator); + const inspection = props.inspection; + const snapshotWork = firstScheduledWorkPreview(operator, props.snapshot.work); + return ( +
+
+ {operator.agentId} + {props.copy.operatorStatus(operator.status)} +
+ {!inspection && snapshotWork ?

{snapshotWork}

: null} + {wait ?

{wait}

: null} + {props.state === 'loading' && !props.inspection ? {props.copy.detailsLoading} : null} + {props.state === 'error' ? {props.copy.detailsFailed} : null} + {inspection ? ( + <> + + {props.copy.edgeSummary( + inspection.inboundEdges.length + inspection.omitted.inboundEdges, + inspection.outboundEdges.length + inspection.omitted.outboundEdges, + )} ·{' '} + {props.copy.activationRecords( + inspection.activations.length + inspection.omitted.activations, + )} + + ({ + key: entry.workId, + text: `${humanizeGraphValue(entry.status)} · ${entry.instructionPreview}`, + }))} + omitted={inspection.omitted.work} + omittedItems={props.copy.omittedItems} + /> + ({ + key: edge.edgeId, + text: props.copy.fromOperator(agentName(props.snapshot, edge.fromOperatorId)), + })), + ...inspection.outboundEdges.map((edge) => ({ + key: edge.edgeId, + text: props.copy.toOperator(agentName(props.snapshot, edge.toOperatorId)), + })), + ]} + omitted={inspection.omitted.inboundEdges + inspection.omitted.outboundEdges} + omittedItems={props.copy.omittedItems} + /> + ({ + key: activation.activationId, + text: `${humanizeGraphValue(activation.status)} · ${props.copy.records(activation.recordCount)} · ${activation.activationId}`, + metadata: [ + graphTimeMetadata('firstEventTime', activation.firstEventTime, props.locale), + graphTimeMetadata('lastEventTime', activation.lastEventTime, props.locale), + { label: 'lastRecordId', value: activation.lastRecordId }, + ...(activation.terminalRecordId + ? [{ label: 'terminalRecordId', value: activation.terminalRecordId }] + : []), + ...graphRunMetadata(activation.run), + ], + }))} + omitted={inspection.omitted.activations} + omittedItems={props.copy.omittedItems} + /> + ({ + key: claim.claimId, + text: `${humanizeGraphValue(claim.admissionState)} · ${claim.claimId}`, + metadata: [ + { label: 'intentId', value: claim.intentId }, + { label: 'childSessionId', value: claim.childSessionId }, + graphTimeMetadata('claimedAt', claim.claimedAt, props.locale), + ...graphRunMetadata(claim.run), + ], + }))} + omitted={inspection.omitted.claims} + omittedItems={props.copy.omittedItems} + /> + ({ + key: record.recordId, + text: `${record.facets.map(humanizeGraphValue).join(', ') || humanizeGraphValue('runtime_activity')} · ${record.recordId}`, + metadata: [ + { label: 'activationId', value: record.activationId }, + graphTimeMetadata('eventTime', record.eventTime, props.locale), + { + label: 'signals', + value: + record.signals.map(graphSignalLabel).join(', ') || humanizeGraphValue('none'), + }, + ...graphRunMetadata(record.run), + ], + }))} + omitted={inspection.omitted.records} + omittedItems={props.copy.omittedItems} + /> + + ) : null} + +
+ ); +} + +function AgentGraphDetailCollection(props: { + label: string; + items: readonly { + key: string; + text: string; + metadata?: readonly { label: string; value: ReactNode }[]; + }[]; + omitted: number; + omittedItems(count: number): string; +}) { + if (props.items.length === 0 && props.omitted === 0) return null; + return ( +
+ {props.label} +
    + {props.items.map((item) => ( +
  • + {item.text} + {item.metadata ? ( +
    + {item.metadata.map((entry) => ( +
    +
    {entry.label}
    +
    {entry.value}
    +
    + ))} +
    + ) : null} +
  • + ))} + {props.omitted > 0 ?
  • {props.omittedItems(props.omitted)}
  • : null} +
+
+ ); +} + +function humanizeGraphValue(value: string): string { + return value.replaceAll('_', ' '); +} + +function graphRunMetadata( + run: AgentGraphClientRunRef, +): readonly { label: string; value: string }[] { + return [ + { label: 'run.sessionId', value: run.sessionId }, + { label: 'run.agentRunId', value: run.agentRunId }, + ...(run.turnId ? [{ label: 'run.turnId', value: run.turnId }] : []), + ]; +} + +function graphTimeMetadata( + label: string, + timestamp: number, + locale: UiLocale, +): { label: string; value: ReactNode } { + const date = new Date(timestamp); + return { + label, + value: ( + + ), + }; +} + +function graphSignalLabel( + signal: AgentGraphOperatorInspection['recentRecords'][number]['signals'][number], +): string { + return signal.kind === 'attention' + ? `${signal.kind}: ${humanizeGraphValue(signal.reason)}` + : `${signal.kind}: ${humanizeGraphValue(signal.status)}`; +} + +function agentName(snapshot: AgentGraphClientSnapshot, operatorId: string): string { + return ( + snapshot.operators.find((operator) => operator.operatorId === operatorId)?.agentId ?? operatorId + ); +} + +function sameEpochPage( + cached: AgentGraphEpochDirectory, + currentPage: AgentGraphEpochDirectory, +): boolean { + if (!currentPage.truncated && currentPage.epochs.length !== cached.epochs.length) return false; + return currentPage.epochs.every((entry, index) => { + const previous = cached.epochs[index]; + return ( + previous?.epoch === entry.epoch && + previous.graphId === entry.graphId && + previous.current === entry.current + ); + }); +} + +function waitReasonEn(operator: AgentGraphClientOperator): string | undefined { + const waits = operator.readiness + .filter((readiness) => readiness.status === 'waiting') + .flatMap((readiness) => readiness.waitingFor); + const wait = waits[0]; + const reason = wait + ? wait.kind === 'input_route' + ? `Waiting for input from ${wait.upstreamOperatorIds.join(', ')}` + : wait.kind === 'activation_missing' + ? `Waiting for ${wait.operatorId} activation` + : `Waiting for ${wait.operatorId} to settle` + : undefined; + const moreWaits = Math.max(0, waits.length - 1); + const parts = [ + reason, + ...(moreWaits > 0 ? [`${moreWaits} more wait${moreWaits === 1 ? '' : 's'}`] : []), + ...(operator.omitted.readinessWaits > 0 + ? [`${operator.omitted.readinessWaits} wait${operator.omitted.readinessWaits === 1 ? '' : 's'} omitted`] + : []), + ...(operator.omitted.readiness > 0 + ? [`${operator.omitted.readiness} readiness check${operator.omitted.readiness === 1 ? '' : 's'} omitted`] + : []), + ].filter(Boolean); + return parts.length > 0 ? parts.join(' · ') : undefined; +} + +function waitReasonZh(operator: AgentGraphClientOperator): string | undefined { + const waits = operator.readiness + .filter((readiness) => readiness.status === 'waiting') + .flatMap((readiness) => readiness.waitingFor); + const wait = waits[0]; + const reason = wait + ? wait.kind === 'input_route' + ? `等待 ${wait.upstreamOperatorIds.join('、')} 的输入` + : wait.kind === 'activation_missing' + ? `等待 ${wait.operatorId} activation` + : `等待 ${wait.operatorId} 结束` + : undefined; + const moreWaits = Math.max(0, waits.length - 1); + const parts = [ + reason, + ...(moreWaits > 0 ? [`另有 ${moreWaits} 项等待条件`] : []), + ...(operator.omitted.readinessWaits > 0 + ? [`${operator.omitted.readinessWaits} 项等待条件已省略`] + : []), + ...(operator.omitted.readiness > 0 + ? [`${operator.omitted.readiness} 项 readiness 检查已省略`] + : []), + ].filter(Boolean); + return parts.length > 0 ? parts.join(' · ') : undefined; +} + +function waitReasonZhTw(operator: AgentGraphClientOperator): string | undefined { + const waits = operator.readiness + .filter((readiness) => readiness.status === 'waiting') + .flatMap((readiness) => readiness.waitingFor); + const wait = waits[0]; + const reason = wait + ? wait.kind === 'input_route' + ? `等待 ${wait.upstreamOperatorIds.join('、')} 的輸入` + : wait.kind === 'activation_missing' + ? `等待 ${wait.operatorId} activation` + : `等待 ${wait.operatorId} 結束` + : undefined; + const moreWaits = Math.max(0, waits.length - 1); + const parts = [ + reason, + ...(moreWaits > 0 ? [`另有 ${moreWaits} 項等待條件`] : []), + ...(operator.omitted.readinessWaits > 0 + ? [`${operator.omitted.readinessWaits} 項等待條件已省略`] + : []), + ...(operator.omitted.readiness > 0 + ? [`${operator.omitted.readiness} 項 readiness 檢查已省略`] + : []), + ].filter(Boolean); + return parts.length > 0 ? parts.join(' · ') : undefined; +} diff --git a/apps/desktop/src/renderer/features/agent-graph/ui/agent-graph-topology.tsx b/apps/desktop/src/renderer/features/agent-graph/ui/agent-graph-topology.tsx new file mode 100644 index 0000000000..2ebd8f1d6e --- /dev/null +++ b/apps/desktop/src/renderer/features/agent-graph/ui/agent-graph-topology.tsx @@ -0,0 +1,333 @@ +/* + * 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 { useId, useLayoutEffect, useMemo, useRef } from 'react'; +import type { + AgentGraphClientEdge, + AgentGraphClientOperator, + AgentGraphClientScheduledWork, + AgentGraphClientSnapshot, +} from '@maka/runtime/stream-graph-read-model'; +import { StatusDot } from '@astryxdesign/core/StatusDot'; +import { dotForStatus, type StatusSemantic } from '@maka/ui'; + +const NODE_WIDTH = 208; +const NODE_HEIGHT = 104; +const COLUMN_GAP = 72; +const ROW_GAP = 24; +const CANVAS_PADDING = 20; + +function compareIdentity(left: string, right: string): number { + return left === right ? 0 : left < right ? -1 : 1; +} + +export interface AgentGraphNodePosition { + readonly operatorId: string; + readonly x: number; + readonly y: number; +} + +export interface AgentGraphLayout { + readonly width: number; + readonly height: number; + readonly nodes: readonly AgentGraphNodePosition[]; +} + +export function revealAgentGraphNode( + viewport: Pick, + position: AgentGraphNodePosition, +): void { + if (position.x < viewport.scrollLeft) viewport.scrollLeft = position.x; + if (position.x + NODE_WIDTH > viewport.scrollLeft + viewport.clientWidth) { + viewport.scrollLeft = position.x + NODE_WIDTH - viewport.clientWidth; + } + if (position.y < viewport.scrollTop) viewport.scrollTop = position.y; + if (position.y + NODE_HEIGHT > viewport.scrollTop + viewport.clientHeight) { + viewport.scrollTop = position.y + NODE_HEIGHT - viewport.clientHeight; + } +} + +export function firstScheduledWorkPreview( + operator: AgentGraphClientOperator, + work: readonly AgentGraphClientScheduledWork[], +): string | undefined { + return work + .filter((candidate) => operator.scheduledWorkIds.includes(candidate.workId)) + .sort( + (left, right) => + Number(right.status === 'requested') - Number(left.status === 'requested') || + right.revision - left.revision || + right.committedAt - left.committedAt || + compareIdentity(right.workId, left.workId), + )[0]?.instructionPreview; +} + +export function scheduledWorkPresentation( + operator: AgentGraphClientOperator, + work: readonly AgentGraphClientScheduledWork[], +): { preview: string | undefined; omitted: number } { + const visibleWorkIds = new Set(work.map((entry) => entry.workId)); + const omittedVisibleReferences = operator.scheduledWorkIds.filter( + (workId) => !visibleWorkIds.has(workId), + ).length; + return { + preview: firstScheduledWorkPreview(operator, work), + omitted: operator.omitted.scheduledWorkIds + omittedVisibleReferences, + }; +} + +const OPERATOR_STATUS_SEMANTICS = { + not_started: 'neutral', + waiting: 'neutral', + runnable: 'active', + running: 'active', + blocked: 'attention', + completed: 'success', + failed: 'error', + aborted: 'error', + cancelled: 'neutral', +} satisfies Record; + +export function agentGraphStatusSemantic( + status: AgentGraphClientOperator['status'], +): StatusSemantic { + return OPERATOR_STATUS_SEMANTICS[status]; +} + +export function AgentGraphStatusDot(props: { + status: AgentGraphClientOperator['status']; + label: string; +}) { + return ( +