diff --git a/README.md b/README.md index 59014db..1a9b7f8 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ DevFlow Copilot 是一个 AI Coding Workbench / Agentic Workflow Console,用 - Prompt 模板:支持模板变量、版本记录、渲染预览和生成请求关联。 - Provider 路由 / local-rule fallback:默认本地规则生成,无需 API Key;OpenAI-compatible Provider 为可选代码层适配。 -- Trace Evidence:展示 Prompt 渲染、Provider 选择、Generation Trace、Agent Step、Tool Call、Fallback Reason 和 Human Review 证据链。 +- Run Evidence Workspace:以真实 AgentStep 为主线关联 Tool Call、Provider、Generation 与 Human Review 证据;默认展示 Local Demo / `local-rule`。未配置真实 API Key 时,页面不会包装为真实在线模型调用。 - Tool Call 记录:记录工具名、输入摘要、输出摘要、状态和耗时,用于解释一次 Agentic Workflow。 - Human Review:生成结果进入人工复核状态机,不自动改代码、不自动提交 Git。 - Knowledge References:轻量 Knowledge Base 关键词 / 简单相似度检索,并返回生成引用。 diff --git a/docs/design/devflow-evidence-workspace-system.md b/docs/design/devflow-evidence-workspace-system.md new file mode 100644 index 0000000..5efdfb0 --- /dev/null +++ b/docs/design/devflow-evidence-workspace-system.md @@ -0,0 +1,258 @@ +# DevFlow Evidence Workspace System + +状态:Phase 1 实现合同 +视觉语言:**Layered Graphite** +Golden Slice:`Run Evidence Workspace` +范围:Design Token、最小 App Shell、Trace Golden Slice、View Model、响应式与候选截图 + +## 1. 产品设计原则 + +DevFlow 是 AI Coding Workflow 的证据工作台,不是聊天套壳、通用监控大屏或 IDE 仿制品。第一阶段围绕一个稳定任务组织页面: + +> 选择 Run → 阅读真实 AgentStep → 检查关联 Evidence / Tool → 查看或执行当前 API 允许的 Human Review 动作。 + +实现必须遵守: + +1. **真实数据优先**:Evidence Stream 只来自 `trace.steps`,按 `stepOrder` 排序。 +2. **证据来源可见**:Direct、Derived、Unavailable 必须显示来源或缺失,不用视觉确定性掩盖数据不确定性。 +3. **选择同步**:Run、Event 与 Inspector 共享一个明确 selection。 +4. **状态不只靠颜色**:状态必须同时包含文字、标记形状和颜色。 +5. **长内容是正常状态**:中文标题允许多行,JSON 在自己的滚动区域内查看。 +6. **界面不宣称因果**:顺序轨道表达记录顺序,不宣称 AgentStep 之间存在后端未记录的绝对因果。 +7. **操作能力不伪造**:不存在的写接口保持 Disabled,并写明边界。 + +## 2. 为什么选择 Layered Graphite + +Layered Graphite 保留 DevFlow 深色工程产品定位,但用层级、seam、窄状态轨和局部对比建立信息结构,而不是纯黑背景、霓虹发光或 KPI 卡片墙。 + +它适合 DevFlow 的原因: + +- Trace、Tool Call、Generation Trace 与 Human Review 都是高密度、可追溯工程数据; +- 深石墨环境能让不同 evidence provenance 和状态持续可读; +- Evidence Stream 是页面主对象,Inspector 是第二主对象,Run Ledger 只承担导航; +- 一张真实页面截图即可表达 DevFlow 的工程差异,而不需要装饰性图表。 + +它与项目现有其他页面的区别: + +- 不复制 Dashboard 的 Hero + KPI 结构; +- 不复制 Workbench 的表单 + Artifact Preview 三栏; +- 不把 Trace 做成 IDE 文件树、Terminal 或普通 CRUD 明细页; +- 以 Evidence selection 和 provenance 为核心,形成 Trace 专属工作流语言。 + +后续全站只共享 Token、Surface、Status、Focus、App Shell 与基础 panel seam;不会强制其他页面复制 Evidence Stream。 + +## 3. Surface 层级 + +| 层级 | Token | 用途 | +|---|---|---| +| Level 0 | `--surface-shell` / `--surface-navigation` | 全局背景、Sidebar、Top Bar | +| Level 1 | `--surface-workspace` | 页面工作区与工具区背景 | +| Level 2 | `--surface-panel` | Run Ledger、Evidence Stream、Inspector | +| Level 3 | `--surface-elevated` / `--surface-selected` | 选中行、工具条、局部展开区 | +| Level 4 | `--surface-overlay` | Tablet Inspector、Mobile Bottom Sheet 遮罩 | + +约束: + +- 不使用纯黑; +- panel 主要依靠 `--line-subtle` seam 分隔,不把每个字段做成卡片; +- `--surface-selected` 只用于当前选择; +- overlay 只在抽屉打开时出现。 + +## 4. Typography + +| 角色 | Token / 下限 | 规则 | +|---|---|---| +| 页面标题 | `--type-page`,20–24px | 最多三行,桌面优先单行 | +| 区域标题 | `--type-region`,15–18px | Run Ledger、Stream、Inspector | +| Event 标题 | `--type-event`,14–16px | 可读性优先 | +| 正文 | `--type-body`,13px | 中文行高约 1.62 | +| 辅助正文 | `--type-support`,12px | 不再大量使用 7–9px | +| Mono 元数据 | `--type-mono`,11px | ID、type、timestamp、JSON | +| 按钮 | 至少 12px | 高度至少 32px | + +只有 panel kicker、紧凑 provenance 标签允许 9–10px;它们不是主阅读内容。 + +## 5. Spacing 与 Radius + +Spacing 使用稳定 4px 倍数:`4 / 8 / 12 / 16 / 20 / 24 / 32 / 40`。 + +Radius: + +- 小控件:`--radius-control: 4px` +- 面板:`--radius-panel: 8px` +- 浮层:`--radius-float: 12px` + +大面积使用极大圆角、胶囊卡片或相同尺寸模块会削弱工程层级,因此禁止。 + +## 6. Status 与 Provenance + +### 6.1 Status + +| 语义 | Token | 形状 / 文案 | +|---|---|---| +| Success | `--status-success` | 圆点 + “成功 / 已确认” | +| Running | `--status-running` | 环形标记 + “运行中” | +| Pending | `--status-pending` | 方角标记 + “待处理 / 待复核” | +| Attention | `--status-attention` | 方角 `!` + 明确原因 | +| Error | `--status-error` | 方角 `×` + “失败 / 错误” | +| Derived | `--status-derived` | 文案必须以 `Derived` 开头 | +| Unavailable | `--status-unavailable` | `—` + “未记录 / Unavailable” | + +### 6.2 Provenance + +- **Direct**:来自 DTO 的字段,例如 `agent_step`、`human_review`。 +- **Derived**:前端整理或计数,必须显式写 `Derived`。 +- **Unavailable**:字段缺失,显示“未记录”,不设置默认 Provider、Model、Review 结果或 Risk。 + +## 7. Evidence Stream + +Evidence Stream 是 Phase 1 第一主对象。 + +每个 Event 只对应一个真实 `AgentStep`,展示: + +- `stepOrder` +- `stepType` +- `stepName` +- `status` +- `summary` +- `latencyMs` +- `startedAt / completedAt` +- 与该 `step.id` 精确匹配的 Tool Call 数量 +- `Direct · agent_step` provenance + +规则: + +1. `trace.steps` 按 `stepOrder` 排序,同序时只用 `id` 保证稳定显示。 +2. 不补 Prompt、Knowledge、Provider、Record、Review 等固定七步。 +3. 两个 Step 只生成两个 Event。 +4. Tool Call 只按 `stepId` 关联;无 `stepId` 或指向不存在 Step 的记录进入 Run-level Linked Evidence。 +5. 上下键切换 selection,Enter / Space 保留按钮原生选择语义。 +6. Focus 必须可见。 + +## 8. Inspector + +Inspector 是第二主对象,包含五个标签页: + +### Context + +当前 AgentStep 的 Summary、Status、Source、Provenance、Latency、Started、Completed 与 Missing Fields。 + +### Evidence + +只展示可确认的 Run-level 关联: + +- Generation Record +- Generation Trace +- Knowledge Reference +- Provider / Model +- 真实 `errorMessage` +- Generated Output + +Provider / Model 缺失显示“未记录”。`local-rule` 只说明 Local demo route,不自动代表 Provider 失败。 + +### Tools + +- 当前 Step:只展示精确 `stepId` 命中的 Tool Call; +- Run-level:展示无可靠 Step 关联的 Tool Call; +- Input / Output 缺失显示“未记录”。 + +### Snapshot + +名称固定为 **Normalized Snapshot**。它是前端 DTO normalization 的 Derived 对象,不命名为 Raw JSON。长 JSON 内部横向和纵向滚动,可复制,不扩大页面宽度。 + +### Review + +复核状态、Reviewer、Comment、Created / Updated 与操作边界。它复用独立 Decision Ledger。 + +## 9. Decision Ledger + +Decision Ledger 表达: + +- 当前真实 Review 状态; +- 进入复核原因:如果 DTO 没有字段,显示“未记录”; +- 可用 Evidence 数量; +- 缺失 Evidence 数量; +- Reviewer、Comment、时间; +- 当前 API 允许的操作。 + +现有 Generation API 支持: + +- `READY_FOR_REVIEW` 时 Save; +- `SAVED` 时 Confirm。 + +Request Changes / Reject 没有现有写接口,因此始终 Disabled 并展示边界。无 `humanReviews` 时显示“未进入人工复核”,禁止显示“复核已完成”。 + +## 10. 响应式规则 + +### Desktop ≥ 1181px + +三栏: + +- Run Ledger:216–248px +- Evidence Stream:`minmax(480px, 1fr)` +- Inspector:310–352px + +页面在 viewport 内形成工作台,三个 panel 各自滚动。 + +### Tablet 721–1180px + +- App Shell Sidebar 收窄至 72px; +- Run Ledger + Evidence Stream 保持两栏; +- Inspector 成为可关闭的右侧浮层; +- 不压缩成不可读三栏。 + +### Mobile ≤ 720px,目标 390×844 + +结构: + +1. Compact Top Bar +2. Run Summary +3. Evidence Stream +4. Selected Event +5. Inspector Bottom Sheet +6. Safe-area padding + +移动端不承担完整 Workbench。Run Ledger 隐藏,由当前 Run Summary 保持上下文。Bottom Sheet 可关闭,默认不永久遮挡正文;JSON 在 Sheet 内滚动。页面不产生横向溢出。 + +## 11. Motion + +只使用 100–180ms: + +- selection 背景变化; +- Inspector / Bottom Sheet 抽出; +- tab 切换; +- loading 状态。 + +禁止常驻闪烁、宽高动画、粒子、霓虹光和影响中文阅读的运动。`prefers-reduced-motion` 会把 motion token 归零并停止 loading 旋转。 + +## 12. 全站共享与 Trace 专属 + +### 全站共享 + +- Layered Graphite Surface +- Text / Status token +- 4px spacing +- Radius +- Focus +- Reduced Motion +- App Shell 断点 +- Disabled / Unavailable 语义 + +### Trace 专属 + +- Run Ledger +- Evidence Stream / Event Rail +- AgentStep provenance +- Run-level Linked Evidence +- Context / Decision Inspector +- Normalized Snapshot +- Decision Ledger + +## 13. 后续扩展 + +- **Workbench**:共享 Token 和 App Shell;生成完成后提供进入 Run Evidence 的明确入口,不复制 Evidence Stream。 +- **Human Review**:共享 Decision Ledger 与 Review 状态语义;队列仍保持自己的主结构。 +- **Dashboard**:只共享 Surface / Status;不把 Dashboard 改成 Evidence Stream。 +- **History**:共享 Run / Generation ID、状态和 provenance 标签;保持列表主对象。 + +Phase 1 完成后停止,不重构上述页面内部业务结构。 diff --git a/docs/design/trace-freeze-conclusion.md b/docs/design/trace-freeze-conclusion.md new file mode 100644 index 0000000..7b9bb89 --- /dev/null +++ b/docs/design/trace-freeze-conclusion.md @@ -0,0 +1,7 @@ +# Trace Freeze Conclusion + +`TRACE_FRONTEND_FROZEN_FOR_PHASE2` + +The Trace page passed TypeScript, build, Trace tests, screenshot matrix, Phase 1.2 real local screenshots, font-size floor, desktop/drawer semantics, focus trap, Mobile Run Selector, no fake Top Bar interaction, backend diff check, original workspace protection, and canonical screenshot protection. + +Future Trace work should be limited to clear bug fixes unless Phase 2 explicitly expands Workbench or Human Review integration. diff --git a/docs/design/trace-freeze-contract.md b/docs/design/trace-freeze-contract.md new file mode 100644 index 0000000..0d352d8 --- /dev/null +++ b/docs/design/trace-freeze-contract.md @@ -0,0 +1,9 @@ +# Trace Freeze Design Contract + +- Preserve Layered Graphite, App Shell, Compact Run Ledger, Evidence Stream, and Inspector. +- Desktop Inspector is a resident complementary column, not a modal. +- Tablet/Mobile Inspector is the only drawer/dialog mode and owns Escape close, focus trap, and background inert. +- Event rows show only order, step type, step name, summary, status, latency, and compact Tool count when present. +- Provider and Model values remain in the header; direct source fields are disclosed in Inspector. +- Review reason is read from a real `HUMAN_REVIEW` AgentStep summary when available. +- QA Fixture screenshots remain test evidence only; final freeze screenshots use real local API + temporary H2. diff --git a/docs/design/trace-freeze-known-issues.md b/docs/design/trace-freeze-known-issues.md new file mode 100644 index 0000000..a9dee1d --- /dev/null +++ b/docs/design/trace-freeze-known-issues.md @@ -0,0 +1,9 @@ +# Trace Freeze Known Issues + +## Local Demo API data limits + +- The existing public local API safely generated multiple Local Demo Runs with `WAITING_REVIEW`, `SAVED`, and `CONFIRMED` states, Tool Calls, Knowledge References, and a real `HUMAN_REVIEW` AgentStep. +- The current API did not expose a safe frontend-only way to create a failed/error AgentRun or to append additional AgentSteps without writing directly to the temporary H2 database. +- The selected formal demo Run contains 5 real AgentSteps from the backend workflow. Phase 1.2 did not fabricate extra steps in the frontend, because the Trace page must render only real `trace.steps`. + +Decision: keep the real API output and document the limitation instead of faking failure states or synthetic steps. diff --git a/docs/images/trace-evidence.png b/docs/images/trace-evidence.png index 1a948b9..cd86cf8 100644 Binary files a/docs/images/trace-evidence.png and b/docs/images/trace-evidence.png differ diff --git a/frontend/package.json b/frontend/package.json index ebe8774..585fdae 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,9 +6,14 @@ "scripts": { "dev": "vite --host 0.0.0.0", "build": "vue-tsc --noEmit && vite build", + "test:trace": "node --test tests/runEvidenceViewModel.test.ts", "preview": "vite preview --host 0.0.0.0", "screenshots": "node ../scripts/capture-screenshots.mjs", - "screenshots:portfolio": "node ../scripts/capture-portfolio-screenshots.mjs" + "screenshots:portfolio": "node ../scripts/capture-portfolio-screenshots.mjs", + "screenshots:evidence-candidates": "node ../scripts/capture-evidence-workspace-candidates.mjs", + "screenshots:evidence-real-candidates": "node ../scripts/capture-evidence-workspace-real-candidates.mjs", + "screenshots:evidence-freeze-candidates": "node ../scripts/capture-evidence-workspace-freeze-candidates.mjs", + "screenshots:evidence-final": "node ../scripts/capture-evidence-workspace-final.mjs" }, "dependencies": { "@element-plus/icons-vue": "^2.3.1", diff --git a/frontend/src/components/SidebarNav.vue b/frontend/src/components/SidebarNav.vue index ca54026..439d472 100644 --- a/frontend/src/components/SidebarNav.vue +++ b/frontend/src/components/SidebarNav.vue @@ -31,8 +31,8 @@ const groups = computed(() => { 当前工作空间 DevFlow Copilot
- Demo Data - local-rule fallback + Local Demo + API-backed UI
@@ -45,11 +45,12 @@ const groups = computed(() => { :to="item.path" class="nav-item" :class="{ active: route.name === item.name }" + :title="item.label" > {{ item.label }} - diff --git a/frontend/src/components/TopBar.vue b/frontend/src/components/TopBar.vue index f15cbc3..5d19a8e 100644 --- a/frontend/src/components/TopBar.vue +++ b/frontend/src/components/TopBar.vue @@ -1,35 +1,11 @@ diff --git a/frontend/src/features/trace/components/EvidenceEventRow.vue b/frontend/src/features/trace/components/EvidenceEventRow.vue new file mode 100644 index 0000000..a5b3669 --- /dev/null +++ b/frontend/src/features/trace/components/EvidenceEventRow.vue @@ -0,0 +1,25 @@ + + + diff --git a/frontend/src/features/trace/components/EvidenceInspector.vue b/frontend/src/features/trace/components/EvidenceInspector.vue new file mode 100644 index 0000000..ae8cb2a --- /dev/null +++ b/frontend/src/features/trace/components/EvidenceInspector.vue @@ -0,0 +1,225 @@ + + + diff --git a/frontend/src/features/trace/components/EvidenceStream.vue b/frontend/src/features/trace/components/EvidenceStream.vue new file mode 100644 index 0000000..a80d6ee --- /dev/null +++ b/frontend/src/features/trace/components/EvidenceStream.vue @@ -0,0 +1,29 @@ + + + diff --git a/frontend/src/features/trace/components/ReviewDecisionPanel.vue b/frontend/src/features/trace/components/ReviewDecisionPanel.vue new file mode 100644 index 0000000..4fb38bf --- /dev/null +++ b/frontend/src/features/trace/components/ReviewDecisionPanel.vue @@ -0,0 +1,88 @@ + + + diff --git a/frontend/src/features/trace/components/RunEvidenceHeader.vue b/frontend/src/features/trace/components/RunEvidenceHeader.vue new file mode 100644 index 0000000..2b12154 --- /dev/null +++ b/frontend/src/features/trace/components/RunEvidenceHeader.vue @@ -0,0 +1,16 @@ + + + diff --git a/frontend/src/features/trace/components/RunLedger.vue b/frontend/src/features/trace/components/RunLedger.vue new file mode 100644 index 0000000..a5ab5ac --- /dev/null +++ b/frontend/src/features/trace/components/RunLedger.vue @@ -0,0 +1,16 @@ + + + diff --git a/frontend/src/features/trace/runEvidenceViewModel.ts b/frontend/src/features/trace/runEvidenceViewModel.ts new file mode 100644 index 0000000..aee2d89 --- /dev/null +++ b/frontend/src/features/trace/runEvidenceViewModel.ts @@ -0,0 +1,287 @@ +import type { + AgentRunTrace, + AgentStep, + GenerationRecord, + GenerationTrace, + HumanReview, + KnowledgeReference, + ToolCallRecord, +} from '@/types/domain' + +export type EvidenceAvailability = 'direct' | 'derived' | 'unavailable' +export type EvidenceTone = 'success' | 'running' | 'pending' | 'attention' | 'error' | 'unavailable' +export type EvidenceSource = 'AgentRun.providerName' | 'GenerationRecord.providerName' | 'GenerationTrace.providerName' | 'AgentRun.modelName' | 'GenerationRecord.modelName' | 'GenerationTrace.modelName' + +export interface EvidenceValue { + value: string + availability: EvidenceAvailability + source?: string +} + +export interface EvidenceEvent { + key: string + order: number + stepId: number + type: string + name: string + status: string + statusLabel: string + tone: EvidenceTone + summary: string + latencyMs?: number + startedAt?: string + completedAt?: string + toolCalls: ToolCallRecord[] + provenance: EvidenceValue + missingFields: string[] +} + +export interface ReviewViewModel { + record?: HumanReview + status: string + statusLabel: string + tone: EvidenceTone + availability: EvidenceAvailability + reason: EvidenceValue + reviewer: string + comment: string + createdAt: string + updatedAt: string + canSave: boolean + canConfirm: boolean + canRequestChanges: boolean + canReject: boolean + writeBoundary: string +} + +export interface RunEvidenceViewModel { + run: AgentRunTrace['run'] + record?: GenerationRecord + generationTrace?: GenerationTrace + events: EvidenceEvent[] + toolCalls: ToolCallRecord[] + unlinkedToolCalls: ToolCallRecord[] + references: KnowledgeReference[] + provider: EvidenceValue + model: EvidenceValue + errorEvidence: EvidenceValue + fallbackEvidence: EvidenceValue + routeEvidence: EvidenceValue + generatedOutput: EvidenceValue + review: ReviewViewModel + derivedAttention: string[] + knowledgeState: 'unused' | 'available' | 'evidence-gap' + normalizedSnapshot: Record +} + +export interface RunEvidenceInput { + trace: AgentRunTrace + records?: GenerationRecord[] + generationTraces?: GenerationTrace[] + references?: KnowledgeReference[] +} + +const missing = '未记录' + +export function createRunEvidenceViewModel(input: RunEvidenceInput): RunEvidenceViewModel { + const { trace } = input + const records = input.records ?? [] + const generationTraces = input.generationTraces ?? [] + const references = input.references ?? [] + const record = records.find((item) => item.id === trace.run.generationRecordId) + const generationTrace = [...generationTraces] + .filter((item) => !record || item.generationRecordId === record.id) + .sort((a, b) => b.id - a.id)[0] + + const provider = resolvedField([ + ['AgentRun.providerName', trace.run.providerName], + ['GenerationRecord.providerName', record?.providerName], + ['GenerationTrace.providerName', generationTrace?.providerName], + ]) + const model = resolvedField([ + ['AgentRun.modelName', trace.run.modelName], + ['GenerationRecord.modelName', record?.modelName], + ['GenerationTrace.modelName', generationTrace?.modelName], + ]) + const errorEvidence = evidenceValue(firstRecorded(record?.errorMessage, generationTrace?.errorMessage)) + const explicitFallback = explicitFallbackEvidence(trace.run.status, record?.status, generationTrace?.status) + const fallbackEvidence = explicitFallback + ? { value: explicitFallback, availability: 'direct' as const, source: 'explicit fallback status' } + : { value: '未记录明确降级结果', availability: 'unavailable' as const } + const routeEvidence = provider.value.toLowerCase().includes('local-rule') + ? { value: '本地演示路由', availability: 'direct' as const, source: provider.source } + : { value: '未记录路由信息', availability: 'unavailable' as const } + + const sortedSteps = [...(trace.steps ?? [])].sort((a, b) => { + if (a.stepOrder !== b.stepOrder) return a.stepOrder - b.stepOrder + return a.id - b.id + }) + const stepIds = new Set(sortedSteps.map((step) => step.id)) + const toolCalls = [...(trace.toolCalls ?? [])] + const events = sortedSteps.map((step) => toEvidenceEvent(step, toolCalls)) + const unlinkedToolCalls = toolCalls.filter((tool) => !tool.stepId || !stepIds.has(tool.stepId)) + const review = createReviewViewModel(trace.humanReviews ?? [], sortedSteps, record) + const hasKnowledgeStep = events.some((event) => normalizeStepType(event.type) === 'KNOWLEDGE_RETRIEVAL') + const knowledgeState = references.length ? 'available' : hasKnowledgeStep ? 'evidence-gap' : 'unused' + const derivedAttention = createDerivedAttention(events, record, generationTrace, knowledgeState) + const generatedOutput = evidenceValue(firstRecorded(record?.outputContent)) + + const normalizedSnapshot: Record = { + snapshotType: 'Normalized Snapshot', + provenance: 'Derived / frontend DTO normalization', + run: trace.run, + steps: sortedSteps, + toolCalls, + unlinkedToolCalls, + humanReviews: trace.humanReviews ?? [], + generationRecord: record ?? null, + generationTrace: generationTrace ?? null, + knowledgeReferences: references, + resolvedFields: { + provider: { value: provider.value, source: provider.source ?? null }, + model: { value: model.value, source: model.source ?? null }, + errorEvidence: errorEvidence.availability === 'direct' ? errorEvidence.value : null, + fallbackEvidence: fallbackEvidence.availability === 'direct' ? fallbackEvidence.value : null, + routeEvidence: routeEvidence.availability === 'direct' ? routeEvidence.value : null, + }, + } + + return { + run: trace.run, + record, + generationTrace, + events, + toolCalls, + unlinkedToolCalls, + references, + provider, + model, + errorEvidence, + fallbackEvidence, + routeEvidence, + generatedOutput, + review, + derivedAttention, + knowledgeState, + normalizedSnapshot, + } +} + +function resolvedField(candidates: Array<[EvidenceSource, string | undefined]>): EvidenceValue { + const candidate = candidates.find(([, value]) => Boolean(firstRecorded(value))) + return candidate + ? { value: firstRecorded(candidate[1])!, availability: 'direct', source: candidate[0] } + : { value: missing, availability: 'unavailable' } +} + +function explicitFallbackEvidence(...statuses: Array) { + const fallbackStatus = statuses.find((status) => normalizeStatus(status) === 'FALLBACK') + return fallbackStatus ? `状态记录为 ${fallbackStatus}` : undefined +} + +function toEvidenceEvent(step: AgentStep, toolCalls: ToolCallRecord[]): EvidenceEvent { + const missingFields: string[] = [] + if (!firstRecorded(step.summary)) missingFields.push('summary') + if (typeof step.latencyMs !== 'number') missingFields.push('latencyMs') + if (!firstRecorded(step.startedAt)) missingFields.push('startedAt') + if (!firstRecorded(step.completedAt)) missingFields.push('completedAt') + + return { + key: `step-${step.id}`, + order: step.stepOrder, + stepId: step.id, + type: firstRecorded(step.stepType) ?? missing, + name: firstRecorded(step.stepName) ?? missing, + status: normalizeStatus(step.status), + statusLabel: statusLabel(step.status), + tone: statusTone(step.status), + summary: firstRecorded(step.summary) ?? missing, + latencyMs: step.latencyMs, + startedAt: step.startedAt, + completedAt: step.completedAt, + toolCalls: toolCalls.filter((tool) => tool.stepId === step.id), + provenance: { value: 'trace.steps', availability: 'direct', source: 'AgentStep' }, + missingFields, + } +} + +function createReviewViewModel(reviews: HumanReview[], steps: AgentStep[], record?: GenerationRecord): ReviewViewModel { + const review = [...reviews].sort((a, b) => timestamp(b.updatedAt ?? b.createdAt) - timestamp(a.updatedAt ?? a.createdAt))[0] + const reviewStep = [...steps] + .filter((step) => normalizeStepType(step.stepType) === 'HUMAN_REVIEW' && Boolean(firstRecorded(step.summary))) + .sort((a, b) => a.stepOrder - b.stepOrder || a.id - b.id)[0] + const reason: EvidenceValue = reviewStep + ? { value: firstRecorded(reviewStep.summary)!, availability: 'direct', source: 'AgentStep.summary' } + : { value: missing, availability: 'unavailable' } + if (!review) { + return { + status: 'UNAVAILABLE', statusLabel: '未进入人工复核', tone: 'unavailable', availability: 'unavailable', + reason, + reviewer: missing, comment: missing, createdAt: missing, updatedAt: missing, + canSave: false, canConfirm: false, canRequestChanges: false, canReject: false, + writeBoundary: '当前 Run 未返回 humanReviews;不会推断复核已经完成。', + } + } + const recordStatus = normalizeStatus(record?.status) + return { + record: review, status: normalizeStatus(review.reviewStatus), statusLabel: reviewStatusLabel(review.reviewStatus), + tone: statusTone(review.reviewStatus), availability: 'direct', reason, reviewer: firstRecorded(review.reviewer) ?? missing, + comment: firstRecorded(review.comment) ?? missing, createdAt: firstRecorded(review.createdAt) ?? missing, + updatedAt: firstRecorded(review.updatedAt) ?? missing, canSave: recordStatus === 'READY_FOR_REVIEW', + canConfirm: recordStatus === 'SAVED', canRequestChanges: false, canReject: false, + writeBoundary: 'Save 与 Confirm 仅在现有 Generation API 状态允许时可用;Request Changes / Reject 当前没有写接口。', + } +} + +function createDerivedAttention(events: EvidenceEvent[], record: GenerationRecord | undefined, generationTrace: GenerationTrace | undefined, knowledgeState: RunEvidenceViewModel['knowledgeState']) { + const notices: string[] = [] + const failedCount = events.filter((event) => event.tone === 'error').length + if (failedCount) notices.push(`证据提示:${failedCount} 个步骤返回失败状态`) + if (!record) notices.push('证据提示:缺少 Generation Record') + if (!generationTrace) notices.push('证据提示:缺少 Generation Trace') + if (knowledgeState === 'evidence-gap') notices.push('证据缺口:知识检索步骤未返回引用') + return notices +} + +export function normalizeStatus(status?: string) { + if (!status) return 'UNAVAILABLE' + const aliases: Record = { Draft: 'DRAFT', Generating: 'GENERATING', 'Ready for Review': 'READY_FOR_REVIEW', Saved: 'SAVED', Confirmed: 'CONFIRMED', Failed: 'FAILED' } + return aliases[status] ?? status.toUpperCase().replace(/\s+/g, '_').replace(/-/g, '_') +} + +export function statusLabel(status?: string) { + const labels: Record = { + DRAFT: '草稿', GENERATING: '运行中', RUNNING: '运行中', READY_FOR_REVIEW: '待人工复核', WAITING_REVIEW: '待人工复核', + PENDING: '待处理', WAITING: '等待中', SAVED: '已保存', CONFIRMED: '已确认', SUCCESS: '成功', PASSED: '成功', + FAILED: '失败', ERROR: '错误', REJECTED: '已驳回', SKIPPED: '未执行', FALLBACK: '已降级', UNAVAILABLE: '未记录', + } + return labels[normalizeStatus(status)] ?? firstRecorded(status) ?? missing +} + +export function reviewStatusLabel(status?: string) { + const normalized = normalizeStatus(status) + if (['PENDING', 'READY_FOR_REVIEW', 'WAITING_REVIEW'].includes(normalized)) return '等待人工复核' + if (normalized === 'CONFIRMED') return '复核已确认' + if (normalized === 'REJECTED') return '复核已驳回' + if (normalized === 'SAVED') return '复核内容已保存' + return statusLabel(status) +} + +export function statusTone(status?: string): EvidenceTone { + const normalized = normalizeStatus(status) + if (['CONFIRMED', 'SAVED', 'SUCCESS', 'PASSED'].includes(normalized)) return 'success' + if (['FAILED', 'ERROR', 'REJECTED'].includes(normalized)) return 'error' + if (['GENERATING', 'RUNNING'].includes(normalized)) return 'running' + if (['READY_FOR_REVIEW', 'WAITING_REVIEW', 'PENDING', 'WAITING', 'DRAFT'].includes(normalized)) return 'pending' + if (normalized === 'FALLBACK') return 'attention' + return 'unavailable' +} + +function evidenceValue(value?: string): EvidenceValue { + const recorded = firstRecorded(value) + return recorded ? { value: recorded, availability: 'direct' } : { value: missing, availability: 'unavailable' } +} + +function normalizeStepType(value: string) { return value.toUpperCase().replace(/\s+/g, '_').replace(/-/g, '_') } +function firstRecorded(...values: Array) { return values.find((value): value is string => typeof value === 'string' && value.trim().length > 0)?.trim() } +function timestamp(value?: string) { const result = value ? new Date(value).getTime() : 0; return Number.isNaN(result) ? 0 : result } diff --git a/frontend/src/layouts/DevFlowLayout.vue b/frontend/src/layouts/DevFlowLayout.vue index 0bf6005..669c15e 100644 --- a/frontend/src/layouts/DevFlowLayout.vue +++ b/frontend/src/layouts/DevFlowLayout.vue @@ -20,7 +20,7 @@ const currentTitle = computed(() => {
-
+
diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 58e4fd4..7eb8c20 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -3,6 +3,8 @@ import ElementPlus from 'element-plus' import 'element-plus/dist/index.css' import 'github-markdown-css/github-markdown-dark.css' import './styles/theme.css' +import './styles/tokens.css' +import './styles/evidence-workspace.css' import App from './App.vue' import router from './router' diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index b9cbe48..c02177e 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -61,7 +61,7 @@ export const navItems: NavItem[] = [ path: '/agent-runs', name: 'AgentRunTrace', label: '执行证据 Trace', - displayTitle: '执行证据 Trace Evidence', + displayTitle: '执行证据', group: '可观测性', icon: DataLine, }, diff --git a/frontend/src/styles/evidence-workspace.css b/frontend/src/styles/evidence-workspace.css new file mode 100644 index 0000000..255fcad --- /dev/null +++ b/frontend/src/styles/evidence-workspace.css @@ -0,0 +1,1588 @@ +.content--workspace { + height: calc(100vh - var(--topbar-height)); + padding: 12px; + overflow: hidden; + background: var(--surface-workspace); +} + +.evidence-workspace { + height: 100%; + min-height: 620px; + display: flex; + flex-direction: column; + gap: 8px; + color: var(--text-primary); + font-size: var(--type-body); +} + +.evidence-workspace button { + min-height: 32px; + font-size: 12px; +} + +body.drawer-lock { + overflow: hidden; +} + +.workspace-kicker, +.pane-kicker { + margin: 0; + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 10px; + line-height: 1.4; + letter-spacing: 0.07em; + text-transform: uppercase; +} + +.workspace-kicker { + color: var(--status-success); +} + +.run-context-bar { + position: relative; + min-width: 0; + min-height: 90px; + display: grid; + grid-template-columns: minmax(280px, 1fr) minmax(560px, 1.7fr); + align-items: center; + gap: 24px; + padding: 14px 16px; + border: 1px solid var(--line-default); + border-radius: var(--radius-panel); + background: var(--surface-panel); +} + +.run-context-title { + min-width: 0; +} + +.run-context-title h1 { + max-width: 680px; + display: -webkit-box; + margin: 3px 0 6px; + overflow: hidden; + color: var(--text-primary); + font-size: var(--type-page); + line-height: 1.35; + font-weight: 650; + white-space: normal; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.run-title-meta { + display: flex; + align-items: center; + gap: 12px; + color: var(--text-muted); + font-size: 11px; +} + +.run-context-facts { + min-width: 0; + display: grid; + grid-template-columns: repeat(4, minmax(90px, 1fr)); + margin: 0; + border-left: 1px solid var(--line-subtle); +} + +.run-context-facts > div { + min-width: 0; + padding: 2px 12px; + border-right: 1px solid var(--line-subtle); +} + +.run-context-facts .context-source { + grid-column: 1 / -1; + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid var(--line-subtle); +} + +.run-context-facts dt, +.run-context-facts dd { + min-width: 0; + margin: 0; +} + +.run-context-facts small { + display: block; + margin-top: 2px; + overflow: hidden; + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 10px; + line-height: 1.4; + text-overflow: ellipsis; + white-space: nowrap; +} + +.context-source-tag { + padding: 2px 5px; + border: 1px solid var(--line-subtle); + border-radius: 3px; + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 10px; +} + +.run-context-facts dt { + color: var(--text-muted); + font-size: 10px; + line-height: 16px; +} + +.run-context-facts dd { + overflow: hidden; + color: var(--text-secondary); + font-size: 12px; + line-height: 18px; + text-overflow: ellipsis; + white-space: nowrap; +} + +[data-availability="unavailable"] { + color: var(--status-unavailable) !important; +} + +[data-availability="derived"] { + color: var(--status-derived) !important; +} + +[data-availability="direct"] { + color: var(--status-success) !important; +} + +.mobile-inspector-trigger { + display: none; +} + +.mobile-run-selector { + display: none; +} + +.workspace-loading { + position: absolute; + z-index: 20; + top: calc(var(--topbar-height) + 8px); + right: 24px; + min-height: 28px; + display: inline-flex; + align-items: center; + gap: 8px; + padding: 0 10px; + border: 1px solid var(--line-default); + border-radius: var(--radius-control); + background: var(--surface-elevated); + color: var(--text-secondary); + font-size: 12px; +} + +.workspace-loading span { + width: 8px; + height: 8px; + border: 2px solid var(--status-running); + border-right-color: transparent; + border-radius: 50%; + animation: evidence-spin 900ms linear infinite; +} + +@keyframes evidence-spin { + to { transform: rotate(360deg); } +} + +.evidence-workspace-grid { + flex: 1; + min-height: 0; + display: grid; + grid-template-columns: clamp(216px, 18vw, 248px) minmax(480px, 1fr) clamp(310px, 24vw, 352px); + gap: 1px; + overflow: hidden; + border: 1px solid var(--line-default); + border-radius: var(--radius-panel); + background: var(--line-default); +} + +.workspace-pane { + min-width: 0; + min-height: 0; + background: var(--surface-panel); +} + +.pane-toolbar { + min-height: 54px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 12px; + border-bottom: 1px solid var(--line-subtle); + background: var(--surface-navigation); +} + +.pane-toolbar h2, +.inspector-header h2 { + margin: 2px 0 0; + color: var(--text-primary); + font-size: var(--type-region); + line-height: 1.35; + font-weight: 630; +} + +.quiet-button, +.primary-quiet-button, +.decision-action, +.mobile-inspector-trigger { + border: 1px solid var(--line-default); + border-radius: var(--radius-control); + background: var(--surface-elevated); + color: var(--text-secondary); + cursor: pointer; + transition: + color var(--motion-fast) var(--motion-ease), + border-color var(--motion-fast) var(--motion-ease), + background var(--motion-fast) var(--motion-ease); +} + +.quiet-button { + padding: 0 9px; +} + +.primary-quiet-button, +.decision-action.primary { + border-color: rgba(101, 211, 164, 0.42); + background: var(--status-success-soft); + color: var(--status-success); +} + +.quiet-button:hover, +.primary-quiet-button:hover:not(:disabled), +.decision-action:hover:not(:disabled) { + border-color: var(--line-focus); + color: var(--text-primary); +} + +.quiet-button:focus-visible, +.primary-quiet-button:focus-visible, +.decision-action:focus-visible, +.run-ledger-row:focus-visible, +.evidence-event-row:focus-visible, +.inspector-tabs button:focus-visible, +.inspector-close:focus-visible, +.nav-item:focus-visible { + outline: 2px solid var(--line-focus); + outline-offset: -2px; +} + +.run-ledger-pane { + display: flex; + flex-direction: column; +} + +.run-ledger-list { + flex: 1; + min-height: 0; + overflow-y: auto; +} + +.run-ledger-row { + width: 100%; + min-height: 78px; + display: grid; + grid-template-columns: 4px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + padding: 10px 10px 10px 0; + border: 0; + border-bottom: 1px solid var(--line-subtle); + background: transparent; + color: inherit; + cursor: pointer; + text-align: left; + transition: background var(--motion-fast) var(--motion-ease); +} + +.run-ledger-row:hover { + background: rgba(255, 255, 255, 0.018); +} + +.run-ledger-row.selected { + background: linear-gradient(90deg, var(--status-success-soft), transparent 84%); +} + +.run-ledger-marker { + align-self: stretch; + width: 3px; + background: var(--status-unavailable); +} + +.run-ledger-row.selected .run-ledger-marker, +.run-ledger-marker[data-tone="success"] { + background: var(--status-success); +} + +.run-ledger-marker[data-tone="running"] { + background: var(--status-running); +} + +.run-ledger-marker[data-tone="pending"], +.run-ledger-marker[data-tone="attention"] { + background: var(--status-pending); +} + +.run-ledger-marker[data-tone="error"] { + background: var(--status-error); +} + +.run-ledger-copy, +.run-ledger-state { + min-width: 0; +} + +.run-ledger-copy strong, +.run-ledger-copy small, +.run-ledger-state small, +.run-ledger-state time { + display: block; +} + +.run-ledger-copy strong { + display: -webkit-box; + overflow: hidden; + color: var(--text-primary); + font-size: 13px; + line-height: 1.55; + font-weight: 600; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.run-ledger-copy small { + margin-top: 5px; + overflow: hidden; + color: var(--text-muted); + font-size: 10px; + line-height: 16px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.run-ledger-copy small span + span::before { + content: "·"; + margin: 0 5px; +} + +.run-ledger-state { + align-self: stretch; + display: flex; + flex-direction: column; + justify-content: space-between; + text-align: right; +} + +.run-ledger-state small { + color: var(--text-secondary); + font-size: 10px; +} + +.run-ledger-state time { + color: var(--text-muted); + font-size: 10px; +} + +.ledger-boundary { + min-height: 38px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 12px; + border-top: 1px solid var(--line-subtle); + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 10px; +} + +.evidence-stream-pane { + display: flex; + flex-direction: column; +} + +.stream-counters { + display: flex; + gap: 6px; +} + +.stream-counters span { + min-height: 24px; + display: inline-flex; + align-items: center; + gap: 4px; + padding: 0 7px; + border: 1px solid var(--line-subtle); + border-radius: var(--radius-control); + color: var(--text-muted); + font-size: 10px; +} + +.stream-counters b { + color: var(--text-secondary); + font-family: var(--font-mono); + font-weight: 500; +} + +.evidence-event-list { + position: relative; + flex: 1; + min-height: 0; + overflow-y: auto; + background: + linear-gradient(90deg, transparent 37px, var(--line-subtle) 37px, var(--line-subtle) 38px, transparent 38px); +} + +.evidence-event-row { + position: relative; + width: 100%; + min-height: 88px; + display: grid; + grid-template-columns: 52px minmax(0, 1fr) 96px; + align-items: center; + gap: 10px; + padding: 9px 12px 9px 0; + border: 0; + border-bottom: 1px solid var(--line-subtle); + background: transparent; + color: inherit; + cursor: pointer; + text-align: left; + transition: background var(--motion-fast) var(--motion-ease); +} + +.evidence-event-row:hover { + background: rgba(255, 255, 255, 0.016); +} + +.evidence-event-row.selected { + background: + linear-gradient(90deg, rgba(101, 211, 164, 0.09), rgba(101, 211, 164, 0.02) 72%, transparent); + box-shadow: inset 2px 0 0 var(--status-success); +} + +.evidence-event-row[data-tone="error"].selected { + background: linear-gradient(90deg, var(--status-error-soft), transparent 76%); + box-shadow: inset 2px 0 0 var(--status-error); +} + +.event-rail { + position: relative; + align-self: stretch; + display: grid; + place-items: center; +} + +.event-order { + position: absolute; + top: 1px; + left: 7px; + color: var(--text-muted); + font-size: 10px; +} + +.event-node { + position: relative; + z-index: 1; + width: 22px; + height: 22px; + display: grid; + place-items: center; + border: 1px solid var(--status-success); + border-radius: 50%; + background: var(--surface-panel); + color: var(--status-success); + font-size: 11px; + font-weight: 700; +} + +.evidence-event-row[data-tone="running"] .event-node { + border-color: var(--status-running); + color: var(--status-running); +} + +.evidence-event-row[data-tone="pending"] .event-node, +.evidence-event-row[data-tone="attention"] .event-node { + border-color: var(--status-pending); + border-radius: 5px; + color: var(--status-pending); +} + +.evidence-event-row[data-tone="error"] .event-node { + border-color: var(--status-error); + border-radius: 5px; + color: var(--status-error); +} + +.evidence-event-row[data-tone="unavailable"] .event-node { + border-color: var(--status-unavailable); + color: var(--status-unavailable); +} + +.event-copy { + min-width: 0; + display: block; +} + +.event-type-line { + display: flex; + align-items: center; + gap: 7px; + color: var(--text-muted); + font-size: 10px; + line-height: 14px; +} + +.provenance-label { + min-height: 16px; + display: inline-flex; + align-items: center; + padding: 0 5px; + border: 1px solid rgba(101, 211, 164, 0.24); + border-radius: 3px; + background: var(--status-success-soft); + font-family: var(--font-mono); + font-size: 10px; +} + +.event-copy strong { + display: block; + margin-top: 1px; + color: var(--text-primary); + font-size: var(--type-event); + line-height: 1.45; + font-weight: 620; +} + +.event-summary { + display: -webkit-box; + margin-top: 3px; + overflow: hidden; + color: var(--text-secondary); + font-size: 12px; + line-height: 1.55; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.event-tool-chip { + width: max-content; + max-width: 96px; + margin-top: 5px; + padding: 2px 6px; + overflow: hidden; + border: 1px solid var(--line-subtle); + border-radius: 999px; + color: var(--text-muted); + font-size: 10px; + line-height: 14px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.event-state { + min-width: 0; + align-self: stretch; + display: flex; + flex-direction: column; + align-items: flex-end; + justify-content: space-between; + color: var(--text-muted); + font-size: 10px; +} + +.evidence-status { + min-height: 21px; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0 7px; + border: 1px solid var(--status-border, var(--line-default)); + border-radius: var(--radius-control); + background: var(--status-bg, var(--status-unavailable-soft)); + color: var(--status-fg, var(--status-unavailable)); + font-size: 10px; + line-height: 1; + white-space: nowrap; +} + +.evidence-status i { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; +} + +.evidence-status[data-tone="success"] { + --status-border: rgba(101, 211, 164, 0.34); + --status-bg: var(--status-success-soft); + --status-fg: var(--status-success); +} + +.evidence-status[data-tone="running"] { + --status-border: rgba(120, 183, 238, 0.34); + --status-bg: var(--status-running-soft); + --status-fg: var(--status-running); +} + +.evidence-status[data-tone="pending"], +.evidence-status[data-tone="attention"] { + --status-border: rgba(212, 170, 99, 0.34); + --status-bg: var(--status-pending-soft); + --status-fg: var(--status-pending); +} + +.evidence-status[data-tone="error"] { + --status-border: rgba(225, 123, 127, 0.36); + --status-bg: var(--status-error-soft); + --status-fg: var(--status-error); +} + +.duration-rail { + width: 76px; + height: 2px; + overflow: hidden; + background: var(--line-subtle); +} + +.duration-rail i { + display: block; + width: var(--duration); + height: 100%; + background: var(--status-running); +} + +.evidence-event-row[data-tone="success"] .duration-rail i { + background: var(--status-success); +} + +.evidence-event-row[data-tone="pending"] .duration-rail i, +.evidence-event-row[data-tone="attention"] .duration-rail i { + background: var(--status-pending); +} + +.evidence-event-row[data-tone="error"] .duration-rail i { + background: var(--status-error); +} + +.derived-attention { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 7px 10px; + border-top: 1px solid rgba(179, 155, 228, 0.22); + background: var(--status-derived-soft); +} + +.derived-attention span { + color: var(--status-derived); + font-size: 10px; + line-height: 16px; +} + +.stream-keyboard-hint { + min-height: 34px; + display: flex; + align-items: center; + gap: 14px; + padding: 0 12px; + border-top: 1px solid var(--line-subtle); + color: var(--text-muted); + font-size: 10px; +} + +.stream-keyboard-hint kbd { + min-width: 18px; + height: 18px; + display: inline-grid; + place-items: center; + margin-right: 3px; + border: 1px solid var(--line-default); + border-radius: 3px; + background: var(--surface-elevated); + color: var(--text-secondary); + font-family: var(--font-mono); + font-size: 10px; +} + +.mobile-selected-event { + display: none; +} + +.inspector-pane { + display: flex; + flex-direction: column; +} + +.inspector-header { + min-height: 54px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 12px; + border-bottom: 1px solid var(--line-subtle); + background: var(--surface-navigation); +} + +.inspector-close { + display: none; + width: 32px; + padding: 0; + border: 1px solid var(--line-default); + border-radius: var(--radius-control); + background: transparent; + color: var(--text-secondary); + cursor: pointer; + font-size: 20px !important; +} + +.inspector-tabs { + min-height: 38px; + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + border-bottom: 1px solid var(--line-subtle); +} + +.inspector-tabs button { + position: relative; + min-width: 0; + padding: 0 4px; + border: 0; + background: transparent; + color: var(--text-muted); + cursor: pointer; + font-family: var(--font-mono); + font-size: 10px; +} + +.inspector-tabs button span, +.inspector-tabs button small { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.inspector-tabs button small { + margin-top: 1px; + color: var(--text-disabled); + font-size: 10px; +} + +.inspector-tabs button.active { + color: var(--status-success); +} + +.inspector-tabs button.active::after { + content: ""; + position: absolute; + right: 8px; + bottom: 0; + left: 8px; + height: 2px; + background: var(--status-success); +} + +.inspector-content { + flex: 1; + min-height: 0; + overflow-y: auto; +} + +.inspector-section, +.decision-ledger { + padding: 12px; +} + +.inspector-selection { + display: grid; + grid-template-columns: 34px minmax(0, 1fr); + align-items: center; + gap: 10px; + padding-bottom: 10px; + border-bottom: 1px solid var(--line-subtle); +} + +.inspector-selection > span { + width: 32px; + height: 32px; + display: grid; + place-items: center; + border: 1px solid var(--status-success); + border-radius: 50%; + color: var(--status-success); + font-size: 11px; +} + +.inspector-selection h3, +.snapshot-header h3 { + margin: 2px 0 0; + color: var(--text-primary); + font-size: 14px; + line-height: 1.5; + font-weight: 620; +} + +.inspector-summary { + margin: 10px 0; + color: var(--text-secondary); + font-size: 12px; + line-height: var(--leading-cn); +} + +.evidence-brief { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 1px; + margin: 12px 0; + overflow: hidden; + border: 1px solid var(--line-subtle); + border-radius: var(--radius-control); + background: var(--line-subtle); +} + +.evidence-brief div { + min-width: 0; + padding: 8px; + background: var(--surface-workspace); +} + +.evidence-brief span, +.evidence-brief strong { + display: block; +} + +.evidence-brief span { + color: var(--text-muted); + font-size: 10px; +} + +.evidence-brief strong { + margin-top: 4px; + overflow: hidden; + color: var(--text-secondary); + font-size: 11px; + line-height: 1.55; + text-overflow: ellipsis; +} + +.linked-evidence-list { + display: grid; + gap: 8px; + margin: 12px 0; +} + +.linked-evidence-list h4 { + margin: 0; + color: var(--text-primary); + font-size: 12px; +} + +.linked-evidence-list ul { + display: grid; + gap: 6px; + margin: 0; + padding: 0; + list-style: none; +} + +.linked-evidence-list li { + min-width: 0; + display: grid; + grid-template-columns: 118px minmax(0, 1fr); + gap: 8px; + padding: 7px 0; + border-top: 1px solid var(--line-subtle); +} + +.linked-evidence-list strong { + color: var(--text-secondary); + font-size: 11px; +} + +.linked-evidence-list span { + overflow: hidden; + color: var(--text-muted); + font-size: 12px; + line-height: 1.45; + text-overflow: ellipsis; +} + +.context-jump-list { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} + +.context-jump-list button { + min-height: 28px; + padding: 0 8px; + border: 1px solid var(--line-subtle); + border-radius: var(--radius-control); + background: var(--surface-elevated); + color: var(--text-secondary); + cursor: pointer; + font-size: 12px; +} + +.inspector-definition-list { + display: grid; + margin: 0; + border-top: 1px solid var(--line-subtle); +} + +.inspector-definition-list > div { + min-width: 0; + display: grid; + grid-template-columns: 92px minmax(0, 1fr); + gap: 10px; + padding: 7px 0; + border-bottom: 1px solid var(--line-subtle); +} + +.inspector-definition-list dt, +.inspector-definition-list dd { + min-width: 0; + margin: 0; + font-size: 11px; + line-height: 1.55; +} + +.inspector-definition-list dt { + color: var(--text-muted); +} + +.inspector-definition-list dd { + overflow-wrap: anywhere; + color: var(--text-secondary); +} + +.inspector-definition-list dd small { + display: block; + margin-top: 2px; + color: var(--text-muted); + font-size: 10px; +} + +.inspector-definition-list .long-value { + max-height: 112px; + overflow: auto; +} + +.attention-list { + display: grid; + gap: 5px; + margin-top: 10px; +} + +.attention-list span { + padding: 6px 8px; + border-left: 2px solid var(--status-derived); + background: var(--status-derived-soft); + color: var(--status-derived); + font-size: 10px; + line-height: 1.5; +} + +.evidence-stack { + display: grid; + gap: 10px; +} + +.linked-evidence-card, +.tool-call-card { + padding: 10px; + border: 1px solid var(--line-subtle); + border-radius: var(--radius-control); + background: var(--surface-workspace); +} + +.linked-evidence-card header, +.tool-call-card header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.linked-evidence-card header strong, +.tool-call-card header strong { + color: var(--text-primary); + font-size: 12px; +} + +.linked-evidence-card header span, +.tool-call-card header span { + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 10px; + text-transform: capitalize; +} + +.linked-evidence-card p, +.tool-call-card p { + margin: 7px 0 0; + color: var(--text-secondary); + font-size: 11px; + line-height: var(--leading-cn); + overflow-wrap: anywhere; +} + +.linked-evidence-card dl, +.tool-call-card dl { + display: grid; + gap: 4px; + margin: 8px 0 0; +} + +.linked-evidence-card dl > div, +.tool-call-card dl > div { + min-width: 0; + display: grid; + grid-template-columns: 70px minmax(0, 1fr); + gap: 8px; +} + +.linked-evidence-card dt, +.linked-evidence-card dd, +.tool-call-card dt, +.tool-call-card dd { + min-width: 0; + margin: 0; + color: var(--text-muted); + font-size: 10px; + line-height: 1.5; + overflow-wrap: anywhere; +} + +.linked-evidence-card dd, +.tool-call-card dd { + color: var(--text-secondary); +} + +.generated-output { + max-height: 120px; + overflow: auto; + white-space: pre-wrap; +} + +.reference-list { + display: grid; + gap: 8px; + margin: 8px 0 0; + padding: 0; + list-style: none; +} + +.reference-list li { + padding-top: 7px; + border-top: 1px solid var(--line-subtle); +} + +.reference-list strong, +.reference-list span { + display: block; +} + +.reference-list strong { + color: var(--status-success); + font-size: 10px; +} + +.reference-list span { + margin-top: 3px; + color: var(--text-secondary); + font-size: 10px; + line-height: 1.55; +} + +.tool-call-card.unlinked { + border-left: 2px solid var(--status-derived); +} + +.inline-empty { + margin: 8px 0 0; + color: var(--text-muted); + font-size: 11px; +} + +.snapshot-section { + height: 100%; + min-height: 0; + display: flex; + flex-direction: column; +} + +.snapshot-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 8px; +} + +.snapshot-section pre { + flex: 1; + min-height: 240px; + margin: 0; + padding: 12px; + overflow: auto; + border: 1px solid var(--line-subtle); + border-radius: var(--radius-control); + background: #081015; + color: var(--text-mono); + font-family: var(--font-mono); + font-size: 10px; + line-height: 1.65; + white-space: pre; +} + +.decision-current { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 10px; +} + +.decision-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 7px; + margin-top: 12px; +} + +.decision-action { + padding: 0 8px; +} + +.decision-action.danger { + color: var(--status-error); +} + +.decision-action:disabled, +.quiet-button:disabled { + border-color: var(--line-subtle); + background: var(--surface-workspace); + color: var(--text-disabled); + cursor: not-allowed; +} + +.write-boundary { + margin: 10px 0 0; + padding: 8px; + border-left: 2px solid var(--status-pending); + background: var(--status-pending-soft); + color: var(--text-secondary); + font-size: 10px; + line-height: var(--leading-cn); +} + +.workspace-empty { + min-height: 180px; + display: grid; + place-content: center; + gap: 5px; + padding: 20px; + color: var(--text-muted); + text-align: center; +} + +.workspace-empty.compact { + flex: 1; +} + +.workspace-empty strong { + color: var(--text-secondary); + font-size: 13px; +} + +.workspace-empty span { + font-size: 11px; + line-height: 1.6; +} + +.workspace-state { + flex: 1; + min-height: 260px; + display: flex; + align-items: center; + justify-content: center; + gap: 24px; + padding: 32px; + border: 1px dashed var(--line-default); + border-radius: var(--radius-panel); + background: var(--surface-panel); +} + +.workspace-state h2 { + margin: 4px 0; + font-size: 18px; +} + +.workspace-state p:not(.pane-kicker) { + max-width: 560px; + margin: 0; + color: var(--text-secondary); + font-size: 12px; + line-height: var(--leading-cn); +} + +.inspector-backdrop { + display: none; +} + +.evidence-workspace *, +.run-context-bar > *, +.run-context-facts > *, +.evidence-workspace-grid > *, +.pane-toolbar > *, +.evidence-event-row > *, +.event-copy > *, +.inspector-header > *, +.inspector-selection > * { + min-width: 0; +} + +@media (max-width: 1280px) { + .run-context-bar { + grid-template-columns: minmax(260px, 0.85fr) minmax(520px, 1.5fr); + gap: 14px; + } + + .evidence-workspace-grid { + grid-template-columns: 216px minmax(430px, 1fr) 310px; + } +} + +@media (max-width: 1180px) and (min-width: 721px) { + .run-context-bar { + grid-template-columns: minmax(220px, 0.75fr) minmax(500px, 1.4fr); + } + + .run-context-title h1 { + font-size: 18px; + } + + .run-context-bar { + min-height: 102px; + padding-bottom: 34px; + } + + .mobile-inspector-trigger { + position: absolute; + right: 14px; + bottom: 8px; + display: block; + padding: 0 9px; + } + + .evidence-workspace-grid { + grid-template-columns: 210px minmax(0, 1fr); + } + + .inspector-pane { + position: fixed; + z-index: 210; + top: calc(var(--topbar-height) + 12px); + right: 12px; + bottom: 12px; + width: min(360px, calc(100vw - 104px)); + overflow: hidden; + border: 1px solid var(--line-strong); + border-radius: var(--radius-float); + box-shadow: 0 24px 64px rgba(0, 0, 0, 0.42); + transform: translateX(calc(100% + 20px)); + opacity: 0; + transition: + transform var(--motion-standard) var(--motion-ease), + opacity var(--motion-standard) var(--motion-ease); + pointer-events: none; + } + + .inspector-pane.open { + transform: translateX(0); + opacity: 1; + pointer-events: auto; + } + + .inspector-close { + display: block; + } + + .inspector-backdrop { + position: fixed; + z-index: 200; + inset: var(--topbar-height) 0 0 72px; + display: block; + background: rgba(6, 10, 13, 0.18); + } +} + +@media (max-width: 720px) { + .content--workspace { + height: auto; + min-height: calc(100vh - 52px); + padding: 8px 8px max(16px, env(safe-area-inset-bottom)); + overflow: visible; + } + + .evidence-workspace { + height: auto; + min-height: 0; + } + + .run-context-bar { + min-height: 0; + display: block; + padding: 12px; + } + + .run-context-title h1 { + display: -webkit-box; + margin-top: 4px; + overflow: hidden; + font-size: 18px; + line-height: 1.45; + white-space: normal; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + } + + .run-title-meta { + flex-wrap: wrap; + } + + .run-context-facts { + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin-top: 12px; + padding-top: 10px; + border-top: 1px solid var(--line-subtle); + border-left: 0; + } + + .run-context-facts > div { + padding: 5px 8px 5px 0; + border-right: 0; + } + + .mobile-run-selector { + min-width: 0; + display: grid; + gap: 8px; + padding: 10px 12px; + border: 1px solid var(--line-default); + border-radius: var(--radius-panel); + background: var(--surface-panel); + } + + .mobile-run-selector strong, + .mobile-run-selector span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .mobile-run-selector strong { + color: var(--text-primary); + font-size: 13px; + } + + .mobile-run-selector span { + margin-top: 3px; + color: var(--text-muted); + font-size: 11px; + } + + .mobile-run-selector select { + width: 100%; + min-height: 34px; + border: 1px solid var(--line-default); + border-radius: var(--radius-control); + background: var(--surface-elevated); + color: var(--text-primary); + font-size: 12px; + } + + .run-context-facts .context-source { + margin-top: 3px; + padding-top: 6px; + } + + .run-context-facts dd { + white-space: normal; + overflow-wrap: anywhere; + } + + .mobile-inspector-trigger { + width: 100%; + display: block; + margin-top: 10px; + } + + .workspace-loading { + position: static; + align-self: flex-end; + } + + .evidence-workspace-grid { + display: block; + overflow: visible; + border-radius: var(--radius-panel); + } + + .run-ledger-pane { + display: none; + } + + .evidence-stream-pane { + min-height: 520px; + } + + .pane-toolbar { + min-height: 58px; + } + + .stream-counters span:last-child { + display: none; + } + + .evidence-event-list { + max-height: none; + overflow: visible; + } + + .evidence-event-row { + min-height: 108px; + grid-template-columns: 44px minmax(0, 1fr); + gap: 7px; + padding: 10px 9px 10px 0; + } + + .event-state { + grid-column: 2; + min-height: 22px; + flex-direction: row; + align-items: center; + justify-content: flex-start; + gap: 10px; + } + + .event-summary { + -webkit-line-clamp: 3; + } + + .evidence-brief { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .linked-evidence-list li { + grid-template-columns: minmax(0, 1fr); + } + + .stream-keyboard-hint { + display: none; + } + + .mobile-selected-event { + display: grid; + gap: 6px; + padding: 12px; + border-top: 1px solid var(--line-default); + background: var(--surface-elevated); + } + + .mobile-selected-event strong { + font-size: 14px; + } + + .mobile-selected-event > span { + color: var(--text-secondary); + font-size: 12px; + line-height: var(--leading-cn); + } + + .mobile-selected-event .primary-quiet-button { + margin-top: 4px; + } + + .inspector-pane { + position: fixed; + z-index: 310; + right: 0; + bottom: 0; + left: 0; + width: auto; + max-height: min(58vh, 560px); + overflow: hidden; + border: 1px solid var(--line-strong); + border-bottom: 0; + border-radius: var(--radius-float) var(--radius-float) 0 0; + box-shadow: 0 -20px 60px rgba(0, 0, 0, 0.48); + transform: translateY(calc(100% + 16px)); + opacity: 0; + transition: + transform var(--motion-standard) var(--motion-ease), + opacity var(--motion-standard) var(--motion-ease); + pointer-events: none; + padding-bottom: max(8px, env(safe-area-inset-bottom)); + } + + .inspector-pane.open { + transform: translateY(0); + opacity: 1; + pointer-events: auto; + } + + .inspector-backdrop { + position: fixed; + z-index: 300; + inset: 0; + display: block; + background: rgba(6, 10, 13, 0.22); + } + + .inspector-close { + display: block; + } + + .inspector-tabs { + overflow-x: auto; + grid-template-columns: repeat(5, minmax(72px, 1fr)); + } + + .inspector-content { + overscroll-behavior: contain; + } + + .snapshot-section pre { + overflow: auto; + } + + .workspace-state { + min-height: 320px; + flex-direction: column; + align-items: stretch; + padding: 24px 16px; + } +} + +@media (prefers-reduced-motion: reduce) { + .workspace-loading span { + animation: none; + } + + .inspector-pane { + transition: none; + } +} diff --git a/frontend/src/styles/theme.css b/frontend/src/styles/theme.css index 51677ce..6cc4511 100644 --- a/frontend/src/styles/theme.css +++ b/frontend/src/styles/theme.css @@ -221,7 +221,7 @@ textarea { .brand-copy span { color: var(--color-text-secondary); - font-size: 9px; + font-size: 10px; letter-spacing: 0; } @@ -244,7 +244,7 @@ textarea { .workspace-label span { color: var(--color-text-disabled); - font-size: 9px; + font-size: 10px; letter-spacing: 0.08em; } @@ -271,7 +271,7 @@ textarea { background: var(--color-bg-elevated); color: var(--color-accent); font-family: var(--font-mono); - font-size: 9px; + font-size: 10px; } .nav-stack { @@ -360,7 +360,7 @@ textarea { .sidebar-status span { color: var(--color-text-secondary); - font-size: 9px; + font-size: 10px; } .pulse-dot { @@ -426,7 +426,7 @@ p { .topbar-title span { color: var(--color-text-secondary); - font-size: 9px; + font-size: 10px; letter-spacing: 0.08em; } @@ -704,3 +704,195 @@ p { animation: none; } } + +/* Phase 1 · minimal Layered Graphite App Shell upgrade */ +.sidebar { + background: var(--surface-navigation); + border-right-color: var(--line-subtle); + transition: + width var(--motion-standard) var(--motion-ease), + padding var(--motion-standard) var(--motion-ease); +} + +.topbar { + grid-template-columns: minmax(180px, 0.75fr) minmax(260px, 520px) minmax(220px, 0.75fr); + background: rgba(15, 22, 27, 0.96); + backdrop-filter: blur(14px); +} + +.topbar-search { + background: var(--surface-workspace); +} + +.topbar-page-context { + min-width: 0; + display: flex; + align-items: center; + gap: var(--space-2); + color: var(--color-text-secondary); + font-size: 11px; +} + +.topbar-page-context .mono { + color: var(--color-text-disabled); + font-size: 10px; + letter-spacing: 0.06em; +} + +.topbar-page-context span:last-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.content { + background: var(--surface-workspace); +} + +@media (max-width: 1180px) and (min-width: 721px) { + :root { + --sidebar-width: 72px; + } + + .shell { + grid-template-columns: var(--sidebar-width) minmax(0, 1fr); + } + + .sidebar { + position: sticky; + width: var(--sidebar-width); + height: 100vh; + padding: 12px 8px; + } + + .brand { + justify-content: center; + padding: 0; + } + + .brand-copy, + .workspace-label, + .nav-group-label, + .nav-item span, + .sidebar-status { + display: none; + } + + .nav-stack { + gap: 8px; + padding: 0; + } + + .nav-group { + gap: 5px; + } + + .nav-item { + width: 40px; + height: 38px; + justify-content: center; + margin: 0 auto; + padding: 0; + border-left-width: 1px; + } + + .topbar { + grid-template-columns: minmax(180px, 0.8fr) minmax(220px, 1.4fr) auto; + height: var(--topbar-height); + min-height: var(--topbar-height); + padding: 0 12px; + } + + .topbar-actions .system-chip:first-child { + display: none; + } + + .content { + padding: 16px; + } + + .content--workspace { + padding: 12px; + } +} + +@media (max-width: 720px) { + :root { + --sidebar-width: 100%; + --topbar-height: 0px; + } + + .shell { + display: block; + } + + .sidebar { + position: sticky; + z-index: 220; + top: 0; + width: 100%; + height: 52px; + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + padding: 7px 8px; + border-right: 0; + border-bottom: 1px solid var(--line-subtle); + overflow: hidden; + } + + .brand { + width: 36px; + height: 36px; + flex: 0 0 auto; + padding: 0; + } + + .brand-copy, + .workspace-label, + .nav-group-label, + .nav-item span, + .sidebar-status { + display: none; + } + + .nav-stack { + display: flex; + flex: 1; + gap: 4px; + padding: 0 0 0 4px; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; + } + + .nav-stack::-webkit-scrollbar { + display: none; + } + + .nav-group { + display: contents; + } + + .nav-item { + width: 34px; + height: 34px; + flex: 0 0 34px; + justify-content: center; + padding: 0; + border-left-width: 1px; + } + + .topbar { + display: none; + } + + .content { + padding: 12px; + } + + .content--workspace { + padding: 8px 8px max(16px, env(safe-area-inset-bottom)); + } +} diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css new file mode 100644 index 0000000..1486a8e --- /dev/null +++ b/frontend/src/styles/tokens.css @@ -0,0 +1,123 @@ +:root { + color-scheme: dark; + + /* Surface · Layered Graphite */ + --surface-shell: #0a0f13; + --surface-navigation: #0f161b; + --surface-workspace: #0c1217; + --surface-panel: #11191f; + --surface-elevated: #172229; + --surface-selected: #172a29; + --surface-overlay: rgba(6, 10, 13, 0.78); + + /* Text */ + --text-primary: #edf3f1; + --text-secondary: #b5c0bf; + --text-muted: #7e8d90; + --text-disabled: #566267; + --text-inverse: #09110f; + --text-mono: #b8d5d0; + + /* Status */ + --status-success: #65d3a4; + --status-success-soft: rgba(101, 211, 164, 0.12); + --status-running: #78b7ee; + --status-running-soft: rgba(120, 183, 238, 0.12); + --status-pending: #d4aa63; + --status-pending-soft: rgba(212, 170, 99, 0.12); + --status-attention: #e09462; + --status-attention-soft: rgba(224, 148, 98, 0.13); + --status-error: #e17b7f; + --status-error-soft: rgba(225, 123, 127, 0.12); + --status-derived: #b39be4; + --status-derived-soft: rgba(179, 155, 228, 0.12); + --status-unavailable: #758185; + --status-unavailable-soft: rgba(117, 129, 133, 0.1); + + /* Structure */ + --line-strong: #344249; + --line-default: #26333a; + --line-subtle: #1c282e; + --line-focus: #75d9b6; + + /* Spacing · stable 4px system */ + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-7: 32px; + --space-8: 40px; + + /* Radius */ + --radius-control: 4px; + --radius-panel: 8px; + --radius-float: 12px; + + /* Typography */ + --font-sans: "Noto Sans SC", "Microsoft YaHei UI", "PingFang SC", "Segoe UI", sans-serif; + --font-mono: "JetBrains Mono", "Noto Sans Mono CJK SC", "SFMono-Regular", Consolas, monospace; + --type-page: clamp(20px, 1.8vw, 24px); + --type-region: clamp(15px, 1.2vw, 18px); + --type-event: clamp(14px, 1vw, 16px); + --type-body: 13px; + --type-support: 12px; + --type-mono: 11px; + --leading-cn: 1.62; + + /* Motion */ + --motion-fast: 100ms; + --motion-standard: 160ms; + --motion-slow: 180ms; + --motion-ease: cubic-bezier(0.2, 0.72, 0.2, 1); + + /* Existing theme compatibility */ + --color-bg: var(--surface-shell); + --color-bg-elevated: var(--surface-workspace); + --color-surface: var(--surface-navigation); + --color-surface-raised: var(--surface-elevated); + --color-card: var(--surface-panel); + --color-card-soft: var(--surface-elevated); + --color-active-row: var(--surface-selected); + --color-border: var(--line-default); + --color-border-subtle: var(--line-subtle); + --color-accent: var(--status-success); + --color-accent-hover: #83dfbb; + --color-accent-muted: var(--status-success-soft); + --color-accent-border: rgba(101, 211, 164, 0.38); + --color-text-primary: var(--text-primary); + --color-text-secondary: var(--text-secondary); + --color-text-disabled: var(--text-disabled); + --color-text-weak: var(--text-muted); + --color-text-ghost: var(--text-disabled); + --color-success: var(--status-success); + --color-success-muted: var(--status-success-soft); + --color-success-border: rgba(101, 211, 164, 0.34); + --color-warning: var(--status-pending); + --color-warning-muted: var(--status-pending-soft); + --color-warning-border: rgba(212, 170, 99, 0.34); + --color-error: var(--status-error); + --color-error-muted: var(--status-error-soft); + --color-error-border: rgba(225, 123, 127, 0.36); + --color-running: var(--status-running); + --color-running-muted: var(--status-running-soft); + --color-running-border: rgba(120, 183, 238, 0.34); + --color-code-bg: #091015; + --radius-sm: var(--radius-control); + --radius-md: var(--radius-control); + --radius-lg: var(--radius-panel); + --radius-card: var(--radius-panel); + --sidebar-width: 208px; + --topbar-height: 48px; + --border-default: 1px solid var(--line-default); + --border-subtle: 1px solid var(--line-subtle); +} + +@media (prefers-reduced-motion: reduce) { + :root { + --motion-fast: 0ms; + --motion-standard: 0ms; + --motion-slow: 0ms; + } +} diff --git a/frontend/src/types/domain.ts b/frontend/src/types/domain.ts index c84d55f..e806adc 100644 --- a/frontend/src/types/domain.ts +++ b/frontend/src/types/domain.ts @@ -41,7 +41,7 @@ export interface GenerationRecord { status: string confirmed: boolean providerName?: string - modelName: string + modelName?: string promptTemplateId?: number promptTemplateName?: string promptTemplateVersion?: number diff --git a/frontend/src/views/AgentRunTraceView.vue b/frontend/src/views/AgentRunTraceView.vue index 467eb6c..fd23edd 100644 --- a/frontend/src/views/AgentRunTraceView.vue +++ b/frontend/src/views/AgentRunTraceView.vue @@ -1,324 +1,87 @@ - - diff --git a/frontend/tests/runEvidenceViewModel.test.ts b/frontend/tests/runEvidenceViewModel.test.ts new file mode 100644 index 0000000..c0f3a05 --- /dev/null +++ b/frontend/tests/runEvidenceViewModel.test.ts @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import test from 'node:test' +import { createRunEvidenceViewModel, reviewStatusLabel } from '../src/features/trace/runEvidenceViewModel.ts' +import type { AgentRunTrace, AgentStep, GenerationRecord, GenerationTrace, HumanReview, ToolCallRecord } from '../src/types/domain.ts' + +function step(id: number, stepOrder: number, overrides: Partial = {}): AgentStep { return { id, runId: 9, stepOrder, stepType: 'PROMPT_RENDER', stepName: `步骤 ${id}`, status: 'SUCCESS', summary: `摘要 ${id}`, latencyMs: id, startedAt: '2026-07-29T10:00:00', completedAt: '2026-07-29T10:00:01', ...overrides } } +function tool(id: number, stepId?: number): ToolCallRecord { return { id, runId: 9, stepId, toolName: `tool-${id}`, status: 'SUCCESS', latencyMs: id, createdAt: '2026-07-29T10:00:00' } } +function review(status: string): HumanReview { return { id: 1, runId: 9, generationRecordId: 90, reviewStatus: status, reviewer: 'manual', comment: '真实复核意见', createdAt: '2026-07-29T10:00:00' } } +function record(overrides: Partial = {}): GenerationRecord { return { id: 90, projectId: 1, generationType: 'requirement-split', inputSummary: '输入', inputContent: '输入内容', outputContent: '输出', status: 'READY_FOR_REVIEW', confirmed: false, costTimeMs: 20, success: true, version: 1, createdAt: '2026-07-29T10:00:00', ...overrides } } +function generationTrace(overrides: Partial = {}): GenerationTrace { return { id: 1, generationRecordId: 90, status: 'SUCCESS', createdAt: '2026-07-29T10:00:00', ...overrides } } +function trace(steps: AgentStep[], tools: ToolCallRecord[] = [], reviews: HumanReview[] = []): AgentRunTrace { return { run: { id: 9, projectId: 1, generationRecordId: 90, title: '真实 Trace', status: 'READY_FOR_REVIEW', createdAt: '2026-07-29T10:00:00' }, steps, toolCalls: tools, humanReviews: reviews } } + +test('按 stepOrder 排序,且不补造固定步骤', () => { const result = createRunEvidenceViewModel({ trace: trace([step(3, 30), step(1, 10)]) }); assert.deepEqual(result.events.map((item) => item.stepId), [1, 3]); assert.equal(result.events.length, 2) }) +test('Tool Call 仅按 stepId 关联,并保留 Run-level 证据', () => { const result = createRunEvidenceViewModel({ trace: trace([step(1, 1), step(2, 2)], [tool(1, 1), tool(2, 1), tool(3), tool(4, 999)]) }); assert.equal(result.events[0].toolCalls.length, 2); assert.equal(result.events[1].toolCalls.length, 0); assert.deepEqual(result.unlinkedToolCalls.map((item) => item.id), [3, 4]) }) +test('Provider / Model 记录真实来源字段且无默认值', () => { const base = trace([step(1, 1)]); const result = createRunEvidenceViewModel({ trace: { ...base, run: { ...base.run, providerName: 'local-rule', modelName: 'local-rule-mvp' } }, records: [record({ providerName: 'record-provider' })], generationTraces: [generationTrace({ providerName: 'trace-provider', modelName: 'trace-model' })] }); assert.equal(result.provider.value, 'local-rule'); assert.equal(result.provider.source, 'AgentRun.providerName'); assert.equal(result.model.value, 'local-rule-mvp'); assert.equal(result.model.source, 'AgentRun.modelName'); assert.equal(result.routeEvidence.value, '本地演示路由') }) +test('errorEvidence 不等于 fallbackEvidence;只有明确 FALLBACK 状态才叫降级', () => { const errorOnly = createRunEvidenceViewModel({ trace: trace([step(1, 1)]), records: [record({ errorMessage: 'provider error' })], generationTraces: [generationTrace()] }); assert.equal(errorOnly.errorEvidence.availability, 'direct'); assert.equal(errorOnly.fallbackEvidence.availability, 'unavailable'); const fallback = createRunEvidenceViewModel({ trace: { ...trace([step(1, 1)]), run: { ...trace([step(1, 1)]).run, status: 'FALLBACK' } }, records: [record()] }); assert.equal(fallback.fallbackEvidence.availability, 'direct') }) +test('无 Knowledge Step 不产生 Attention;有 Knowledge Step 无引用才产生 Evidence Gap', () => { const noKnowledge = createRunEvidenceViewModel({ trace: trace([step(1, 1)]), records: [record()], generationTraces: [generationTrace()] }); assert.equal(noKnowledge.knowledgeState, 'unused'); assert.equal(noKnowledge.derivedAttention.some((item) => item.includes('知识')), false); const missingReference = createRunEvidenceViewModel({ trace: trace([step(1, 1, { stepType: 'KNOWLEDGE_RETRIEVAL' })]), records: [record()], generationTraces: [generationTrace()] }); assert.equal(missingReference.knowledgeState, 'evidence-gap'); assert.equal(missingReference.derivedAttention.some((item) => item.includes('证据缺口')), true) }) +test('Review 状态不推断完成,Pending / Confirmed / Rejected 正确区分', () => { const noReview = createRunEvidenceViewModel({ trace: trace([step(1, 1)]) }); assert.equal(noReview.review.statusLabel, '未进入人工复核'); assert.equal(reviewStatusLabel('PENDING'), '等待人工复核'); assert.equal(reviewStatusLabel('CONFIRMED'), '复核已确认'); assert.equal(reviewStatusLabel('REJECTED'), '复核已驳回'); const actual = createRunEvidenceViewModel({ trace: trace([step(1, 1)], [], [review('CONFIRMED')]) }); assert.equal(actual.review.availability, 'direct') }) +test('Failed Step、缺少 Generation 与长中文 / JSON 保持稳定', () => { const longChinese = '这是一段用于验证长中文布局不会被适配层截断的说明。'.repeat(20); const result = createRunEvidenceViewModel({ trace: trace([step(1, 1, { status: 'FAILED', summary: longChinese }), step(2, 2, { summary: JSON.stringify({ payload: longChinese }) })]) }); assert.equal(result.events[0].tone, 'error'); assert.equal(result.record, undefined); assert.doesNotThrow(() => JSON.stringify(result.normalizedSnapshot)) }) +test('Review Reason 使用 HUMAN_REVIEW summary,缺失时不推断', () => { const reason = '需要人工确认订单接口缓存策略是否覆盖异常路径。'; const actual = createRunEvidenceViewModel({ trace: trace([step(1, 1), step(2, 2, { stepType: 'HUMAN_REVIEW', summary: reason })], [], [review('PENDING')]) }); assert.equal(actual.review.reason.value, reason); assert.equal(actual.review.reason.source, 'AgentStep.summary'); assert.equal(actual.review.reason.availability, 'direct'); const missingReason = createRunEvidenceViewModel({ trace: trace([step(1, 1)], [], [review('PENDING')]) }); assert.equal(missingReason.review.reason.availability, 'unavailable') }) +test('Trace 与 App Shell 相关业务文本不使用 8px / 9px 字体', () => { const root = resolve(import.meta.dirname, '..'); const files = ['src/styles/evidence-workspace.css', 'src/styles/theme.css', 'src/features/trace/components/EvidenceEventRow.vue', 'src/features/trace/components/EvidenceInspector.vue', 'src/components/TopBar.vue']; const offenders = files.flatMap((file) => { const content = readFileSync(resolve(root, file), 'utf8'); return [...content.matchAll(/font-size:\s*(8|9)px/g)].map((match) => `${file}:${match.index}`) }); assert.deepEqual(offenders, []) }) diff --git a/scripts/capture-evidence-workspace-candidates.mjs b/scripts/capture-evidence-workspace-candidates.mjs new file mode 100644 index 0000000..4a3c7c3 --- /dev/null +++ b/scripts/capture-evidence-workspace-candidates.mjs @@ -0,0 +1,665 @@ +import { spawn } from 'node:child_process' +import { copyFile, mkdir, readFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import net from 'node:net' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const rootDir = path.resolve(__dirname, '..') +const frontendDir = path.join(rootDir, 'frontend') +const canonicalDir = path.join(rootDir, 'docs', 'images') +const candidateDir = path.join(canonicalDir, 'candidates', 'evidence-workspace-system') +const beforeFile = path.join(candidateDir, 'before-trace-desktop.png') +const baseUrl = process.env.DEVFLOW_EVIDENCE_SCREENSHOT_URL || 'http://127.0.0.1:5178' +const frontendPort = Number(new URL(baseUrl).port || 5178) + +const projects = [{ + id: 1, + projectName: 'DevFlow Copilot', + techStack: 'Java 17 / Spring Boot 3 / Vue 3 / TypeScript', + readmeContent: 'AI Coding Workflow Evidence Workspace', + directoryStructure: 'backend, frontend, docs', + currentRequirement: '只展示真实 AgentStep、Tool Call、Generation Trace 与 Human Review。', + codingRules: '不自动修改代码或 Git。', + version: 1, + createdAt: '2026-07-29T09:00:00', + updatedAt: '2026-07-29T14:32:00', +}] + +const runs = [ + run(42, '解释 local-rule fallback 并整理 Trace Evidence 重构计划', 'READY_FOR_REVIEW', 'local-rule', '14:32'), + run(41, '校验 Knowledge References 注入边界', 'CONFIRMED', 'local-rule', '14:18'), + run(40, '对比 Tool Call 与 Prompt 输出', 'FAILED', 'openai-compatible', '13:57'), + run(39, '复核 README 的 Provider 选择', 'SAVED', 'local-rule', '13:41'), +] + +const generationRecord = { + id: 2042, + projectId: 1, + generationType: 'requirement-split', + inputSummary: '解释 Provider fallback 与 Trace Evidence 页面重构边界。', + inputContent: '为 DevFlow Copilot 输出一份可复核的 Trace Evidence 重构计划,保留现有路由与 API。', + outputContent: [ + '保留 /agent-runs 路由与 API contract。', + 'Evidence Stream 只展示真实 AgentStep。', + 'Tool Call 只按 stepId 关联。', + 'Request Changes / Reject 没有 API,保持 disabled。', + ].join('\n'), + status: 'READY_FOR_REVIEW', + confirmed: false, + providerName: 'local-rule', + modelName: 'local-rule-mvp', + promptTemplateId: 4, + promptTemplateName: '需求拆解标准模板', + promptTemplateVersion: 4, + renderedPrompt: '只生成 review-only Artifact;不得自动修改代码、提交 Git 或部署。', + promptTokens: 312, + completionTokens: 553, + totalTokens: 865, + costTimeMs: 175, + success: true, + errorMessage: 'OpenAI-compatible 预检未发现 DEVFLOW_AI_API_KEY;按显式配置降级至 local-rule。未发出真实模型请求。', + version: 1, + createdAt: '2026-07-29T14:32:08', + updatedAt: '2026-07-29T14:32:08', +} + +const steps = [ + step(4201, 1, 'TASK_DECOMPOSITION', '任务接收', 'SUCCESS', '接收 requirement-split 请求,并绑定 DevFlow Copilot 项目上下文。', 4, '14:32:08.004'), + step(4202, 2, 'PROMPT_RENDER', 'Prompt 模板渲染', 'SUCCESS', '使用需求拆解标准模板 v4,合并项目边界与 Knowledge 上下文。', 11, '14:32:08.015'), + step(4203, 3, 'KNOWLEDGE_RETRIEVAL', 'Knowledge Base 检索', 'SUCCESS', '关键词 / 简单相似度检索命中 2 个 chunk,并注入 Prompt 上下文。', 17, '14:32:08.032'), + step(4204, 4, 'PROVIDER_ROUTE', 'Provider 路由与 fallback', 'FALLBACK', 'OpenAI-compatible 缺少 API Key,按明确配置降级到 local-rule;没有发出真实模型请求。', 9, '14:32:08.041'), + step(4205, 5, 'LLM_GENERATION', 'Provider 生成 Artifact', 'SUCCESS', 'local-rule-mvp 返回结构化 Artifact,Generation Record 进入 READY_FOR_REVIEW。', 134, '14:32:08.175'), + step(4206, 6, 'HUMAN_REVIEW', '人工复核', 'PENDING', '生成结果等待开发者先保存,再执行最终确认。', 0, '14:32:08.175', false), +] + +const toolCalls = [ + tool(5001, 4202, 'prompt-template-render', 'requirement-split / templateId=4 / projectId=1', '渲染 1,046 字符;未包含 API Key。', 11), + tool(5002, 4203, 'keyword-knowledge-search', 'local-rule provider trace human review', '命中 2 个 Knowledge Reference。', 17), + tool(5003, 4204, 'generation-provider', 'openai-compatible / fallbackToLocal=true', 'local-rule / local-rule-mvp;保留明确 fallback reason。', 9), + tool(5004, undefined, 'run-audit-export', 'runId=42', 'Run-level evidence;接口未返回可靠 stepId。', 3), +] + +const trace = { + run: runs[0], + steps, + toolCalls, + humanReviews: [{ + id: 6001, + runId: 42, + generationRecordId: 2042, + reviewStatus: 'PENDING', + reviewer: 'manual', + comment: '等待开发者保存或确认 Artifact。Request Changes 与 Reject 当前没有 API。', + createdAt: '2026-07-29T14:32:08', + updatedAt: '2026-07-29T14:32:08', + }], +} + +const generationTraces = [{ + id: 7001, + generationRecordId: 2042, + promptVersion: 4, + inputVariables: '{"projectName":"DevFlow Copilot","generationType":"requirement-split"}', + renderedPromptSummary: '生成 review-only Trace Evidence 重构计划。', + providerName: 'local-rule', + modelName: 'local-rule-mvp', + status: 'SUCCESS', + latencyMs: 134, + errorMessage: generationRecord.errorMessage, + createdAt: '2026-07-29T14:32:08', +}] + +const references = [ + { + documentId: 8, + documentTitle: 'DevFlow Copilot 项目边界', + chunkId: 801, + chunkIndex: 0, + score: 0.94, + citationLabel: '项目边界#0', + snippet: '默认使用 local-rule 本地规则生成;只有配置 API Key 后才调用真实 OpenAI-compatible Provider。', + }, + { + documentId: 9, + documentTitle: 'Agent Workflow Boundary', + chunkId: 901, + chunkIndex: 1, + score: 0.82, + citationLabel: 'Workflow Boundary#1', + snippet: 'Agent Run Trace 是包含 steps、tool calls 与 human review 的可解释审计记录,不是完整自主多 Agent Runtime。', + }, +] + +function run(id, title, status, providerName, time) { + return { + id, + projectId: 1, + generationRecordId: id === 42 ? 2042 : 2000 + id, + title, + goal: '生成 review-only Artifact', + status, + providerName, + modelName: providerName === 'local-rule' ? 'local-rule-mvp' : undefined, + latencyMs: id === 42 ? 175 : undefined, + startedAt: `2026-07-29T${time}:08`, + completedAt: status === 'READY_FOR_REVIEW' ? undefined : `2026-07-29T${time}:09`, + createdAt: `2026-07-29T${time}:08`, + updatedAt: `2026-07-29T${time}:09`, + } +} + +function step(id, stepOrder, stepType, stepName, status, summary, latencyMs, clock, completed = true) { + const base = `2026-07-29T${clock.slice(0, 8)}` + return { + id, + runId: 42, + stepOrder, + stepType, + stepName, + status, + summary, + latencyMs, + startedAt: base, + completedAt: completed ? base : undefined, + } +} + +function tool(id, stepId, toolName, inputSummary, outputSummary, latencyMs) { + return { + id, + runId: 42, + stepId, + toolName, + inputSummary, + outputSummary, + status: toolName === 'generation-provider' ? 'FALLBACK' : 'SUCCESS', + latencyMs, + createdAt: '2026-07-29T14:32:08', + } +} + +function payload(data) { + return { + status: 200, + contentType: 'application/json; charset=utf-8', + body: JSON.stringify({ code: 0, message: 'ok', data }), + } +} + +function traceForScenario(scenario) { + if (scenario === 'short-run') return { ...trace, steps: steps.slice(0, 2) } + if (scenario === 'long-run') { + const additional = Array.from({ length: 8 }, (_, index) => ({ + ...steps[index % steps.length], + id: 4300 + index, + stepOrder: 7 + index, + stepName: `长 Run 验证步骤 ${index + 7}`, + summary: `这是长 Run 的第 ${index + 7} 个真实 DTO 形状 QA Step,用于验证 Evidence Stream 独立滚动。`, + })) + return { ...trace, steps: [...steps, ...additional] } + } + if (scenario === 'no-tool') return { ...trace, toolCalls: [] } + if (scenario === 'multiple-tools') { + return { + ...trace, + toolCalls: [ + ...toolCalls, + tool(5005, 4201, 'project-context-bind', 'projectId=1', '已绑定项目上下文。', 2), + tool(5006, 4201, 'requirement-validator', 'generationType=requirement-split', '字段校验通过。', 1), + ], + } + } + if (scenario === 'no-review') return { ...trace, humanReviews: [] } + if (scenario === 'confirmed-review' || scenario === 'rejected-review') { + const reviewStatus = scenario === 'confirmed-review' ? 'CONFIRMED' : 'REJECTED' + return { + ...trace, + humanReviews: trace.humanReviews.map((item) => ({ + ...item, + reviewStatus, + comment: reviewStatus === 'CONFIRMED' ? '已由人工确认。' : '人工复核已驳回该 Artifact。', + })), + } + } + if (scenario === 'failed-step') { + return { + ...trace, + steps: steps.map((item, index) => index === 2 + ? { ...item, status: 'FAILED', summary: 'Knowledge 查询失败;后端返回明确 FAILED 状态。' } + : item), + } + } + if (scenario === 'long-chinese') { + return { + ...trace, + steps: steps.map((item, index) => index === 0 + ? { ...item, summary: '长中文验收:'.concat('当任务名称、证据摘要与状态说明同时超过两到三行时,页面仍需保持完整来源、键盘焦点与可读行高。'.repeat(8)) } + : item), + } + } + return trace +} + +function recordForScenario(scenario) { + if (scenario === 'confirmed-review') { + return { ...generationRecord, status: 'CONFIRMED', confirmed: true } + } + if (scenario === 'rejected-review') { + return { ...generationRecord, status: 'REJECTED', confirmed: false } + } + if (scenario === 'long-json') { + return { + ...generationRecord, + outputContent: JSON.stringify({ + title: 'Long Normalized Snapshot QA Fixture', + payload: 'long-json-value-'.repeat(600), + }), + } + } + return generationRecord +} + +async function mockApi(route, scenario = 'normal') { + const request = route.request() + const url = new URL(request.url()) + const pathname = url.pathname + if (!pathname.startsWith('/api/')) return route.continue() + + if (pathname === '/api/projects') return route.fulfill(payload(projects)) + if (pathname === '/api/agent-runs') return route.fulfill(payload(scenario === 'empty' ? [] : runs)) + if (pathname === '/api/generations') { + return route.fulfill(payload(scenario === 'missing-generation' ? [] : [recordForScenario(scenario)])) + } + if (pathname === '/api/agent-runs/42/trace') { + if (scenario === 'loading') await new Promise((resolve) => setTimeout(resolve, 1200)) + if (scenario === 'error') { + return route.fulfill({ + status: 500, + contentType: 'application/json; charset=utf-8', + body: JSON.stringify({ code: 5000, message: 'QA fixture trace detail error', data: null }), + }) + } + return route.fulfill(payload(traceForScenario(scenario))) + } + if (pathname === '/api/generation-traces') { + return route.fulfill(payload(scenario === 'missing-generation' ? [] : generationTraces)) + } + if (pathname === '/api/knowledge/references') { + return route.fulfill(payload(scenario === 'no-knowledge' ? [] : references)) + } + if (pathname === '/api/generations/2042/save') { + return route.fulfill(payload({ ...generationRecord, status: 'SAVED' })) + } + if (pathname === '/api/generations/2042/confirm') { + return route.fulfill(payload({ ...generationRecord, status: 'CONFIRMED', confirmed: true })) + } + return route.fulfill(payload(null)) +} + +async function importPlaywright() { + const requireFromFrontend = createRequire(path.join(frontendDir, 'package.json')) + return requireFromFrontend('playwright') +} + +function isPortOpen(port) { + return new Promise((resolve) => { + const socket = net.createConnection(port, '127.0.0.1') + socket.once('connect', () => { + socket.destroy() + resolve(true) + }) + socket.once('error', () => resolve(false)) + socket.setTimeout(800, () => { + socket.destroy() + resolve(false) + }) + }) +} + +async function waitForFrontend() { + const started = Date.now() + while (Date.now() - started < 45000) { + try { + const response = await fetch(baseUrl) + if (response.ok) return + } catch { + // keep waiting + } + await new Promise((resolve) => setTimeout(resolve, 500)) + } + throw new Error(`Vite 未在 ${baseUrl} 就绪`) +} + +async function ensureFrontend() { + if (await isPortOpen(frontendPort)) return undefined + const child = process.platform === 'win32' + ? spawn('cmd.exe', ['/d', '/s', '/c', `npm.cmd run dev -- --host 127.0.0.1 --port ${frontendPort}`], { + cwd: frontendDir, + stdio: ['ignore', 'pipe', 'pipe'], + }) + : spawn('npm', ['run', 'dev', '--', '--host', '127.0.0.1', '--port', String(frontendPort)], { + cwd: frontendDir, + stdio: ['ignore', 'pipe', 'pipe'], + }) + child.stdout.on('data', (chunk) => process.stdout.write(`[vite] ${chunk}`)) + child.stderr.on('data', (chunk) => process.stderr.write(`[vite] ${chunk}`)) + await waitForFrontend() + return child +} + +function stopProcessTree(child) { + if (!child || child.killed) return + if (process.platform === 'win32') { + spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' }) + } else { + child.kill('SIGTERM') + } +} + +async function capturePage(browser, viewport, fileName, openMobileInspector = false) { + const page = await browser.newPage({ viewport, deviceScaleFactor: 1 }) + await page.route('**/api/**', (route) => mockApi(route, 'normal')) + await page.goto(`${baseUrl}/agent-runs?generationRecordId=2042&qaFixture=1`, { waitUntil: 'networkidle' }) + await page.waitForSelector('.evidence-event-row', { timeout: 30000 }) + await page.addStyleTag({ + content: ` + * { caret-color: transparent !important; } + .el-message { display: none !important; } + `, + }) + if (openMobileInspector) { + await page.evaluate(() => { + document.querySelector('.mobile-inspector-trigger')?.click() + }) + await page.waitForSelector('.inspector-pane.open') + } + await page.waitForTimeout(250) + await assertNoPageOverflow(page, fileName) + await page.screenshot({ + path: path.join(candidateDir, fileName), + fullPage: false, + animations: 'disabled', + }) + await page.close() + console.log(path.relative(rootDir, path.join(candidateDir, fileName))) +} + +async function assertNoPageOverflow(page, label) { + const overflow = await page.evaluate(() => ({ + innerWidth: window.innerWidth, + scrollWidth: document.documentElement.scrollWidth, + })) + if (overflow.scrollWidth > overflow.innerWidth) { + throw new Error(`${label} 存在页面横向溢出:${overflow.scrollWidth} > ${overflow.innerWidth}`) + } +} + +async function openScenario(browser, scenario, viewport = { width: 1280, height: 800 }) { + const page = await browser.newPage({ viewport, deviceScaleFactor: 1 }) + await page.route('**/api/**', (route) => mockApi(route, scenario)) + await page.goto(`${baseUrl}/agent-runs?generationRecordId=2042&qaFixture=1`, { waitUntil: 'domcontentloaded' }) + return page +} + +async function verifyStateMatrix(browser) { + const checks = [] + + async function verify(name, assertion, viewport) { + const page = await openScenario(browser, name, viewport) + try { + await assertion(page) + await assertNoPageOverflow(page, name) + checks.push(name) + } finally { + await page.close() + } + } + + await verify('normal', async (page) => { + await page.waitForSelector('.evidence-event-row') + if (await page.locator('.evidence-event-row').count() !== 6) throw new Error('Normal 应包含 6 个 fixture steps') + }) + await verify('loading', async (page) => { + await page.waitForSelector('.workspace-loading') + }) + await verify('empty', async (page) => { + await page.waitForSelector('.workspace-empty-page') + }) + await verify('error', async (page) => { + await page.waitForSelector('.workspace-error') + }) + await verify('short-run', async (page) => { + await page.waitForSelector('.evidence-event-row') + if (await page.locator('.evidence-event-row').count() !== 2) throw new Error('Short Run 应只显示 2 个 AgentStep') + }) + await verify('long-run', async (page) => { + await page.waitForSelector('.evidence-event-row') + if (await page.locator('.evidence-event-row').count() !== 14) throw new Error('Long Run fixture 数量不正确') + }) + await verify('no-tool', async (page) => { + await page.waitForSelector('.evidence-event-row') + if (await page.locator('.event-tool-chip').count()) throw new Error('Zero Tool 行不应显示 Tool 文案') + }) + await verify('multiple-tools', async (page) => { + await page.waitForSelector('.evidence-event-row') + if (!(await page.locator('.event-tool-chip').first().innerText()).includes('Tool')) throw new Error('有 Tool 时未显示紧凑数量标签') + await page.getByRole('tab', { name: /工具调用/ }).click() + if (await page.locator('.tool-call-card').count() < 3) throw new Error('Multiple Tools 未显示多条精确 stepId 关联') + }) + await verify('no-review', async (page) => { + await page.waitForSelector('.evidence-event-row') + if (!(await page.locator('.run-context-facts').innerText()).includes('未进入人工复核')) { + throw new Error('No Review 被错误表示') + } + }) + await verify('confirmed-review', async (page) => { + await page.waitForSelector('.evidence-event-row') + if (!(await page.locator('.run-context-facts').innerText()).includes('复核已确认')) { + throw new Error('Confirmed Review 映射错误') + } + }) + await verify('rejected-review', async (page) => { + await page.waitForSelector('.evidence-event-row') + if (!(await page.locator('.run-context-facts').innerText()).includes('复核已驳回')) { + throw new Error('Rejected Review 映射错误') + } + }) + await verify('failed-step', async (page) => { + await page.waitForSelector('.evidence-event-row[data-tone="error"]') + }) + await verify('missing-generation', async (page) => { + await page.waitForSelector('.derived-attention') + if (!(await page.locator('.derived-attention').innerText()).includes('缺少 Generation Record')) { + throw new Error('Missing Generation 未明确标记 Derived Attention') + } + }) + await verify('no-knowledge', async (page) => { + await page.waitForSelector('.derived-attention') + const attention = await page.locator('.derived-attention').innerText() + if (!attention.includes('证据缺口')) throw new Error('No Knowledge 未明确标记') + return + if (!(await page.locator('.derived-attention').innerText()).includes('未返回 Knowledge Reference')) { + throw new Error('No Knowledge 未明确标记') + } + }) + await verify('long-chinese', async (page) => { + await page.waitForSelector('.evidence-event-row') + const summary = await page.locator('.event-summary').first().textContent() + if (!summary || summary.length < 300) throw new Error('Long Chinese fixture 未完整进入 View Model') + }) + await verify('long-json', async (page) => { + await page.waitForSelector('.evidence-event-row') + await page.getByRole('tab', { name: /数据快照/ }).click() + const scrollable = await page.locator('.snapshot-section pre').evaluate((element) => { + return element.scrollWidth > element.clientWidth && element.scrollHeight > element.clientHeight + }) + if (!scrollable) throw new Error('Long JSON 未保持 Inspector 内部滚动') + }) + + const interactionPage = await openScenario(browser, 'normal') + try { + await interactionPage.waitForSelector('.evidence-event-row') + const first = interactionPage.locator('.evidence-event-row').first() + await first.focus() + await interactionPage.keyboard.press('ArrowDown') + const secondSelected = await interactionPage.locator('.evidence-event-row').nth(1).getAttribute('aria-selected') + const activeId = await interactionPage.evaluate(() => document.activeElement?.id) + if (secondSelected !== 'true' || activeId !== 'evidence-event-step-4202') { + throw new Error('Keyboard Focus / ArrowDown selection 失败') + } + checks.push('keyboard-focus') + } finally { + await interactionPage.close() + } + + const desktopPage = await openScenario(browser, 'normal', { width: 1440, height: 900 }) + try { + await desktopPage.waitForSelector('.inspector-pane.open') + const role = await desktopPage.locator('.inspector-pane').getAttribute('role') + const modal = await desktopPage.locator('.inspector-pane').getAttribute('aria-modal') + if (role !== 'complementary' || modal) throw new Error('Desktop Inspector 不应为 modal dialog') + await desktopPage.keyboard.press('Escape') + if (!(await desktopPage.locator('.inspector-pane').evaluate((node) => node.classList.contains('open')))) { + throw new Error('Desktop Escape 不应关闭常驻 Inspector') + } + checks.push('desktop-inspector-semantics') + } finally { + await desktopPage.close() + } + + const mobilePage = await openScenario(browser, 'normal', { width: 390, height: 844 }) + try { + await mobilePage.waitForSelector('.mobile-inspector-trigger') + if (await mobilePage.locator('.inspector-pane.open').count()) throw new Error('Mobile Inspector 应默认关闭') + await mobilePage.evaluate(() => document.querySelector('.mobile-inspector-trigger')?.click()) + await mobilePage.waitForSelector('.inspector-pane.open') + await mobilePage.locator('.inspector-close').click() + await mobilePage.waitForFunction(() => !document.querySelector('.inspector-pane')?.classList.contains('open')) + checks.push('inspector-closed') + await assertNoPageOverflow(mobilePage, 'mobile-inspector-closed') + } finally { + await mobilePage.close() + } + + const drawerPage = await openScenario(browser, 'normal', { width: 1024, height: 768 }) + try { + const trigger = drawerPage.locator('.mobile-inspector-trigger') + await trigger.click() + await drawerPage.waitForSelector('.inspector-pane.open') + const dialogFocused = await drawerPage.evaluate(() => document.activeElement?.getAttribute('role') === 'dialog') + if (!dialogFocused) throw new Error('Inspector 打开后未获得焦点') + const shellInert = await drawerPage.evaluate(() => Boolean(document.querySelector('.sidebar')?.hasAttribute('inert') && document.querySelector('.topbar')?.hasAttribute('inert') && document.querySelector('.run-context-bar')?.hasAttribute('inert') && document.querySelector('.evidence-stream-pane')?.hasAttribute('inert'))) + if (!shellInert) throw new Error('Drawer 打开时背景未完整 inert') + await drawerPage.keyboard.press('Tab') + await drawerPage.keyboard.press('Shift+Tab') + const focusInside = await drawerPage.evaluate(() => Boolean(document.activeElement?.closest('.inspector-pane'))) + if (!focusInside) throw new Error('Drawer Focus Trap 失败') + await drawerPage.getByRole('tab', { name: /上下文/ }).focus() + await drawerPage.keyboard.press('ArrowRight') + if ((await drawerPage.getByRole('tab', { name: /证据/ }).getAttribute('aria-selected')) !== 'true') throw new Error('Tabs ArrowRight 失败') + await drawerPage.keyboard.press('End') + if ((await drawerPage.getByRole('tab', { name: /人工复核/ }).getAttribute('aria-selected')) !== 'true') throw new Error('Tabs End 失败') + await drawerPage.keyboard.press('Home') + if ((await drawerPage.getByRole('tab', { name: /上下文/ }).getAttribute('aria-selected')) !== 'true') throw new Error('Tabs Home 失败') + await drawerPage.keyboard.press('Escape') + await drawerPage.waitForFunction(() => !document.querySelector('.inspector-pane')?.classList.contains('open')) + const focusRestored = await drawerPage.evaluate(() => document.activeElement?.classList.contains('mobile-inspector-trigger')) + if (!focusRestored) throw new Error('Inspector 关闭后未恢复触发焦点') + checks.push('escape-focus-restore') + } finally { + await drawerPage.close() + } + + const mobileSelectorPage = await openScenario(browser, 'normal', { width: 390, height: 844 }) + try { + await mobileSelectorPage.waitForSelector('.mobile-run-selector') + if (await mobileSelectorPage.locator('.run-ledger-pane').isVisible()) throw new Error('Mobile Run Ledger 应隐藏') + if (!(await mobileSelectorPage.locator('.mobile-run-selector').innerText()).includes('run')) throw new Error('Mobile Run Selector 未显示当前 Run 上下文') + checks.push('mobile-run-selector') + } finally { + await mobileSelectorPage.close() + } + + const reducedPage = await browser.newPage({ + viewport: { width: 1280, height: 800 }, + reducedMotion: 'reduce', + }) + await reducedPage.route('**/api/**', (route) => mockApi(route, 'normal')) + try { + await reducedPage.goto(`${baseUrl}/agent-runs?generationRecordId=2042&qaFixture=1`, { waitUntil: 'domcontentloaded' }) + await reducedPage.waitForSelector('.evidence-event-row') + const motion = await reducedPage.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--motion-standard').trim()) + if (motion !== '0ms') throw new Error(`Reduced Motion token 未归零:${motion}`) + checks.push('reduced-motion') + } finally { + await reducedPage.close() + } + + console.log(`State matrix verified (${checks.length}): ${checks.join(', ')}`) +} + +async function createComparison(browser) { + const before = await readFile(beforeFile) + const after = await readFile(path.join(candidateDir, 'after-trace-1440x900.png')) + const page = await browser.newPage({ viewport: { width: 1600, height: 620 }, deviceScaleFactor: 1 }) + await page.setContent(` + +
+

DevFlow · Run Evidence Workspace

Phase 1 candidate comparison · real Vue page on the right

+
+
+
Before · fixed seven-step summary canonical baseline
+
After · Layered Graphite Evidence Workspace development QA fixture
+
+ `) + await page.screenshot({ + path: path.join(candidateDir, 'before-after-trace.png'), + fullPage: false, + animations: 'disabled', + }) + await page.close() + console.log(path.relative(rootDir, path.join(candidateDir, 'before-after-trace.png'))) +} + +async function main() { + await mkdir(candidateDir, { recursive: true }) + await copyFile(path.join(canonicalDir, 'trace-evidence.png'), beforeFile) + const frontendProcess = await ensureFrontend() + const { chromium } = await importPlaywright() + let browser + try { + browser = await chromium.launch({ channel: 'chrome', headless: true, args: ['--hide-scrollbars'] }) + } catch { + browser = await chromium.launch({ headless: true, args: ['--hide-scrollbars'] }) + } + + try { + await verifyStateMatrix(browser) + await capturePage(browser, { width: 1440, height: 900 }, 'after-trace-1440x900.png') + await capturePage(browser, { width: 1280, height: 800 }, 'after-trace-1280x800.png') + await capturePage(browser, { width: 1024, height: 768 }, 'after-trace-1024x768.png') + await capturePage(browser, { width: 390, height: 844 }, 'after-trace-390x844.png', true) + await createComparison(browser) + } finally { + await browser.close() + stopProcessTree(frontendProcess) + } +} + +main().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/scripts/capture-evidence-workspace-final.mjs b/scripts/capture-evidence-workspace-final.mjs new file mode 100644 index 0000000..d8dd571 --- /dev/null +++ b/scripts/capture-evidence-workspace-final.mjs @@ -0,0 +1,59 @@ +import { mkdir } from 'node:fs/promises' +import { createRequire } from 'node:module' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const rootDir = path.resolve(__dirname, '..') +const frontendDir = path.join(rootDir, 'frontend') +const candidateDir = path.join(rootDir, 'docs', 'images', 'candidates', 'devflow-final') +const baseUrl = process.env.DEVFLOW_EVIDENCE_REAL_URL || 'http://127.0.0.1:5181' +const generationRecordId = process.env.DEVFLOW_EVIDENCE_FINAL_RECORD_ID || '' +const requireFromFrontend = createRequire(path.join(frontendDir, 'package.json')) +const { chromium } = requireFromFrontend('playwright') + +async function assertFinalCandidate(page, name) { + const text = await page.locator('body').innerText() + const forbidden = ['Development QA Fixture', 'CURRENT PAGE', 'Selected run evidence', 'Run Navigation', 'Phase 1', '重构', '调试'] + for (const value of forbidden) { + if (text.includes(value)) throw new Error(`${name} contains forbidden text: ${value}`) + } + if (/\?{4,}/.test(text)) throw new Error(`${name} contains four or more consecutive question marks`) + const { scrollWidth, innerWidth } = await page.evaluate(() => ({ scrollWidth: document.documentElement.scrollWidth, innerWidth: window.innerWidth })) + if (scrollWidth > innerWidth) throw new Error(`${name} has horizontal overflow: ${scrollWidth} > ${innerWidth}`) +} + +async function openPage(browser, viewport, name) { + const page = await browser.newPage({ viewport, deviceScaleFactor: 1 }) + const query = generationRecordId ? `?generationRecordId=${encodeURIComponent(generationRecordId)}` : '' + await page.goto(`${baseUrl}/agent-runs${query}`, { waitUntil: 'networkidle' }) + await page.waitForSelector('.evidence-event-row', { timeout: 30000 }) + await page.addStyleTag({ content: '* { caret-color: transparent !important; } .el-message { display: none !important; }' }) + await assertFinalCandidate(page, name) + return page +} + +async function capture(browser, viewport, name) { + const page = await openPage(browser, viewport, name) + try { + await page.screenshot({ path: path.join(candidateDir, name), fullPage: false, animations: 'disabled' }) + await assertFinalCandidate(page, name) + } finally { await page.close() } + console.log(path.relative(rootDir, path.join(candidateDir, name))) +} + +async function main() { + await mkdir(candidateDir, { recursive: true }) + const api = await fetch(`${baseUrl}/api/projects`) + if (!api.ok) throw new Error(`Real local API is unavailable at ${baseUrl}/api/projects`) + let browser + try { browser = await chromium.launch({ channel: 'chrome', headless: true, args: ['--hide-scrollbars'] }) } + catch { browser = await chromium.launch({ headless: true, args: ['--hide-scrollbars'] }) } + try { + await capture(browser, { width: 1440, height: 900 }, 'trace-final-1440x900.png') + await capture(browser, { width: 1280, height: 800 }, 'trace-final-1280x800.png') + await capture(browser, { width: 390, height: 844 }, 'trace-final-mobile-390x844.png') + } finally { await browser.close() } +} + +main().catch((error) => { console.error(error); process.exit(1) }) diff --git a/scripts/capture-evidence-workspace-freeze-candidates.mjs b/scripts/capture-evidence-workspace-freeze-candidates.mjs new file mode 100644 index 0000000..e237818 --- /dev/null +++ b/scripts/capture-evidence-workspace-freeze-candidates.mjs @@ -0,0 +1,111 @@ +import { mkdir, readFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const rootDir = path.resolve(__dirname, '..') +const frontendDir = path.join(rootDir, 'frontend') +const candidateDir = path.join(rootDir, 'docs', 'images', 'candidates', 'evidence-workspace-system-v1-2') +const previousDir = path.join(rootDir, 'docs', 'images', 'candidates', 'evidence-workspace-system-v1-1') +const baseUrl = process.env.DEVFLOW_EVIDENCE_REAL_URL || 'http://127.0.0.1:5181' +const generationRecordId = process.env.DEVFLOW_EVIDENCE_FREEZE_RECORD_ID || '' +const requireFromFrontend = createRequire(path.join(frontendDir, 'package.json')) +const { chromium } = requireFromFrontend('playwright') + +async function ensureRealApi() { + const response = await fetch(`${baseUrl}/api/projects`) + if (!response.ok) throw new Error(`Real local API is unavailable at ${baseUrl}/api/projects`) + const body = await response.json() + if (!Array.isArray(body.data) || !body.data.length) throw new Error('Real local API returned no seeded projects') +} + +async function assertNoOverflow(page, name) { + const overflow = await page.evaluate(() => ({ + innerWidth: window.innerWidth, + scrollWidth: document.documentElement.scrollWidth, + })) + if (overflow.scrollWidth > overflow.innerWidth) { + throw new Error(`${name} has horizontal overflow: ${overflow.scrollWidth} > ${overflow.innerWidth}`) + } +} + +async function assertFormalCandidate(page, name) { + const pageText = await page.locator('body').innerText() + for (const forbidden of ['Development QA Fixture', '调试', '重构计划', 'CURRENT PAGE', 'Selected run evidence', 'Run Navigation']) { + if (pageText.includes(forbidden)) throw new Error(`${name} contains forbidden text: ${forbidden}`) + } + await assertNoOverflow(page, name) +} + +async function openRealPage(browser, viewport, name) { + const page = await browser.newPage({ viewport, deviceScaleFactor: 1 }) + const query = generationRecordId ? `?generationRecordId=${encodeURIComponent(generationRecordId)}` : '' + await page.goto(`${baseUrl}/agent-runs${query}`, { waitUntil: 'networkidle' }) + await page.waitForSelector('.evidence-event-row', { timeout: 30000 }) + await page.addStyleTag({ content: '* { caret-color: transparent !important; } .el-message { display: none !important; }' }) + await assertFormalCandidate(page, name) + return page +} + +async function screenshot(browser, viewport, name, setup) { + const page = await openRealPage(browser, viewport, name) + try { + if (setup) await setup(page) + await page.waitForTimeout(180) + await assertFormalCandidate(page, name) + await page.screenshot({ path: path.join(candidateDir, name), fullPage: false, animations: 'disabled' }) + } finally { + await page.close() + } + console.log(path.relative(rootDir, path.join(candidateDir, name))) +} + +async function createComparison(browser) { + const before = await readFile(path.join(previousDir, 'trace-real-1440x900.png')) + const after = await readFile(path.join(candidateDir, 'trace-freeze-1440x900.png')) + const page = await browser.newPage({ viewport: { width: 1600, height: 620 }, deviceScaleFactor: 1 }) + await page.setContent(`

DevFlow · Trace System Freeze

Phase 1.1 real local API / Phase 1.2 hardened freeze candidate

Phase 1.1
Phase 1.2 · TRACE_FRONTEND_FROZEN_FOR_PHASE2
`) + await page.screenshot({ path: path.join(candidateDir, 'trace-phase1-1-vs-phase1-2.png'), fullPage: false, animations: 'disabled' }) + await page.close() + console.log(path.relative(rootDir, path.join(candidateDir, 'trace-phase1-1-vs-phase1-2.png'))) +} + +async function main() { + await mkdir(candidateDir, { recursive: true }) + await ensureRealApi() + let browser + try { + browser = await chromium.launch({ channel: 'chrome', headless: true, args: ['--hide-scrollbars'] }) + } catch { + browser = await chromium.launch({ headless: true, args: ['--hide-scrollbars'] }) + } + + try { + await screenshot(browser, { width: 1440, height: 900 }, 'trace-freeze-1440x900.png') + await screenshot(browser, { width: 1280, height: 800 }, 'trace-freeze-1280x800.png') + await screenshot(browser, { width: 1024, height: 768 }, 'trace-tablet-closed-1024x768.png') + await screenshot(browser, { width: 1024, height: 768 }, 'trace-tablet-open-1024x768.png', async (page) => { + await page.locator('.mobile-inspector-trigger').click() + await page.waitForSelector('.inspector-pane.open[role="dialog"]') + }) + await screenshot(browser, { width: 390, height: 844 }, 'trace-mobile-closed-390x844.png') + await screenshot(browser, { width: 390, height: 844 }, 'trace-mobile-run-selector-390x844.png', async (page) => { + await page.locator('.mobile-run-selector').scrollIntoViewIfNeeded() + const selector = page.locator('.mobile-run-selector select') + if (await selector.count()) await selector.focus() + }) + await screenshot(browser, { width: 390, height: 844 }, 'trace-mobile-inspector-390x844.png', async (page) => { + await page.locator('.mobile-inspector-trigger').click() + await page.waitForSelector('.inspector-pane.open[role="dialog"]') + }) + await createComparison(browser) + } finally { + await browser.close() + } +} + +main().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/scripts/capture-evidence-workspace-real-candidates.mjs b/scripts/capture-evidence-workspace-real-candidates.mjs new file mode 100644 index 0000000..dfb3ebe --- /dev/null +++ b/scripts/capture-evidence-workspace-real-candidates.mjs @@ -0,0 +1,74 @@ +import { copyFile, mkdir, readFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const rootDir = path.resolve(__dirname, '..') +const frontendDir = path.join(rootDir, 'frontend') +const candidateDir = path.join(rootDir, 'docs', 'images', 'candidates', 'evidence-workspace-system-v1-1') +const baseUrl = process.env.DEVFLOW_EVIDENCE_REAL_URL || 'http://127.0.0.1:5181' +const requireFromFrontend = createRequire(path.join(frontendDir, 'package.json')) +const { chromium } = requireFromFrontend('playwright') + +async function ensureRealApi() { + const response = await fetch(`${baseUrl}/api/projects`) + if (!response.ok) throw new Error(`Real local API is unavailable at ${baseUrl}/api/projects`) + const body = await response.json() + if (!Array.isArray(body.data) || !body.data.length) throw new Error('Real local API returned no seeded projects') +} + +async function openRealPage(browser, viewport) { + const page = await browser.newPage({ viewport, deviceScaleFactor: 1 }) + await page.goto(`${baseUrl}/agent-runs`, { waitUntil: 'networkidle' }) + await page.waitForSelector('.evidence-event-row', { timeout: 30000 }) + const pageText = await page.locator('body').innerText() + for (const forbidden of ['Development QA Fixture', '重构计划']) { + if (pageText.includes(forbidden)) throw new Error(`Real candidate contains forbidden text: ${forbidden}`) + } + const overflow = await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth) + if (overflow) throw new Error(`${viewport.width}px candidate has horizontal overflow`) + await page.addStyleTag({ content: '* { caret-color: transparent !important; } .el-message { display: none !important; }' }) + return page +} + +async function screenshot(browser, viewport, name, openInspector = false) { + const page = await openRealPage(browser, viewport) + try { + if (openInspector) { + await page.locator('.mobile-inspector-trigger').click() + await page.waitForSelector('.inspector-pane.open') + } else if (await page.locator('.inspector-pane.open').count() && viewport.width <= 1180) { + throw new Error(`${name} must capture the inspector closed state`) + } + await page.screenshot({ path: path.join(candidateDir, name), fullPage: false, animations: 'disabled' }) + } finally { await page.close() } + console.log(path.relative(rootDir, path.join(candidateDir, name))) +} + +async function comparison(browser) { + const before = await readFile(path.join(rootDir, 'docs', 'images', 'trace-evidence.png')) + const after = await readFile(path.join(candidateDir, 'trace-real-1440x900.png')) + const page = await browser.newPage({ viewport: { width: 1600, height: 620 }, deviceScaleFactor: 1 }) + await page.setContent(`

DevFlow · Trace Visual Convergence

Phase 1 baseline / Phase 1.1 real local API candidate

Phase 1 baseline
Phase 1.1 · real local API
`) + await page.screenshot({ path: path.join(candidateDir, 'trace-before-phase1-after-phase1-1.png'), fullPage: false, animations: 'disabled' }) + await page.close() + console.log(path.relative(rootDir, path.join(candidateDir, 'trace-before-phase1-after-phase1-1.png'))) +} + +async function main() { + await mkdir(candidateDir, { recursive: true }) + await ensureRealApi() + let browser + try { browser = await chromium.launch({ channel: 'chrome', headless: true, args: ['--hide-scrollbars'] }) } catch { browser = await chromium.launch({ headless: true, args: ['--hide-scrollbars'] }) } + try { + await screenshot(browser, { width: 1440, height: 900 }, 'trace-real-1440x900.png') + await screenshot(browser, { width: 1280, height: 800 }, 'trace-real-1280x800.png') + await screenshot(browser, { width: 1024, height: 768 }, 'trace-tablet-closed-1024x768.png') + await screenshot(browser, { width: 1024, height: 768 }, 'trace-tablet-open-1024x768.png', true) + await screenshot(browser, { width: 390, height: 844 }, 'trace-mobile-closed-390x844.png') + await screenshot(browser, { width: 390, height: 844 }, 'trace-mobile-open-390x844.png', true) + await comparison(browser) + } finally { await browser.close() } +} +main().catch((error) => { console.error(error); process.exit(1) })