From e6a024d3ed618d8909f01f7f1d24c43a6c900438 Mon Sep 17 00:00:00 2001 From: Abigail Deng Date: Thu, 6 Aug 2026 13:19:31 +0800 Subject: [PATCH 1/2] Redesign run detail presentation --- .../workflowActivityVNextMessages.en-US.ts | 41 + .../workflowActivityVNextMessages.zh-CN.ts | 39 + .../activity/RunDetailPage.test.tsx | 158 +++- .../activity/RunDetailPage.tsx | 733 ++++++++++++------ .../activity/runDetailPresentation.test.ts | 167 ++++ .../activity/runDetailPresentation.ts | 151 ++++ .../pages/workflow-activity-vnext/styles.ts | 24 + 7 files changed, 1034 insertions(+), 279 deletions(-) create mode 100644 apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/runDetailPresentation.test.ts create mode 100644 apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/runDetailPresentation.ts diff --git a/apps/aevatar-console-web/src/locales/workflowActivityVNextMessages.en-US.ts b/apps/aevatar-console-web/src/locales/workflowActivityVNextMessages.en-US.ts index aab530b5f..fae742be8 100644 --- a/apps/aevatar-console-web/src/locales/workflowActivityVNextMessages.en-US.ts +++ b/apps/aevatar-console-web/src/locales/workflowActivityVNextMessages.en-US.ts @@ -39,6 +39,7 @@ const workflowActivityVNextMessages = { 'workflowActivityVNext.common.empty': 'Empty', 'workflowActivityVNext.common.failed': 'Failed', 'workflowActivityVNext.common.no': 'No', + 'workflowActivityVNext.common.notReported': 'Not reported', 'workflowActivityVNext.common.open': 'Open', 'workflowActivityVNext.common.pending': 'Pending', 'workflowActivityVNext.common.refresh': 'Refresh', @@ -261,6 +262,8 @@ const workflowActivityVNextMessages = { 'Save locations unavailable', 'workflowActivityVNext.new.yaml': 'Workflow YAML', 'workflowActivityVNext.run.backAria': 'Back to Activity', + 'workflowActivityVNext.run.attempted': 'Attempted', + 'workflowActivityVNext.run.branch': 'Branch', 'workflowActivityVNext.run.commandId': 'Request ID', 'workflowActivityVNext.run.code': 'Code', 'workflowActivityVNext.run.completedSteps': 'Completed steps', @@ -270,8 +273,15 @@ const workflowActivityVNextMessages = { 'workflowActivityVNext.run.confirmTitle': 'Confirm new run', 'workflowActivityVNext.run.correlationId': 'Tracking ID', 'workflowActivityVNext.run.cost': 'Cost', + 'workflowActivityVNext.run.currencyNotReported': 'Currency not reported', 'workflowActivityVNext.run.diagnostics': 'Diagnostics', + 'workflowActivityVNext.run.duration': 'Duration', + 'workflowActivityVNext.run.durationMinutes': '{minutes}m', + 'workflowActivityVNext.run.durationMinutesSeconds': '{minutes}m {seconds}s', + 'workflowActivityVNext.run.durationSeconds': '{seconds}s', 'workflowActivityVNext.run.error': 'Final error', + 'workflowActivityVNext.run.executionPath': 'Execution path', + 'workflowActivityVNext.run.failed': 'Failed', 'workflowActivityVNext.run.failedSummary': 'The run did not complete.', 'workflowActivityVNext.run.description': 'Review the result, steps, and history for this run.', @@ -284,43 +294,74 @@ const workflowActivityVNextMessages = { 'workflowActivityVNext.run.graphSummary': '{nodes} nodes · {edges} edges', 'workflowActivityVNext.run.graphUnavailable': 'Run graph unavailable', 'workflowActivityVNext.run.input': 'Input', + 'workflowActivityVNext.run.initiator': 'Initiator', + 'workflowActivityVNext.run.inputSummary': 'Input summary', 'workflowActivityVNext.run.loading': 'Loading run…', 'workflowActivityVNext.run.loadingDescription': 'Loading run details…', 'workflowActivityVNext.run.newActorId': 'Run address', + 'workflowActivityVNext.run.nextStepId': 'Next step ID', + 'workflowActivityVNext.run.noExecutionPath': + 'No execution path is available yet.', + 'workflowActivityVNext.run.noOutcomeYet': + 'No final outcome is available yet.', 'workflowActivityVNext.run.noSteps': 'No steps are available yet.', 'workflowActivityVNext.run.noDiagnostics': 'No diagnostics were returned.', 'workflowActivityVNext.run.noTimeline': 'No timeline events are visible yet.', 'workflowActivityVNext.run.output': 'Final output', + 'workflowActivityVNext.run.outcome': 'Outcome', + 'workflowActivityVNext.run.overview': 'Overview', 'workflowActivityVNext.run.message': 'Message', 'workflowActivityVNext.run.promptTokens': 'Prompt tokens', 'workflowActivityVNext.run.requestedSteps': 'Requested steps', 'workflowActivityVNext.run.requestParameters': 'Request parameters', + 'workflowActivityVNext.run.reported': 'Reported', + 'workflowActivityVNext.run.reportingState': 'Reporting state', + 'workflowActivityVNext.run.result': 'Result', + 'workflowActivityVNext.run.reviewFailedStep': 'Review failed step', 'workflowActivityVNext.run.retry': 'Retry failed step', 'workflowActivityVNext.run.retryUnavailable': 'Retry is available when one step has failed.', 'workflowActivityVNext.run.runAgain': 'Run again', 'workflowActivityVNext.run.runAgainUnavailable': "Run again isn't available for this run.", + 'workflowActivityVNext.run.runId': 'Run ID', 'workflowActivityVNext.run.roleReplies': 'Role replies', 'workflowActivityVNext.run.severity': 'Severity', 'workflowActivityVNext.run.sourceImmutable': "This starts a new run. The original run won't change.", + 'workflowActivityVNext.run.skipped': 'Skipped', 'workflowActivityVNext.run.startFailed': "The new run couldn't be started", 'workflowActivityVNext.run.startingStep': 'Starting step', 'workflowActivityVNext.run.statusUrl': 'Status URL', 'workflowActivityVNext.run.statisticsUsage': 'Statistics and usage', 'workflowActivityVNext.run.step': 'Step', + 'workflowActivityVNext.run.stepId': 'Step ID', 'workflowActivityVNext.run.stepFailedSummary': 'This step did not complete.', 'workflowActivityVNext.run.steps': 'Steps', + 'workflowActivityVNext.run.stateVersion': 'State version', + 'workflowActivityVNext.run.succeeded': 'Succeeded', 'workflowActivityVNext.run.timeline': 'Timeline', + 'workflowActivityVNext.run.timelineEvent.completed': 'Run completed', + 'workflowActivityVNext.run.timelineEvent.failed': 'Run failed', + 'workflowActivityVNext.run.timelineEvent.response': + 'Step produced a response', + 'workflowActivityVNext.run.timelineEvent.started': 'Run started', + 'workflowActivityVNext.run.timelineEvent.stopped': 'Run stopped', + 'workflowActivityVNext.run.timelineEvent.toolFinished': 'Tool finished', + 'workflowActivityVNext.run.timelineEvent.toolStarted': 'Tool started', + 'workflowActivityVNext.run.timelineEvent.updated': 'Run updated', + 'workflowActivityVNext.run.toolCalls': 'Tool calls', 'workflowActivityVNext.run.totalSteps': 'Total steps', 'workflowActivityVNext.run.totalTokens': 'Total tokens', 'workflowActivityVNext.run.type': 'Type', + 'workflowActivityVNext.run.usage': 'Usage', 'workflowActivityVNext.run.unavailable': 'Run unavailable', 'workflowActivityVNext.run.unavailableDescription': "This run couldn't be loaded.", 'workflowActivityVNext.run.unavailableGuidance': 'Try again to load this run.', + 'workflowActivityVNext.run.wait': 'Wait', + 'workflowActivityVNext.run.waiting': 'Waiting', 'workflowActivityVNext.settings.account': 'Account', 'workflowActivityVNext.settings.accountDescription': 'Your profile and access.', diff --git a/apps/aevatar-console-web/src/locales/workflowActivityVNextMessages.zh-CN.ts b/apps/aevatar-console-web/src/locales/workflowActivityVNextMessages.zh-CN.ts index 28c000554..ba6a3fa59 100644 --- a/apps/aevatar-console-web/src/locales/workflowActivityVNextMessages.zh-CN.ts +++ b/apps/aevatar-console-web/src/locales/workflowActivityVNextMessages.zh-CN.ts @@ -43,6 +43,7 @@ const workflowActivityVNextMessages: Record = 'workflowActivityVNext.common.empty': '空', 'workflowActivityVNext.common.failed': '失败', 'workflowActivityVNext.common.no': '否', + 'workflowActivityVNext.common.notReported': '未报告', 'workflowActivityVNext.common.open': '打开', 'workflowActivityVNext.common.pending': '等待中', 'workflowActivityVNext.common.refresh': '刷新', @@ -247,6 +248,8 @@ const workflowActivityVNextMessages: Record = 'workflowActivityVNext.new.workspaceUnavailable': '保存位置不可用', 'workflowActivityVNext.new.yaml': '工作流 YAML', 'workflowActivityVNext.run.backAria': '返回活动列表', + 'workflowActivityVNext.run.attempted': '已尝试', + 'workflowActivityVNext.run.branch': '分支', 'workflowActivityVNext.run.commandId': '请求 ID', 'workflowActivityVNext.run.code': '代码', 'workflowActivityVNext.run.completedSteps': '已完成步骤', @@ -256,8 +259,16 @@ const workflowActivityVNextMessages: Record = 'workflowActivityVNext.run.confirmTitle': '确认新运行', 'workflowActivityVNext.run.correlationId': '跟踪 ID', 'workflowActivityVNext.run.cost': '费用', + 'workflowActivityVNext.run.currencyNotReported': '未报告币种', 'workflowActivityVNext.run.diagnostics': '诊断', + 'workflowActivityVNext.run.duration': '耗时', + 'workflowActivityVNext.run.durationMinutes': '{minutes}分', + 'workflowActivityVNext.run.durationMinutesSeconds': + '{minutes}分 {seconds}秒', + 'workflowActivityVNext.run.durationSeconds': '{seconds}秒', 'workflowActivityVNext.run.error': '最终错误', + 'workflowActivityVNext.run.executionPath': '执行路径', + 'workflowActivityVNext.run.failed': '失败', 'workflowActivityVNext.run.failedSummary': '此次运行未完成。', 'workflowActivityVNext.run.description': '查看此次运行的结果、步骤和历史。', 'workflowActivityVNext.run.title': '运行详情', @@ -269,41 +280,69 @@ const workflowActivityVNextMessages: Record = 'workflowActivityVNext.run.graphSummary': '{nodes} 个节点 · {edges} 条边', 'workflowActivityVNext.run.graphUnavailable': '运行图不可用', 'workflowActivityVNext.run.input': '输入', + 'workflowActivityVNext.run.initiator': '发起人', + 'workflowActivityVNext.run.inputSummary': '输入摘要', 'workflowActivityVNext.run.loading': '正在加载运行…', 'workflowActivityVNext.run.loadingDescription': '正在加载运行详情…', 'workflowActivityVNext.run.newActorId': '运行地址', + 'workflowActivityVNext.run.nextStepId': '下一步骤 ID', + 'workflowActivityVNext.run.noExecutionPath': '暂时没有可用的执行路径。', + 'workflowActivityVNext.run.noOutcomeYet': '暂时没有最终结果。', 'workflowActivityVNext.run.noSteps': '暂时没有可用步骤。', 'workflowActivityVNext.run.noDiagnostics': '暂无诊断信息。', 'workflowActivityVNext.run.noTimeline': '尚无可见的时间线事件。', 'workflowActivityVNext.run.output': '最终输出', + 'workflowActivityVNext.run.outcome': '结果', + 'workflowActivityVNext.run.overview': '概览', 'workflowActivityVNext.run.message': '消息', 'workflowActivityVNext.run.promptTokens': '输入 Token', 'workflowActivityVNext.run.requestedSteps': '已请求步骤', 'workflowActivityVNext.run.requestParameters': '请求参数', + 'workflowActivityVNext.run.reported': '已报告', + 'workflowActivityVNext.run.reportingState': '报告状态', + 'workflowActivityVNext.run.result': '结果', + 'workflowActivityVNext.run.reviewFailedStep': '查看失败步骤', 'workflowActivityVNext.run.retry': '重试失败步骤', 'workflowActivityVNext.run.retryUnavailable': '存在一个失败步骤时才能重试。', 'workflowActivityVNext.run.runAgain': '再次运行', 'workflowActivityVNext.run.runAgainUnavailable': '此次运行暂时无法重新开始。', + 'workflowActivityVNext.run.runId': '运行 ID', 'workflowActivityVNext.run.roleReplies': '角色回复数', 'workflowActivityVNext.run.severity': '严重级别', 'workflowActivityVNext.run.sourceImmutable': '这会开始一次新运行,原运行不会改变。', + 'workflowActivityVNext.run.skipped': '已跳过', 'workflowActivityVNext.run.startFailed': '无法开始新运行', 'workflowActivityVNext.run.startingStep': '起始步骤', 'workflowActivityVNext.run.statusUrl': '状态 URL', 'workflowActivityVNext.run.statisticsUsage': '统计与用量', 'workflowActivityVNext.run.step': '步骤', + 'workflowActivityVNext.run.stepId': '步骤 ID', 'workflowActivityVNext.run.stepFailedSummary': '此步骤未完成。', 'workflowActivityVNext.run.steps': '步骤', + 'workflowActivityVNext.run.stateVersion': '状态版本', + 'workflowActivityVNext.run.succeeded': '成功', 'workflowActivityVNext.run.timeline': '时间线', + 'workflowActivityVNext.run.timelineEvent.completed': '运行已完成', + 'workflowActivityVNext.run.timelineEvent.failed': '运行失败', + 'workflowActivityVNext.run.timelineEvent.response': '步骤已生成响应', + 'workflowActivityVNext.run.timelineEvent.started': '运行已开始', + 'workflowActivityVNext.run.timelineEvent.stopped': '运行已停止', + 'workflowActivityVNext.run.timelineEvent.toolFinished': '工具调用已完成', + 'workflowActivityVNext.run.timelineEvent.toolStarted': '工具调用已开始', + 'workflowActivityVNext.run.timelineEvent.updated': '运行已更新', + 'workflowActivityVNext.run.toolCalls': '工具调用数', 'workflowActivityVNext.run.totalSteps': '步骤总数', 'workflowActivityVNext.run.totalTokens': 'Token 总数', 'workflowActivityVNext.run.type': '类型', + 'workflowActivityVNext.run.usage': '用量', 'workflowActivityVNext.run.unavailable': '运行记录不可用', 'workflowActivityVNext.run.unavailableDescription': '无法加载此次运行。', 'workflowActivityVNext.run.unavailableGuidance': '请重试加载此次运行。', + 'workflowActivityVNext.run.wait': '等待', + 'workflowActivityVNext.run.waiting': '等待中', 'workflowActivityVNext.settings.account': '账户', 'workflowActivityVNext.settings.accountDescription': '你的个人资料与访问权限。', diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/RunDetailPage.test.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/RunDetailPage.test.tsx index 58bc22af5..3202175cc 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/RunDetailPage.test.tsx +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/RunDetailPage.test.tsx @@ -228,7 +228,7 @@ describe('Workflow Activity vNext run detail recovery', () => { expect( screen.queryByText('/api/workflow/runs/status/command-alpha'), ).not.toBeVisible(); - expect(screen.queryByText(/state version/i)).not.toBeInTheDocument(); + expect(screen.getByText(/state version/i)).not.toBeVisible(); }); it("keeps a retry failure's server detail out of the confirmation message", async () => { @@ -265,10 +265,8 @@ describe('Workflow Activity vNext run detail recovery', () => { ); expect(await screen.findByText('Incident review')).toBeInTheDocument(); - fireEvent.click(screen.getByRole('tab', { name: 'Graph' })); - expect( - await screen.findByText('Run graph unavailable'), - ).toBeInTheDocument(); + fireEvent.click(screen.getByRole('tab', { name: 'Execution path' })); + expect(await screen.findByText('Human approval')).toBeVisible(); expect(screen.getByRole('button', { name: 'Run again' })).toBeDisabled(); }); @@ -278,6 +276,7 @@ describe('Workflow Activity vNext run detail recovery', () => { ); expect(await screen.findByText('The run did not complete.')).toBeVisible(); + fireEvent.click(screen.getByRole('tab', { name: 'Steps' })); expect(screen.getByText('This step did not complete.')).toBeVisible(); for (const rawError of screen.getAllByText('Approval timed out')) { expect(rawError).not.toBeVisible(); @@ -296,7 +295,7 @@ describe('Workflow Activity vNext run detail recovery', () => { expect( await screen.findByRole('heading', { level: 1, name: 'Run details' }), ).toBeInTheDocument(); - expect(screen.queryByText('run-source-alpha')).not.toBeInTheDocument(); + expect(screen.getByText('run-source-alpha')).not.toBeVisible(); }); it('names a forbidden detail response without inventing run facts', async () => { @@ -321,35 +320,148 @@ describe('Workflow Activity vNext run detail recovery', () => { expect(screen.queryByText('Incident review')).not.toBeInTheDocument(); }); - it('renders returned diagnostics, request parameters, statistics, and usage facts', async () => { + it('reconciles a failed one-step outcome before technical evidence', async () => { + const run = buildRunDetail(); + run.steps = [run.steps[0]]; + run.statistics = { + completedSteps: 1, + requestedSteps: 1, + roleReplyCount: 0, + stepTypeCounts: { human_approval: 1 }, + totalSteps: 1, + }; + mockWorkflowActivityApi.getRun.mockResolvedValue(run); + renderWithQueryClient( , ); expect( - await screen.findByText(/"prompt": "Investigate"/), - ).toBeInTheDocument(); + await screen.findByText('Approval did not arrive before the deadline'), + ).toBeVisible(); + expect(screen.getByText('Attempted')).toBeVisible(); + expect(screen.getByText('Succeeded')).toBeVisible(); + expect( + within(screen.getByTestId('run-step-metrics')).getByText('Failed'), + ).toBeVisible(); + expect(screen.getByText('Waiting')).toBeVisible(); + expect(screen.getByText('Skipped')).toBeVisible(); + expect( + within(screen.getByTestId('run-step-metrics')).getByText('Not reported'), + ).toBeVisible(); + expect(screen.queryByText('Completed steps')).not.toBeInTheDocument(); + + const outcome = screen.getByTestId('run-step-metrics'); + expect( + within(outcome).getAllByText('1', { selector: 'strong' }), + ).toHaveLength(2); + expect( + within(outcome).getAllByText('0', { selector: 'strong' }), + ).toHaveLength(2); + const reviewFailedStep = screen.getByRole('button', { + name: 'Review failed step', + }); + expect(reviewFailedStep).toBeEnabled(); + fireEvent.click(reviewFailedStep); + expect(screen.getByRole('tab', { name: 'Steps' })).toHaveAttribute( + 'aria-selected', + 'true', + ); + for (const rawError of screen.getAllByText('Approval timed out')) { + expect(rawError).not.toBeVisible(); + } + }); + + it('shows ordered product steps without raw IDs in the default surface', async () => { + renderWithQueryClient( + , + ); + + fireEvent.click(await screen.findByRole('tab', { name: 'Steps' })); const stepsRegion = screen.getByRole('region', { name: 'Steps' }); expect(stepsRegion).toHaveAttribute('tabindex', '0'); + expect(within(stepsRegion).getByText('Responder · LLM call')).toBeVisible(); + expect(within(stepsRegion).getByText('Human approval')).toBeVisible(); + expect(within(stepsRegion).getByText('Succeeded')).toBeVisible(); + expect(within(stepsRegion).getByText('Failed')).toBeVisible(); + expect( + within(stepsRegion).queryByText('step-root'), + ).not.toBeInTheDocument(); expect( - within(stepsRegion).getByText('step-root').closest('td'), - ).toHaveAttribute('data-label', 'Step'); + within(stepsRegion).getByText(/"prompt": "Investigate"/), + ).not.toBeVisible(); + }); - fireEvent.click(screen.getByRole('tab', { name: 'Diagnostics' })); - const diagnosticsRegion = screen.getByRole('region', { - name: 'Diagnostics', - }); - expect(diagnosticsRegion).toHaveAttribute('tabindex', '0'); + it('localizes timeline events and keeps machine vocabulary collapsed', async () => { + const run = buildRunDetail(); + run.timeline = [ + { + kind: 'RunStarted', + timestampUtc: '2026-08-04T10:00:00Z', + stage: 'runtime.start', + message: 'command accepted', + agentId: 'actor-internal-alpha', + stepId: '', + stepType: '', + toolCall: null, + content: '', + data: { commandId: 'command-internal-alpha' }, + }, + { + kind: 'InternalRoleReplyReceived', + timestampUtc: '2026-08-04T10:00:30Z', + stage: 'role.reply', + message: 'responder', + agentId: 'actor-internal-alpha', + stepId: 'step-root', + stepType: 'llm_call', + toolCall: null, + content: 'Prepared response', + data: {}, + }, + ]; + mockWorkflowActivityApi.getRun.mockResolvedValue(run); + + renderWithQueryClient( + , + ); + + fireEvent.click(await screen.findByRole('tab', { name: 'Timeline' })); + expect(screen.getByText('Run started')).toBeVisible(); + expect(screen.getByText('Step produced a response')).toBeVisible(); + expect(screen.getByText('+30s')).toBeVisible(); expect( - within(diagnosticsRegion).getByText('APPROVAL_TIMEOUT').closest('td'), - ).toHaveAttribute('data-label', 'Code'); + screen.queryByText('InternalRoleReplyReceived'), + ).not.toBeInTheDocument(); + expect(screen.getByText(/command-internal-alpha/)).not.toBeVisible(); + }); + + it('labels reported usage honestly and does not invent a currency', async () => { + renderWithQueryClient( + , + ); + + fireEvent.click(await screen.findByRole('tab', { name: 'Usage' })); + const usageRegion = screen.getByRole('tabpanel', { name: 'Usage' }); + expect(within(usageRegion).getByText('Reported')).toBeVisible(); + expect(within(usageRegion).getByText('12')).toBeVisible(); expect( - screen.getByText('Approval did not arrive before the deadline'), - ).toBeInTheDocument(); + within(usageRegion).getByText('0.02 · Currency not reported'), + ).toBeVisible(); + expect(within(usageRegion).getByText('Tool calls')).toBeVisible(); + expect(within(usageRegion).getByText('Not reported')).toBeVisible(); + }); + + it('renders the execution path from same-version step facts, not graph IDs', async () => { + renderWithQueryClient( + , + ); - fireEvent.click(screen.getByRole('tab', { name: 'Statistics and usage' })); - expect(screen.getByText('12')).toBeInTheDocument(); - expect(screen.getByText('0.02')).toBeInTheDocument(); + fireEvent.click(await screen.findByRole('tab', { name: 'Execution path' })); + expect(screen.getByText('Responder · LLM call')).toBeVisible(); + expect(screen.getByText('Human approval')).toBeVisible(); + expect(screen.queryByText('node-root')).not.toBeInTheDocument(); + expect(screen.queryByText('node-failed')).not.toBeInTheDocument(); }); }); diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/RunDetailPage.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/RunDetailPage.tsx index dd4e344cb..f71d59d4b 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/RunDetailPage.tsx +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/RunDetailPage.tsx @@ -1,5 +1,6 @@ import { ArrowLeftOutlined, ReloadOutlined } from '@ant-design/icons'; import { useQuery } from '@tanstack/react-query'; +import { getLocale } from '@umijs/max'; import { Alert, Button, Descriptions, Modal, Space, Tabs } from 'antd'; import React from 'react'; import { @@ -13,6 +14,13 @@ import { buildWorkflowActivitySectionHref } from '../navigation'; import TableScrollRegion from '../TableScrollRegion'; import TechnicalDetails from '../TechnicalDetails'; import WorkflowActivityVNextShell from '../WorkflowActivityVNextShell'; +import { + buildFailurePresentation, + buildStepMetrics, + buildUsagePresentation, + getStepDisplayName, + getTimelineEventLabel, +} from './runDetailPresentation'; import { getRunOriginLabel, getRunStatusPresentation } from './runPresentation'; import { resolveRunRecovery } from './runRecovery'; @@ -63,6 +71,121 @@ function RunFailureSummary({ ); } +function formatDuration(durationMs: number | null): string { + if (durationMs === null || !Number.isFinite(durationMs) || durationMs < 0) { + return t('workflowActivityVNext.common.notReported', 'Not reported'); + } + const totalSeconds = Math.round(durationMs / 1000); + if (totalSeconds < 60) { + return t('workflowActivityVNext.run.durationSeconds', '{seconds}s', { + seconds: totalSeconds, + }); + } + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return seconds + ? t( + 'workflowActivityVNext.run.durationMinutesSeconds', + '{minutes}m {seconds}s', + { minutes, seconds }, + ) + : t('workflowActivityVNext.run.durationMinutes', '{minutes}m', { + minutes, + }); +} + +function runDurationMilliseconds( + startedAtUtc: string | null, + timeline: readonly { readonly kind: string; readonly timestampUtc: string }[], +): number | null { + if (!startedAtUtc) return null; + const startedAt = Date.parse(startedAtUtc); + const terminalTimestamp = [...timeline] + .reverse() + .find((event) => + ['runcompleted', 'runerror', 'runstopped'].includes( + event.kind.trim().toLowerCase(), + ), + )?.timestampUtc; + if (!terminalTimestamp) return null; + const terminalAt = Date.parse(terminalTimestamp); + return Number.isFinite(startedAt) && Number.isFinite(terminalAt) + ? Math.max(0, terminalAt - startedAt) + : null; +} + +function formatLocalTimestamp(timestampUtc: string): string { + const timestamp = new Date(timestampUtc); + if (Number.isNaN(timestamp.getTime())) return timestampUtc; + return new Intl.DateTimeFormat(getLocale(), { + dateStyle: 'medium', + timeStyle: 'medium', + }).format(timestamp); +} + +function formatTimelineOffset( + startedAtUtc: string | null, + timestampUtc: string, +): string { + if (!startedAtUtc) return ''; + const startedAt = Date.parse(startedAtUtc); + const timestamp = Date.parse(timestampUtc); + if (!Number.isFinite(startedAt) || !Number.isFinite(timestamp)) return ''; + return `+${formatDuration(Math.max(0, timestamp - startedAt))}`; +} + +function stepOutcome(success: boolean | null): string { + if (success === null) { + return t('workflowActivityVNext.run.waiting', 'Waiting'); + } + return success + ? t('workflowActivityVNext.common.succeeded', 'Succeeded') + : t('workflowActivityVNext.common.failed', 'Failed'); +} + +function localizedTimelineEventLabel(event: { + readonly kind: string; + readonly stage: string; +}): string { + const label = getTimelineEventLabel(event); + const translations: Record = { + 'Run completed': [ + 'workflowActivityVNext.run.timelineEvent.completed', + 'Run completed', + ], + 'Run failed': [ + 'workflowActivityVNext.run.timelineEvent.failed', + 'Run failed', + ], + 'Run started': [ + 'workflowActivityVNext.run.timelineEvent.started', + 'Run started', + ], + 'Run stopped': [ + 'workflowActivityVNext.run.timelineEvent.stopped', + 'Run stopped', + ], + 'Run updated': [ + 'workflowActivityVNext.run.timelineEvent.updated', + 'Run updated', + ], + 'Step produced a response': [ + 'workflowActivityVNext.run.timelineEvent.response', + 'Step produced a response', + ], + 'Tool finished': [ + 'workflowActivityVNext.run.timelineEvent.toolFinished', + 'Tool finished', + ], + 'Tool started': [ + 'workflowActivityVNext.run.timelineEvent.toolStarted', + 'Tool started', + ], + }; + const translation = translations[label]; + return translation ? t(translation[0], translation[1]) : label; +} + const RunDetailPage: React.FC<{ readonly runId: string; readonly scopeId: string; @@ -85,6 +208,7 @@ const RunDetailPage: React.FC<{ readonly kind: 'retry' | 'run_again'; readonly stepId: string; } | null>(null); + const [activeTab, setActiveTab] = React.useState('overview'); const recovery = resolveRunRecovery(detail.data?.steps ?? [], graph.data); const fork = async (startAtStepId: string): Promise => { @@ -162,6 +286,17 @@ const RunDetailPage: React.FC<{ const run = detail.data; const statusPresentation = getRunStatusPresentation(run.summary.status); + const failure = buildFailurePresentation(run); + const stepMetrics = buildStepMetrics(run.steps); + const usage = buildUsagePresentation( + run.usageTotals, + run.steps, + run.timeline, + ); + const notReported = t( + 'workflowActivityVNext.common.notReported', + 'Not reported', + ); return ( ) : null} -
- - ) : ( - t('workflowActivityVNext.common.unavailable', 'Unavailable') - ), - }, - ]} - /> -
+
+ + {t('workflowActivityVNext.run.outcome', 'Outcome')} + +

{statusPresentation.label}

+ {run.finalError ? ( + + {t( + 'workflowActivityVNext.run.failedSummary', + 'The run did not complete.', + )} + + ) : null} + {failure.primaryCause ? ( +

+ {failure.primaryCause} +

+ ) : run.finalOutput ? ( +

{run.finalOutput}

+ ) : ( +

+ {t( + 'workflowActivityVNext.run.noOutcomeYet', + 'No final outcome is available yet.', + )} +

+ )} + {stepMetrics.failed > 0 ? ( + + ) : run.summary.success === null ? ( + + ) : null} +
+
+ {[ + [ + t('workflowActivityVNext.run.attempted', 'Attempted'), + stepMetrics.attempted, + ], + [ + t('workflowActivityVNext.run.succeeded', 'Succeeded'), + stepMetrics.succeeded, + ], + [ + t('workflowActivityVNext.run.failed', 'Failed'), + stepMetrics.failed, + ], + [ + t('workflowActivityVNext.run.waiting', 'Waiting'), + stepMetrics.waiting, + ], + [ + t('workflowActivityVNext.run.skipped', 'Skipped'), + stepMetrics.skipped, + ], + ].map(([label, value]) => ( +
+ {label} + {value === null ? ( + {notReported} + ) : ( + {value} + )} +
+ ))} +
+ + + {runId} + ), + }, + { + key: 'stateVersion', + label: t( + 'workflowActivityVNext.run.stateVersion', + 'State version', + ), + children: run.summary.stateVersion, + }, + { + key: 'input', + label: t('workflowActivityVNext.run.input', 'Input'), + children: + run.input || + t('workflowActivityVNext.common.empty', 'Empty'), + }, + { + key: 'finalError', + label: t( + 'workflowActivityVNext.run.error', + 'Final error', + ), + children: + run.finalError || + t( + 'workflowActivityVNext.common.unavailable', + 'Unavailable', + ), + }, + ]} + /> + {failure.evidence.length ? ( +
+                      {JSON.stringify(
+                        {
+                          evidence: failure.evidence,
+                          diagnostics: run.diagnostics,
+                        },
+                        null,
+                        2,
+                      )}
+                    
+ ) : null} +
+ + ), + }, { key: 'steps', label: t('workflowActivityVNext.run.steps', 'Steps'), @@ -402,67 +712,46 @@ const RunDetailPage: React.FC<{ {t('workflowActivityVNext.run.step', 'Step')} - {t('workflowActivityVNext.run.type', 'Type')} - {t( - 'workflowActivityVNext.activity.columnStatus', - 'Status', - )} + {t('workflowActivityVNext.run.outcome', 'Outcome')} - {t('workflowActivityVNext.run.output', 'Output')} - {t( - 'workflowActivityVNext.run.requestParameters', - 'Request parameters', - )} + {t('workflowActivityVNext.run.duration', 'Duration')} + {t('workflowActivityVNext.run.result', 'Result')} {run.steps.map((step) => ( - {step.stepId} + {getStepDisplayName(step)} - {step.stepType} + {stepOutcome(step.success)} - {step.success === null - ? t( - 'workflowActivityVNext.common.pending', - 'Pending', - ) - : step.success - ? t( - 'workflowActivityVNext.common.succeeded', - 'Succeeded', - ) - : t( - 'workflowActivityVNext.common.failed', - 'Failed', - )} + {formatDuration(step.durationMs)} {step.error ? ( @@ -477,21 +766,21 @@ const RunDetailPage: React.FC<{ 'Unavailable', ) )} - - -
-                            {Object.keys(step.requestParameters).length
-                              ? JSON.stringify(step.requestParameters, null, 2)
-                              : t(
-                                  'workflowActivityVNext.common.empty',
-                                  'Empty',
-                                )}
-                          
+ +
+                              {JSON.stringify(
+                                {
+                                  stepId: step.stepId,
+                                  requestParameters: step.requestParameters,
+                                  requestedAtUtc: step.requestedAtUtc,
+                                  completedAtUtc: step.completedAtUtc,
+                                  error: step.error,
+                                },
+                                null,
+                                2,
+                              )}
+                            
+
))} @@ -509,103 +798,11 @@ const RunDetailPage: React.FC<{ ), }, - { - key: 'diagnostics', - label: t('workflowActivityVNext.run.diagnostics', 'Diagnostics'), - children: run.diagnostics.length ? ( - - - - - - - - - - - - {run.diagnostics.map((diagnostic) => ( - - - - - - - ))} - -
- {t('workflowActivityVNext.run.severity', 'Severity')} - {t('workflowActivityVNext.run.code', 'Code')}{t('workflowActivityVNext.run.step', 'Step')} - {t('workflowActivityVNext.run.message', 'Message')} -
- {diagnostic.severity} - - {diagnostic.code} - - {diagnostic.stepId || - t( - 'workflowActivityVNext.common.unavailable', - 'Unavailable', - )} - - {diagnostic.message} - {diagnostic.hint ? ( - - {diagnostic.hint} - - ) : null} -
-
- ) : ( -
-

- {t( - 'workflowActivityVNext.run.noDiagnostics', - 'No diagnostics were returned.', - )} -

-
- ), - }, { key: 'timeline', label: t('workflowActivityVNext.run.timeline', 'Timeline'), children: run.timeline.length ? ( -
    +
      {run.timeline.map((event) => (
    1. - {event.timestampUtc}{' '} - · {event.kind} · {event.message} - {event.content ? ( -
      {event.content}
      - ) : null} - {event.toolCall ? ( +
      + {localizedTimelineEventLabel(event)} + + {formatTimelineOffset( + run.summary.startedAtUtc, + event.timestampUtc, + )} + +
      + +
      -                        {JSON.stringify(event.toolCall, null, 2)}
      +                        {JSON.stringify(event, null, 2)}
                             
      - ) : null} +
    2. ))}
    @@ -640,47 +844,31 @@ const RunDetailPage: React.FC<{ ), }, { - key: 'statistics', - label: t( - 'workflowActivityVNext.run.statisticsUsage', - 'Statistics and usage', - ), + key: 'usage', + label: t('workflowActivityVNext.run.usage', 'Usage'), children: ( ), }, { - key: 'graph', - label: t('workflowActivityVNext.run.graph', 'Graph'), - children: graph.isPending ? ( -

    - {t( - 'workflowActivityVNext.run.graphLoading', - 'Loading run graph…', - )} -

    - ) : graph.isError ? ( - void graph.refetch()}> - {t('workflowActivityVNext.common.retry', 'Retry')} - - } - message={t( - 'workflowActivityVNext.run.graphUnavailable', - 'Run graph unavailable', - )} - showIcon - type="warning" - /> + key: 'execution-path', + label: t( + 'workflowActivityVNext.run.executionPath', + 'Execution path', + ), + children: run.steps.length ? ( +
      + {run.steps.map((step) => ( +
    1. +
      + {getStepDisplayName(step)} + {stepOutcome(step.success)} +
      + {formatDuration(step.durationMs)} + {step.branchKey ? ( + + {t('workflowActivityVNext.run.branch', 'Branch')}:{' '} + {step.branchKey} + + ) : null} + {step.suspensionType ? ( + + {t('workflowActivityVNext.run.wait', 'Wait')}:{' '} + {step.suspensionType} + + ) : null} + + + {step.stepId} + + ), + }, + { + key: 'nextStepId', + label: t( + 'workflowActivityVNext.run.nextStepId', + 'Next step ID', + ), + children: + step.nextStepId || + t( + 'workflowActivityVNext.common.unavailable', + 'Unavailable', + ), + }, + ]} + /> + +
    2. + ))} +
    ) : ( -
    +

    {t( - 'workflowActivityVNext.run.graphSummary', - '{nodes} nodes · {edges} edges', - { - nodes: graph.data?.nodes.length ?? 0, - edges: graph.data?.edges.length ?? 0, - }, + 'workflowActivityVNext.run.noExecutionPath', + 'No execution path is available yet.', )}

    -
      - {graph.data?.nodes.map((node) => ( -
    • - {node.nodeId} - {node.stepId ? ` · ${node.stepId}` : ''} -
    • - ))} -
    ), }, diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/runDetailPresentation.test.ts b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/runDetailPresentation.test.ts new file mode 100644 index 000000000..65981b049 --- /dev/null +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/runDetailPresentation.test.ts @@ -0,0 +1,167 @@ +import type { + WorkflowActivityDiagnostic, + WorkflowActivityStep, + WorkflowActivityTimelineEvent, + WorkflowActivityUsageTotals, +} from '@/shared/models/workflowActivity'; +import { + buildFailurePresentation, + buildStepMetrics, + buildUsagePresentation, + getStepDisplayName, + getTimelineEventLabel, +} from './runDetailPresentation'; + +function usage( + overrides: Partial = {}, +): WorkflowActivityUsageTotals { + return { + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, + cost: 0, + ...overrides, + }; +} + +function step( + overrides: Partial = {}, +): WorkflowActivityStep { + return { + stepId: 'step-internal-alpha', + stepType: 'human_approval', + targetRole: '', + requestedAtUtc: '2026-08-04T10:00:00Z', + completedAtUtc: '2026-08-04T10:01:00Z', + success: false, + durationMs: 60_000, + outputPreview: '', + error: 'Approval timed out', + requestParameters: {}, + nextStepId: '', + branchKey: '', + suspensionType: 'approval', + suspensionPrompt: 'Approve?', + suspensionContent: '', + suspensionTimeoutSeconds: 60, + toolApproval: null, + usage: usage(), + ...overrides, + }; +} + +function diagnostic( + overrides: Partial = {}, +): WorkflowActivityDiagnostic { + return { + timestampUtc: '2026-08-04T10:01:00Z', + severity: 'error', + code: 'APPROVAL_TIMEOUT', + source: 'workflow', + message: 'Approval did not arrive before the deadline', + hint: 'Review the approval channel', + stepId: 'step-internal-alpha', + stepType: 'human_approval', + targetRole: '', + ...overrides, + }; +} + +describe('run detail presentation', () => { + it('reconciles a failed one-step run without calling the failed step completed', () => { + expect(buildStepMetrics([step()])).toEqual({ + attempted: 1, + failed: 1, + skipped: null, + succeeded: 0, + waiting: 0, + }); + }); + + it('groups equivalent failure evidence behind one primary cause', () => { + expect( + buildFailurePresentation({ + diagnostics: [diagnostic(), diagnostic({ source: 'projection' })], + finalError: 'Approval did not arrive before the deadline', + steps: [step({ error: 'Approval timed out' })], + }), + ).toEqual({ + evidence: [ + 'Approval did not arrive before the deadline', + 'Approval timed out', + ], + primaryCause: 'Approval did not arrive before the deadline', + }); + }); + + it('treats all-zero usage as not reported because the contract has no measurement provenance', () => { + expect(buildUsagePresentation(usage(), [], [])).toEqual({ + completionTokens: null, + cost: null, + currency: null, + promptTokens: null, + state: 'not_reported', + toolCalls: null, + totalTokens: null, + }); + }); + + it('does not turn a timeline without tool-call evidence into measured zero', () => { + const runStarted = { + kind: 'RunStarted', + toolCall: null, + } as WorkflowActivityTimelineEvent; + + expect( + buildUsagePresentation(usage(), [], [runStarted]).toolCalls, + ).toBeNull(); + }); + + it('keeps reported usage and tool calls without inventing a cost currency', () => { + const toolEvent = { + toolCall: { success: true }, + } as WorkflowActivityTimelineEvent; + + expect( + buildUsagePresentation( + usage({ + completionTokens: 8, + cost: 0.02, + promptTokens: 4, + totalTokens: 12, + }), + [step({ usage: usage({ totalTokens: 12 }) })], + [toolEvent], + ), + ).toEqual({ + completionTokens: 8, + cost: 0.02, + currency: null, + promptTokens: 4, + state: 'reported', + toolCalls: 1, + totalTokens: 12, + }); + }); + + it('names execution steps from product fields instead of raw IDs', () => { + expect( + getStepDisplayName( + step({ stepType: 'llm_call', targetRole: 'incident responder' }), + ), + ).toBe('Incident responder · LLM call'); + expect(getStepDisplayName(step())).toBe('Human approval'); + }); + + it('maps machine timeline events to user language', () => { + expect(getTimelineEventLabel({ kind: 'RunStarted', stage: '' })).toBe( + 'Run started', + ); + expect( + getTimelineEventLabel({ kind: 'UnknownEvent', stage: 'role.reply' }), + ).toBe('Step produced a response'); + expect( + getTimelineEventLabel({ kind: 'UnknownEvent', stage: 'runtime.signal' }), + ).toBe('Run updated'); + }); +}); diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/runDetailPresentation.ts b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/runDetailPresentation.ts new file mode 100644 index 000000000..2a5ce3304 --- /dev/null +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/runDetailPresentation.ts @@ -0,0 +1,151 @@ +import type { + WorkflowActivityDiagnostic, + WorkflowActivityStep, + WorkflowActivityTimelineEvent, + WorkflowActivityUsageTotals, +} from '@/shared/models/workflowActivity'; + +export type RunStepMetrics = { + readonly attempted: number; + readonly failed: number; + readonly skipped: null; + readonly succeeded: number; + readonly waiting: number; +}; + +export type RunFailurePresentation = { + readonly evidence: readonly string[]; + readonly primaryCause: string; +}; + +export type RunUsagePresentation = { + readonly completionTokens: number | null; + readonly cost: number | null; + readonly currency: null; + readonly promptTokens: number | null; + readonly state: 'not_reported' | 'reported'; + readonly toolCalls: number | null; + readonly totalTokens: number | null; +}; + +function normalizeEvidence(value: string): string { + return value.trim().replace(/\s+/g, ' ').toLowerCase(); +} + +function humanize(value: string): string { + const words = value + .trim() + .replace(/[_-]+/g, ' ') + .replace(/\s+/g, ' ') + .toLowerCase(); + if (!words) return ''; + return words + .replace(/^llm\b/, 'LLM') + .replace(/^ai\b/, 'AI') + .replace(/^./, (character) => character.toUpperCase()); +} + +export function buildStepMetrics( + steps: readonly WorkflowActivityStep[], +): RunStepMetrics { + return { + attempted: steps.length, + failed: steps.filter((step) => step.success === false).length, + skipped: null, + succeeded: steps.filter((step) => step.success === true).length, + waiting: steps.filter((step) => step.success === null).length, + }; +} + +export function buildFailurePresentation(input: { + readonly diagnostics: readonly WorkflowActivityDiagnostic[]; + readonly finalError: string; + readonly steps: readonly WorkflowActivityStep[]; +}): RunFailurePresentation { + const preferredDiagnostics = [...input.diagnostics].sort((left, right) => { + const leftError = left.severity.trim().toLowerCase() === 'error' ? 0 : 1; + const rightError = right.severity.trim().toLowerCase() === 'error' ? 0 : 1; + return leftError - rightError; + }); + const candidates = [ + ...preferredDiagnostics.map((item) => item.message), + input.finalError, + ...input.steps.map((item) => item.error), + ]; + const seen = new Set(); + const evidence = candidates.flatMap((candidate) => { + const value = candidate.trim(); + const key = normalizeEvidence(value); + if (!key || seen.has(key)) return []; + seen.add(key); + return [value]; + }); + return { + evidence, + primaryCause: evidence[0] ?? '', + }; +} + +export function buildUsagePresentation( + totals: WorkflowActivityUsageTotals, + steps: readonly WorkflowActivityStep[], + timeline: readonly WorkflowActivityTimelineEvent[], +): RunUsagePresentation { + const stepTotals = steps.reduce( + (result, step) => ({ + completionTokens: result.completionTokens + step.usage.completionTokens, + cost: result.cost + step.usage.cost, + promptTokens: result.promptTokens + step.usage.promptTokens, + totalTokens: result.totalTokens + step.usage.totalTokens, + }), + { completionTokens: 0, cost: 0, promptTokens: 0, totalTokens: 0 }, + ); + const totalsReported = Object.values(totals).some((value) => value !== 0); + const stepsReported = Object.values(stepTotals).some((value) => value !== 0); + const reported = totalsReported || stepsReported; + const resolved = totalsReported ? totals : stepTotals; + const toolCalls = timeline.filter((event) => event.toolCall !== null).length; + return { + completionTokens: reported ? resolved.completionTokens : null, + cost: reported ? resolved.cost : null, + currency: null, + promptTokens: reported ? resolved.promptTokens : null, + state: reported ? 'reported' : 'not_reported', + toolCalls: toolCalls > 0 ? toolCalls : null, + totalTokens: reported ? resolved.totalTokens : null, + }; +} + +export function getStepDisplayName(step: WorkflowActivityStep): string { + const stepType = humanize(step.stepType) || 'Workflow step'; + const targetRole = humanize(step.targetRole); + return targetRole ? `${targetRole} · ${stepType}` : stepType; +} + +export function getTimelineEventLabel(event: { + readonly kind: string; + readonly stage: string; +}): string { + switch (event.kind.trim().toLowerCase()) { + case 'runstarted': + return 'Run started'; + case 'runcompleted': + return 'Run completed'; + case 'runerror': + return 'Run failed'; + case 'runstopped': + return 'Run stopped'; + default: + break; + } + switch (event.stage.trim().toLowerCase()) { + case 'role.reply': + return 'Step produced a response'; + case 'tool.call': + return 'Tool started'; + case 'tool.result': + return 'Tool finished'; + default: + return 'Run updated'; + } +} diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/styles.ts b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/styles.ts index 139f67f08..45f1124dc 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/styles.ts +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/styles.ts @@ -208,6 +208,26 @@ export const workflowActivityVNextCss = ` .wa-vnext__run-tabs > .ant-tabs-nav { margin: 0 0 16px; min-height: 32px; } .wa-vnext__run-tabs > .ant-tabs-nav::before { border-color: var(--wa-line); } .wa-vnext__run-tabs .ant-tabs-tab { font-size: 12px; min-height: 32px; padding: 7px 2px; } +.wa-vnext__run-overview { display: grid; gap: 16px; } +.wa-vnext__outcome { align-items: start; background: var(--wa-subtle); border: 1px solid var(--wa-line); border-radius: var(--wa-radius); display: grid; gap: 8px; justify-items: start; padding: 20px; } +.wa-vnext__outcome h2 { font-size: 20px; line-height: 26px; margin: 0; } +.wa-vnext__outcome p { color: var(--wa-muted); line-height: 18px; margin: 0; max-width: 720px; } +.wa-vnext__eyebrow { color: var(--wa-muted); font-size: 10px; font-weight: 700; text-transform: uppercase; } +.wa-vnext__primary-cause { color: var(--wa-ink) !important; font-size: 14px; font-weight: 600; } +.wa-vnext__step-metrics { border: 1px solid var(--wa-line); border-radius: var(--wa-radius); display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); overflow: hidden; } +.wa-vnext__step-metric { border-right: 1px solid var(--wa-line); display: grid; gap: 5px; min-height: 72px; padding: 12px; } +.wa-vnext__step-metric:last-child { border-right: 0; } +.wa-vnext__step-metric > span:first-child { color: var(--wa-muted); font-size: 11px; } +.wa-vnext__step-metric strong { font-size: 20px; line-height: 24px; } +.wa-vnext__timeline, .wa-vnext__execution-path { list-style: none; margin: 0; padding: 0; } +.wa-vnext__timeline li, .wa-vnext__execution-path li { border-bottom: 1px solid var(--wa-line); padding: 14px 0; } +.wa-vnext__timeline li:first-child, .wa-vnext__execution-path li:first-child { padding-top: 0; } +.wa-vnext__timeline li:last-child, .wa-vnext__execution-path li:last-child { border-bottom: 0; } +.wa-vnext__timeline-heading, .wa-vnext__execution-path li > div:first-child { align-items: center; display: flex; gap: 12px; justify-content: space-between; } +.wa-vnext__timeline-heading span, .wa-vnext__timeline time, .wa-vnext__execution-path li > span, .wa-vnext__execution-path li > div:first-child span { color: var(--wa-muted); font-size: 11px; } +.wa-vnext__timeline time { display: block; margin-top: 3px; } +.wa-vnext__execution-path li { display: grid; gap: 5px; padding-left: 22px; position: relative; } +.wa-vnext__execution-path li::before { background: var(--wa-blue); border: 3px solid var(--wa-blue-bg); border-radius: 50%; content: ""; height: 11px; left: 0; position: absolute; top: 17px; width: 11px; } .wa-vnext__settings-layout { margin: 0 auto; max-width: 1120px; min-width: 0; } .wa-vnext__settings-nav { border-bottom: 1px solid var(--wa-line); display: flex; gap: 22px; margin-bottom: 16px; min-height: 32px; overflow-x: auto; scrollbar-width: thin; } .wa-vnext__settings-nav-link { border-bottom: 2px solid transparent; color: var(--wa-muted); flex: 0 0 auto; font-size: 12px; font-weight: 600; line-height: 17px; min-height: 32px; padding: 7px 2px 6px; text-decoration: none; } @@ -320,6 +340,10 @@ export const workflowActivityVNextCss = ` .wa-vnext__settings-savebar .ant-btn { width: 100%; } .wa-vnext__state--compact { padding: 18px; } .wa-vnext__publish-review-item { gap: 6px; padding-block: 10px; } + .wa-vnext__step-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .wa-vnext__step-metric { border-bottom: 1px solid var(--wa-line); } + .wa-vnext__step-metric:nth-child(2n) { border-right: 0; } + .wa-vnext__step-metric:last-child { border-bottom: 0; grid-column: 1 / -1; } } @media (prefers-reduced-motion: reduce) { .wa-vnext *, .wa-vnext *::before, .wa-vnext *::after { scroll-behavior: auto !important; transition: none !important; } } `; From a5378f02e9db9aba832fd82f91f6bdb94bb3741e Mon Sep 17 00:00:00 2001 From: AbigailDeng <108705114+AbigailDeng@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:03:31 +0800 Subject: [PATCH 2/2] Fix run detail test fixture typing --- .../activity/RunDetailPage.test.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/RunDetailPage.test.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/RunDetailPage.test.tsx index 076177a57..6c6477bb6 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/RunDetailPage.test.tsx +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/RunDetailPage.test.tsx @@ -168,13 +168,17 @@ function buildRunDetail() { }, }, ], - timeline: [], + timeline: + [] as import('@/shared/models/workflowActivity').WorkflowActivityTimelineEvent[], statistics: { totalSteps: 2, requestedSteps: 2, completedSteps: 2, roleReplyCount: 1, - stepTypeCounts: { human_approval: 1, llm_call: 1 }, + stepTypeCounts: { human_approval: 1, llm_call: 1 } as Record< + string, + number + >, }, usageTotals: { promptTokens: 4,