From 119cf4a338531687c8cee46e5e30b8dae4108db1 Mon Sep 17 00:00:00 2001 From: jackwener Date: Sun, 19 Jul 2026 23:03:49 +0800 Subject: [PATCH 1/3] refactor(desktop): split visual-smoke-fixture into per-domain modules behind a barrel Pure move of the #1 main-process hotspot (2538 lines) into a thin registry barrel at the original path (visual-smoke-fixture.ts, 689 lines) plus 6 per-domain seeder modules under visual-smoke/: - seed-helpers.ts shared spine (NOW, session-id consts, scenario sets, header/writeSession/writeJson) - scenarios-settings.ts settings/connections/plan/daily-review - scenarios-modules.ts skills-market + MCP - scenarios-artifacts.ts ArtifactPane seed + spec writer - scenarios-chat.ts turn/processing/streaming/permission/error + task-ledger + live-turn projections - scenarios-sessions.ts long-transcript / workstation-statuses / turn-control lineage / long-sidebar / stale The 4-symbol public surface (resolveVisualSmokeFixture, getVisualSmokeState, seedVisualSmokeFixture, VisualSmokeFixture) stays defined in the barrel, so the 3 non-test consumers' imports are byte-identical. bot-onboarding-visual-smoke.ts boundary left untouched. Zero behavior change: registry order stable, every scenario renders identically (alignment auditor clean across all 11 fixtures). --- apps/desktop/src/main/visual-smoke-fixture.ts | 1981 +---------------- .../main/visual-smoke/scenarios-artifacts.ts | 280 +++ .../src/main/visual-smoke/scenarios-chat.ts | 466 ++++ .../main/visual-smoke/scenarios-modules.ts | 100 + .../main/visual-smoke/scenarios-sessions.ts | 672 ++++++ .../main/visual-smoke/scenarios-settings.ts | 300 +++ .../src/main/visual-smoke/seed-helpers.ts | 145 ++ 7 files changed, 2029 insertions(+), 1915 deletions(-) create mode 100644 apps/desktop/src/main/visual-smoke/scenarios-artifacts.ts create mode 100644 apps/desktop/src/main/visual-smoke/scenarios-chat.ts create mode 100644 apps/desktop/src/main/visual-smoke/scenarios-modules.ts create mode 100644 apps/desktop/src/main/visual-smoke/scenarios-sessions.ts create mode 100644 apps/desktop/src/main/visual-smoke/scenarios-settings.ts create mode 100644 apps/desktop/src/main/visual-smoke/seed-helpers.ts diff --git a/apps/desktop/src/main/visual-smoke-fixture.ts b/apps/desktop/src/main/visual-smoke-fixture.ts index 224cb9f542..fac58d0a43 100644 --- a/apps/desktop/src/main/visual-smoke-fixture.ts +++ b/apps/desktop/src/main/visual-smoke-fixture.ts @@ -1,19 +1,70 @@ -import { mkdir, rm, stat, writeFile } from 'node:fs/promises'; -import { dirname, join } from 'node:path'; -import type { - ArtifactRecord, - DailyReviewArchive, - LlmConnection, - PermissionRequestEvent, - PlanReminder, - SessionHeader, - StoredMessage, - UiLocale, - VisualSmokeScenario, - VisualSmokeState, -} from '@maka/core'; -import { createDefaultSettings } from '@maka/core/settings'; +import { mkdir, rm } from 'node:fs/promises'; +import type { UiLocale, VisualSmokeScenario, VisualSmokeState } from '@maka/core'; import type { CredentialStore } from './credential-store.js'; +import { + ARTIFACT_SESSION_ID, + ERROR_SESSION_ID, + LONG_SIDEBAR_SCENARIOS, + LONG_SIDEBAR_SESSION_PREFIX, + LONG_TRANSCRIPT_SESSION_ID, + PERMISSION_SESSION_ID, + PROCESSING_SESSION_ID, + STALE_FAKE_SESSION_ID, + TURN_CONTROL_BRANCH_ORPHAN_SESSION_ID, + TURN_CONTROL_BRANCH_VISIBLE_SESSION_ID, + TURN_CONTROL_PRIMARY_SESSION_ID, + TURN_CONTROL_SCENARIOS, + TURN_SESSION_ID, + VISUAL_SMOKE_NOW, + WORKSTATION_RUNNING_SESSION_ID, + writeSession, +} from './visual-smoke/seed-helpers.js'; +import { + artifactMessages, + artifactSession, + writeArtifacts, +} from './visual-smoke/scenarios-artifacts.js'; +import { + errorMessages, + errorSession, + permissionLiveTurns, + permissionMessages, + permissionSession, + permissionState, + processingLiveTurns, + processingMessages, + processingSession, + streamingAnswerLiveTurns, + streamingLiveTurns, + streamingMessages, + streamingSession, + turnMessages, + turnSession, + writeTaskLedgerFixture, +} from './visual-smoke/scenarios-chat.js'; +import { + seedMcpFixture, + seedSkillsMarketFixture, +} from './visual-smoke/scenarios-modules.js'; +import { + healthyMessages, + healthySession, + longSidebarSessions, + longTranscriptMessages, + longTranscriptSession, + staleFakeMessages, + staleFakeSession, + staleLegacyMessages, + staleLegacySession, + turnControlSessions, + workstationStatusSessions, +} from './visual-smoke/scenarios-sessions.js'; +import { + writeConnections, + writeDailyReviewArchives, + writePlanReminders, + writeSettings, +} from './visual-smoke/scenarios-settings.js'; const VISUAL_SMOKE_SCENARIOS = new Set([ 'all', @@ -141,11 +192,6 @@ const VISUAL_SMOKE_SCENARIOS = new Set([ 'browser-empty', ]); -// Fixed clock for screenshot fixtures. All seeded timestamps and -// transient smoke state derive from this value unless tests explicitly -// pass `now`, so two baseline runs produce identical visible time copy. -const VISUAL_SMOKE_NOW = Date.UTC(2026, 4, 22, 3, 0, 0); - export interface VisualSmokeFixture { scenario: VisualSmokeScenario; workspaceName: string; @@ -641,1898 +687,3 @@ export async function seedVisualSmokeFixture(input: { await seedMcpFixture(input.workspaceRoot); } } - -/** - * MCP module fixture: seeds an mcp.json with a couple of installed servers so - * the 已安装 tab and the server rows render for the alignment auditor + CDP - * capture. Both are `enabled: false` so no real `npx` / HTTP connection is - * attempted in visual-smoke mode — the rows render deterministically in the - * neutral 已停用 state (exception-only status: no color unless a real failure). - * The 市场 tab is the default surface and is driven by the static MCP_CATALOG, - * so it renders without any on-disk seed. - */ -async function seedMcpFixture(workspaceRoot: string): Promise { - const config = { - version: 1, - mcpServers: { - filesystem: { - enabled: false, - command: 'npx', - args: ['-y', '@modelcontextprotocol/server-filesystem', '/workspace/maka'], - }, - 'linear-remote': { - enabled: false, - url: 'https://mcp.linear.app/sse', - transport: 'sse', - }, - }, - }; - await writeJson(join(workspaceRoot, 'mcp.json'), config); -} - -/** - * Marketplace fixture: seeds a managed-source catalog (≥6 entries across - * categories with varied recency) plus a couple of workspace skills so the - * 市场 grid, category filter, sort, and the 内置/已安装 rows all render - * meaningfully in the CDP capture. Managed sources normally live in - * ~/.maka/skill-sources; the dev-gated MAKA_SKILL_SOURCES_ROOT override - * (resolveManagedSkillSourcesRoot) points both the seeder and the runtime - * IPC at a fixture-local dir so nothing touches the real home catalog. - */ -async function seedSkillsMarketFixture(workspaceRoot: string): Promise { - const sourcesRoot = join(workspaceRoot, '.maka', 'skill-sources'); - process.env.MAKA_SKILL_SOURCES_ROOT = sourcesRoot; - await mkdir(sourcesRoot, { recursive: true }); - - const sources: ReadonlyArray<{ id: string; name: string; description: string; category: string }> = [ - { id: 'research-brief', name: '研究简报', category: '研究与分析', description: '把网页资料、引用和结论整理成结构化 brief,适合快速进入陌生领域。' }, - { id: 'doc-review', name: '文档审阅', category: '文档与写作', description: '检查 DOCX / Markdown 的结构、语气和遗漏项,并输出可执行修改建议。' }, - { id: 'meeting-followup', name: '会议跟进', category: '效率工具', description: '从会议记录里抽取决定、风险和 owner,生成下一步任务清单。' }, - { id: 'release-checklist', name: '发布检查', category: 'DevOps与部署', description: '按发布前 checklist 扫描 diff、测试和文档,减少临门一脚的遗漏。' }, - { id: 'data-analyst', name: '数据分析助手', category: '数据与AI', description: '读取 CSV / 表格,做透视、异常检测和趋势总结,产出可复述的结论。' }, - { id: 'ui-audit', name: 'UI 走查', category: '设计与UI', description: '对照设计规范逐项走查间距、层级和状态色,列出需要修的细节。' }, - { id: 'blog-outline', name: '博客提纲', category: '内容创作', description: '把零散想法整理成有节奏的文章提纲,附上每段的论据方向。' }, - ]; - - // Stagger mtimes so 排序:最近 has a meaningful order (the last-written - // source is the most recent). Written newest-last on purpose. - for (const source of sources) { - const dir = join(sourcesRoot, source.id); - await mkdir(dir, { recursive: true }); - const content = [ - '---', - `name: ${source.name}`, - `description: ${source.description}`, - `category: ${source.category}`, - '---', - '', - `# ${source.name}`, - '', - source.description, - '', - ].join('\n'); - await writeFile(join(dir, 'SKILL.md'), content, { encoding: 'utf8', mode: 0o600 }); - } - - // A couple of workspace skills so 已安装 is not empty and one managed - // source shows as installed in the grid. The bundled OfficeCLI skills - // (seeded separately after the fixture) populate the 内置 tab. - const workspaceSkills: ReadonlyArray<{ id: string; name: string; description: string }> = [ - { id: 'meeting-followup', name: '会议跟进', description: '从会议记录里抽取决定、风险和 owner,生成下一步任务清单。' }, - { id: 'daily-standup', name: '每日站会', description: '汇总昨日进展、今日计划和阻塞,生成简短的站会同步。' }, - ]; - for (const skill of workspaceSkills) { - const dir = join(workspaceRoot, 'skills', skill.id); - await mkdir(dir, { recursive: true }); - const content = [ - '---', - `name: ${skill.name}`, - `description: ${skill.description}`, - '---', - '', - `# ${skill.name}`, - '', - skill.description, - '', - ].join('\n'); - await writeFile(join(dir, 'SKILL.md'), content, { encoding: 'utf8', mode: 0o600 }); - } -} - -/** - * Scenarios that share the long-sidebar (60-session) on-disk seed. - * Kept as a Set so future scenarios reusing the same seed can be - * registered in one place. Mirrors `TURN_CONTROL_SCENARIOS`. - */ -const LONG_SIDEBAR_SCENARIOS = new Set([ - 'module-skills', - 'module-daily-review', - 'plan-reminders', - 'sidebar-long-sessions', - 'sidebar-search-modal-open', - 'command-palette-open', - 'sidebar-row-actions-visible', -]); - -/** - * PR109f (g): scenarios that share the turn-control-history on-disk - * seed. Keeps the trio listed in one place so a reviewer can confirm - * they're variants of the same state family (active session differs, - * everything else identical). - */ -const TURN_CONTROL_SCENARIOS = new Set([ - 'turn-control-history', - 'turn-control-branch-visible', - 'turn-control-branch-orphan', -]); - -const TURN_SESSION_ID = 'visual-smoke-turn'; - -async function writeTaskLedgerFixture(workspaceRoot: string, now: number): Promise { - const tasks = [ - { - id: 'task-root-implementation', key: 'T1', subject: '完成会话任务台账升级', status: 'in_progress', - createdAt: now - 50 * 60_000, updatedAt: now - 2 * 60_000, - owner: { actor: 'main_agent', runId: 'run-task-parent', turnId: 'turn-fixture-2' }, - }, - { - id: 'task-child-storage', key: 'T1.1', parentId: 'task-root-implementation', - subject: '验证旧 JSONL 迁移与并发短 key 分配', status: 'completed', - createdAt: now - 45 * 60_000, updatedAt: now - 8 * 60_000, endedAt: now - 8 * 60_000, - completionEvidence: 'Core 与 Storage 定向测试全部通过。', - }, - { - id: 'task-child-ui', key: 'T1.2', parentId: 'task-root-implementation', - subject: '检查窄窗口下的任务树布局', status: 'blocked', - createdAt: now - 40 * 60_000, updatedAt: now - 3 * 60_000, - blockedReason: '等待视觉回归截图确认 990px 视口没有文字重叠。', - owner: { actor: 'child_agent', agentId: 'local-read', runId: 'run-task-child', turnId: 'turn-task-child' }, - }, - { - id: 'task-grandchild-copy', key: 'T1.2.1', parentId: 'task-child-ui', - subject: '核对深层缩进、超长任务描述、owner 与阻塞原因在窄窗口中仍可完整换行且不遮挡后续内容', - status: 'pending', createdAt: now - 35 * 60_000, updatedAt: now - 3 * 60_000, - }, - { - id: 'task-docs', key: 'T2', subject: '同步生命周期文档与边界说明', status: 'pending', - createdAt: now - 30 * 60_000, updatedAt: now - 5 * 60_000, - }, - { - id: 'task-runtime', key: 'T3', subject: '验证 Goal 一次提醒门禁', status: 'completed', - createdAt: now - 25 * 60_000, updatedAt: now - 6 * 60_000, endedAt: now - 6 * 60_000, - completionEvidence: 'Goal gate 定向测试覆盖空任务、阻塞任务、一次提醒和上限放行。', - }, - ]; - await writeFile( - join(workspaceRoot, 'sessions', TURN_SESSION_ID, 'tasks.json'), - `${JSON.stringify(tasks, null, 2)}\n`, - 'utf8', - ); -} -const LONG_TRANSCRIPT_SESSION_ID = 'visual-smoke-long-transcript'; -const PROCESSING_SESSION_ID = 'visual-smoke-processing'; -const STREAMING_SESSION_ID = 'visual-smoke-streaming'; -// PR-STREAM-TURN-CENTER: realistic multi-block markdown (heading + paragraph + -// list) for the `streaming-answer` scenario, so the captured streaming bubble -// exercises the same prose layout a real answer does and its left edge is -// unambiguous to compare against the committed turn above it. -const STREAMING_ANSWER_MARKDOWN = [ - '## Maka Desktop 项目概况', - '', - '这里是当前项目的快速概览:', - '', - '- 框架:Electron + React 19 + Vite 7', - '- 语言:TypeScript', - '- 构建:tsc(main / preload)+ Vite(renderer)', - '', - '正在整理目录结构,稍等……', -].join('\n'); -const PERMISSION_SESSION_ID = 'visual-smoke-permission'; -const WORKSTATION_RUNNING_SESSION_ID = 'visual-smoke-ws-running'; -const WORKSTATION_WAITING_SESSION_ID = 'visual-smoke-ws-waiting'; -const WORKSTATION_BLOCKED_AUTH_SESSION_ID = 'visual-smoke-ws-blocked-auth'; -const WORKSTATION_BLOCKED_PERM_SESSION_ID = 'visual-smoke-ws-blocked-perm'; -const WORKSTATION_BLOCKED_TOOL_SESSION_ID = 'visual-smoke-ws-blocked-tool'; -const WORKSTATION_BLOCKED_UNKNOWN_SESSION_ID = 'visual-smoke-ws-blocked-unknown'; -const WORKSTATION_ACTIVE_SESSION_ID = 'visual-smoke-ws-active'; -const WORKSTATION_REVIEW_SESSION_ID = 'visual-smoke-ws-review'; -const WORKSTATION_DONE_SESSION_ID = 'visual-smoke-ws-done'; -const WORKSTATION_ARCHIVED_SESSION_ID = 'visual-smoke-ws-archived'; -const WORKSTATION_ABORTED_SESSION_ID = 'visual-smoke-ws-aborted'; -const ERROR_SESSION_ID = 'visual-smoke-error'; -const ARTIFACT_SESSION_ID = 'visual-smoke-artifact'; -const STALE_FAKE_SESSION_ID = 'visual-smoke-stale-fake'; -const STALE_LEGACY_SESSION_ID = 'visual-smoke-stale-legacy'; -const HEALTHY_SESSION_ID = 'visual-smoke-healthy'; -// PR109f (g): turn-control-history primary + branch sessions. The -// `BRANCH_ORPHAN` session's `parentSessionId` intentionally references -// a session id that is NEVER written to disk so the renderer's -// `deriveBranchBanner()` resolves the parent as missing and renders no -// banner in the negative screenshot case. -const TURN_CONTROL_PRIMARY_SESSION_ID = 'visual-smoke-turn-control-primary'; -const TURN_CONTROL_BRANCH_VISIBLE_SESSION_ID = 'visual-smoke-turn-control-branch-visible'; -const TURN_CONTROL_BRANCH_ORPHAN_SESSION_ID = 'visual-smoke-turn-control-branch-orphan'; -const TURN_CONTROL_ORPHAN_PARENT_ID = 'visual-smoke-turn-control-deleted-parent'; - -/** - * PR-SIDEBAR-IA-0 Phase 1: sidebar-long-sessions scenario seeds many - * sessions with this prefix. Two digits → 60 distinct IDs (00..59). - * Active session is always `${LONG_SIDEBAR_SESSION_PREFIX}00` (newest by - * lastMessageAt). Path is short so it stays stable in screenshot - * baselines. - */ -const LONG_SIDEBAR_SESSION_PREFIX = 'visual-smoke-sidebar-long-'; -const LONG_SIDEBAR_SESSION_COUNT = 60; - -async function writePlanReminders(workspaceRoot: string, now: number): Promise { - const scheduledRunAt = Date.UTC(2026, 11, 18, 3, 0, 0); - const pausedRunAt = Date.UTC(2026, 11, 20, 3, 0, 0); - const reminders: PlanReminder[] = [ - { - id: 'visual-plan-reminder-standup', - title: '同步项目风险', - note: '提醒我整理 Sidebar gate、搜索接入和计划任务剩余风险。', - schedule: { kind: 'once', runAt: scheduledRunAt }, - delivery: { channel: 'local' }, - status: 'scheduled', - enabled: true, - createdAt: now - 2 * 60 * 60_000, - updatedAt: now - 2 * 60 * 60_000, - nextRunAt: scheduledRunAt, - runs: [], - runCount: 0, - }, - { - id: 'visual-plan-reminder-paused', - title: '暂停的发布检查', - note: '用户可以先暂停提醒,恢复后继续按原时间触发。', - schedule: { kind: 'once', runAt: pausedRunAt }, - delivery: { channel: 'local' }, - status: 'paused', - enabled: false, - createdAt: now - 3 * 60 * 60_000, - updatedAt: now - 30 * 60_000, - runs: [], - runCount: 0, - }, - { - id: 'visual-plan-reminder-weekly-review', - title: '每周竞品动态追踪', - note: '汇总同类 AI 工具的近期产品变化,提醒我复盘可对标的交互。', - schedule: { kind: 'cron', expression: '0 10 * * 1', startAt: now - 3.5 * 60 * 60_000 }, - delivery: { channel: 'local' }, - status: 'scheduled', - enabled: true, - createdAt: now - 3.5 * 60 * 60_000, - updatedAt: now - 35 * 60_000, - nextRunAt: Date.UTC(2026, 11, 21, 2, 0, 0), - runs: [], - runCount: 0, - }, - { - id: 'visual-plan-reminder-completed', - title: '已触发的本地提醒', - note: '', - schedule: { kind: 'once', runAt: now - 45 * 60_000 }, - delivery: { channel: 'local' }, - status: 'completed', - enabled: false, - createdAt: now - 4 * 60 * 60_000, - updatedAt: now - 45 * 60_000, - lastRun: { - id: 'visual-plan-run-completed', - at: now - 45 * 60_000, - status: 'triggered', - message: '计划提醒已触发', - }, - runs: [ - { - id: 'visual-plan-run-completed', - at: now - 45 * 60_000, - status: 'triggered', - message: '计划提醒已触发', - }, - ], - runCount: 1, - }, - ]; - await writeJson(join(workspaceRoot, 'plan-reminders.json'), reminders); -} - -async function writeDailyReviewArchives(workspaceRoot: string, now: number): Promise { - const dayFromMs = Date.UTC(2026, 4, 21, 0, 0, 0); - const dayToMs = Date.UTC(2026, 4, 22, 0, 0, 0); - const daily: DailyReviewArchive = { - id: '2026-05-21-daily', - day: { fromMs: dayFromMs, toMs: dayToMs }, - mode: 'daily', - status: 'ok', - generatedAt: now - 10 * 60_000, - trigger: 'manual', - modelKey: 'zai-live::glm-4.5', - totals: { - sessionCount: 8, - requestCount: 34, - totalTokens: 128_640, - costUsd: 1.82, - errorCount: 1, - }, - sections: { - summary: '今天主要围绕 Maka 桌面端的侧边栏、权限中心和每日回顾展开,重点是把入口、报告保存和设置项接到真实运行链路。', - gaps: '权限中心按钮已经接入系统设置跳转;每日回顾外部通知仍缺少报告自动推送运行时,需要保持不可用状态而不是展示假开关。', - usage: '模型请求集中在 UI 逆向与合约验证,工具调用以文件检索、构建和截图 smoke 为主。', - code: '建议继续收敛 Settings 与模块页的 shared page shell,减少同类 surface 在 styles.css 里的重复规则。', - }, - }; - const deep: DailyReviewArchive = { - ...daily, - id: '2026-05-21-deep', - mode: 'deep', - generatedAt: now - 5 * 60_000, - trigger: 'cron', - totals: { - ...daily.totals, - sessionCount: 12, - requestCount: 58, - totalTokens: 211_300, - costUsd: 3.94, - errorCount: 1, - }, - sections: { - summary: '深度分析覆盖最近一轮 Maka UI 打磨:参考布局学习、权限中心重画、Daily Review 从聚合面板走向可保存报告。', - gaps: '第一性原理层面需要把“模块页 shell / Settings row / 状态 pill / 操作按钮”抽成真实组件,否则后续仍会在 CSS 中继续堆叠局部规则。', - usage: '高频动作是读取源码、运行 contract、构建 renderer、生成 visual-smoke 截图。失败成本主要来自多处页面壳层行为不统一。', - code: '下一步优先建立模块页 PageShell、SettingsActionRow 和 StatusPill primitives,再迁移 Daily Review、权限中心、计划任务和技能页。', - }, - }; - const archiveDir = join(workspaceRoot, 'daily-reviews', 'archive'); - await mkdir(archiveDir, { recursive: true }); - await writeJson(join(archiveDir, `${daily.id}.json`), daily); - await writeJson(join(archiveDir, `${deep.id}.json`), deep); -} - -async function writeSettings(workspaceRoot: string): Promise { - // PR-SIDEBAR-IA-0 Phase 3 P0 fixup v2 (kenji `08be08d8` + WAWQAQ - // `1886c41b`): the fixture previously seeded a placeholder - // Chinese personal name for screenshot baselines, but a real - // user reading the chat surface can't tell who that placeholder - // is. Worse, if a demo workspace was ever opened on top of a - // real user's workspace, the placeholder would persist and - // confuse them about who set it. - // - // Phase 3 fixup v2 leaves `displayName` empty so screenshots and - // Settings match a new, unconfigured user. Settings test - // (`visual-smoke-fixture.test.ts`) asserts the empty-string value - // so a future patch that re-adds a demo name lands as an explicit - // copy decision, not silent drift. - const settings = createDefaultSettings(); - settings.personalization.displayName = ''; - settings.appearance.theme = 'auto'; - await writeJson(join(workspaceRoot, 'settings.json'), settings); -} - -async function writeConnections(workspaceRoot: string, now: number, scenario: VisualSmokeScenario): Promise { - const connections: LlmConnection[] = [ - { - slug: 'zai-live', - name: 'Z.ai Live Fixture', - providerType: 'zai-coding-plan', - baseUrl: 'https://api.z.ai/api/coding/paas/v4', - defaultModel: 'glm-5.1', - enabled: true, - models: [ - model('glm-4.5', { functionCalling: true }, 128_000), - model('glm-4.5-air', { functionCalling: true }, 128_000), - model('glm-4.6', { reasoning: true, functionCalling: true }, 200_000), - model('glm-4.7', { reasoning: true, functionCalling: true }, 200_000), - model('glm-5', { reasoning: true, functionCalling: true }, 200_000), - model('glm-5-turbo', { reasoning: true, functionCalling: true }, 200_000), - model('glm-5.1', { vision: true, reasoning: true, functionCalling: true }, 1_000_000), - ], - modelSource: 'fetched', - modelsFetchedAt: now - 5 * 60_000, - lastTestStatus: 'verified', - lastTestAt: new Date(now - 4 * 60_000).toISOString(), - lastTestMessage: '连接已验证', - createdAt: now - 3_600_000, - updatedAt: now - 4 * 60_000, - }, - { - slug: 'relay-fallback', - name: 'Fallback Relay Fixture', - providerType: 'openai-compatible', - baseUrl: 'https://relay.example.test/v1', - defaultModel: 'relay-static-model', - enabled: true, - modelSource: 'fallback', - createdAt: now - 3_500_000, - updatedAt: now - 3_500_000, - }, - { - slug: 'empty-fetched', - name: 'Fetched Empty Fixture', - providerType: 'openai-compatible', - baseUrl: 'https://empty.example.test/v1', - defaultModel: 'empty-placeholder', - enabled: true, - models: [], - modelSource: 'fetched', - modelsFetchedAt: now - 15 * 60_000, - lastTestStatus: 'verified', - lastTestAt: new Date(now - 15 * 60_000).toISOString(), - lastTestMessage: '连接已验证', - createdAt: now - 3_400_000, - updatedAt: now - 15 * 60_000, - }, - { - slug: 'needs-reauth', - name: 'Needs Reauth Fixture', - providerType: 'anthropic', - defaultModel: 'claude-sonnet-4-5-20250929', - enabled: true, - models: [model('claude-sonnet-4-5-20250929', { vision: true, reasoning: true, functionCalling: true }, 200_000)], - modelSource: 'fetched', - modelsFetchedAt: now - 3 * 3_600_000, - lastTestStatus: 'needs_reauth', - lastTestAt: new Date(now - 10 * 60_000).toISOString(), - lastTestMessage: '鉴权失败', - createdAt: now - 3_300_000, - updatedAt: now - 10 * 60_000, - }, - { - slug: 'broken-provider', - name: 'Broken Provider Fixture', - providerType: 'openai', - defaultModel: 'gpt-4o-mini', - enabled: true, - models: [model('gpt-4o-mini', { vision: true, functionCalling: true }, 128_000)], - modelSource: 'fetched', - modelsFetchedAt: now - 4 * 3_600_000, - lastTestStatus: 'error', - lastTestAt: new Date(now - 8 * 60_000).toISOString(), - lastTestMessage: '模型服务返回错误', - createdAt: now - 3_200_000, - updatedAt: now - 8 * 60_000, - }, - ]; - if (scenario === 'oauth-relogin') { - // A openai-codex (OAuth) connection whose last test came back - // needs_reauth. Its detail sheet must offer an inline 登录 / 重新登录 - // button (driven by the shared OAuth login flow) instead of the old dead - // prose. Credential presence for OAuth connections is resolved through the - // subscription token store (empty here), so the button reads 登录; the - // hasSecret===true → 重新登录 label is pinned by the detail-sheet contract. - connections.push({ - slug: 'codex-oauth', - name: 'OpenAI Codex Fixture', - providerType: 'openai-codex', - defaultModel: 'gpt-5.5', - enabled: true, - models: [model('gpt-5.5', { reasoning: true, functionCalling: true }, 200_000)], - modelSource: 'fetched', - modelsFetchedAt: now - 6 * 60_000, - lastTestStatus: 'needs_reauth', - lastTestAt: new Date(now - 6 * 60_000).toISOString(), - lastTestMessage: '需要重新登录', - createdAt: now - 3_100_000, - updatedAt: now - 6 * 60_000, - }); - } - const focusSlug = connectionFocusSlug(scenario); - const ordered = focusSlug - ? [ - ...connections.filter((connection) => connection.slug === focusSlug), - ...connections.filter((connection) => connection.slug !== focusSlug), - ] - : connections; - await writeJson(join(workspaceRoot, 'llm-connections.json'), { - defaultSlug: focusSlug ?? 'zai-live', - connections: ordered, - }); -} - -function connectionFocusSlug(scenario: VisualSmokeScenario): string | null { - switch (scenario) { - case 'fallback-source': - return 'relay-fallback'; - case 'fetched-empty': - return 'empty-fetched'; - case 'oauth-relogin': - return 'codex-oauth'; - case 'connection-error': - return 'broken-provider'; - default: - return null; - } -} - -function model( - id: string, - capabilities: NonNullable[number]['capabilities'], - contextWindow: number, -): NonNullable[number] { - return { id, capabilities, contextWindow }; -} - -function turnSession(now: number): SessionHeader { - return header({ - id: TURN_SESSION_ID, - name: '模型管理与工具调用示例', - connection: 'zai-live', - model: 'glm-5.1', - now, - lastMessageAt: now - 9 * 60_000, - }); -} - -function longTranscriptSession(now: number): SessionHeader { - return header({ - id: LONG_TRANSCRIPT_SESSION_ID, - name: '超长会话滚动几何', - connection: 'zai-live', - model: 'glm-5.1', - now, - lastMessageAt: now - 5 * 60_000, - }); -} - -/** - * 24 turns, each ~1300px tall once rendered, so the transcript is ~25x the - * 250px contain-intrinsic-size placeholder per turn and dozens of viewports - * tall overall. Plain text on purpose: the contract under test is scroll - * geometry, not markdown rendering. - */ -function longTranscriptMessages(now: number): StoredMessage[] { - const filler = Array.from( - { length: 60 }, - (_, line) => `第 ${line + 1} 行 — 用于撑高单个 turn 的占位正文内容。`, - ).join(' \n'); - const messages: StoredMessage[] = []; - const base = now - 60 * 60_000; - for (let turn = 0; turn < 24; turn++) { - const turnId = `long-transcript-turn-${turn}`; - messages.push({ - type: 'user', - id: `long-transcript-user-${turn}`, - turnId, - ts: base + turn * 60_000, - text: `长会话问题 ${turn + 1}`, - }); - messages.push({ - type: 'assistant', - id: `long-transcript-assistant-${turn}`, - turnId, - ts: base + turn * 60_000 + 30_000, - text: `长会话回答 ${turn + 1}\n\n${filler}`, - modelId: 'glm-5.1', - }); - } - return messages; -} - -function turnMessages(now: number): StoredMessage[] { - const turnId = 'turn-fixture-1'; - return [ - { type: 'user', id: 'msg-user-1', turnId, ts: now - 10 * 60_000, text: '检查项目状态,列出需要我优先处理的风险。' }, - { - type: 'tool_call', - id: 'tool-status', - turnId, - ts: now - 9 * 60_000 - 50_000, - toolName: 'Bash', - displayName: '检查测试状态', - intent: '运行测试摘要并读取失败输出', - args: { cmd: 'npm test --workspaces --if-present', cwd: '/workspace/maka' }, - }, - { - type: 'tool_result', - id: 'tool-status-result', - turnId, - ts: now - 9 * 60_000 - 42_000, - toolUseId: 'tool-status', - isError: false, - durationMs: 8_240, - content: { - kind: 'terminal', - cwd: '/workspace/maka', - cmd: 'npm test --workspaces --if-present', - status: 'completed', - exitCode: 0, - output: { - mode: 'pipes', - stdout: 'core 41 passing\nstorage 17 passing\nruntime 70 passing\ndesktop 74 passing\n', - stderr: '', - stdoutTruncated: false, - stderrTruncated: false, - redacted: false, - }, - }, - }, - { - type: 'tool_call', - id: 'tool-diff', - turnId, - ts: now - 9 * 60_000 - 38_000, - toolName: 'Read', - displayName: '查看关键 diff', - intent: '确认启用模型名单是否有测试覆盖', - args: { path: 'apps/desktop/src/renderer/settings/provider-connection-detail.tsx' }, - }, - { - type: 'tool_result', - id: 'tool-diff-result', - turnId, - ts: now - 9 * 60_000 - 34_000, - toolUseId: 'tool-diff', - isError: false, - durationMs: 1_120, - content: { - kind: 'file_diff', - paths: ['apps/desktop/src/renderer/settings/provider-connection-detail.tsx'], - diff: [ - 'diff --git a/provider-connection-detail.tsx b/provider-connection-detail.tsx', - '+function EnabledModelManager(props) {', - '+ const enabled = new Set(props.enabledModelIds);', - '+ return
    ;', - '-// default-model radio table', - ].join('\n'), - }, - }, - { - type: 'assistant', - id: 'msg-assistant-1', - turnId, - ts: now - 9 * 60_000, - text: '当前需要重点观察截图基线是否稳定、启用模型名单是否清晰,以及完整目录是否只在搜索时出现。这些状态会作为下一轮界面验收的基线。', - thinking: { - text: '这段是 fixture 用的模型推理草稿。它应默认折叠,并且不会进入默认复制答案路径。', - }, - modelId: 'glm-5.1', - }, - { - type: 'token_usage', - id: 'usage-1', - turnId, - ts: now - 9 * 60_000 + 100, - input: 1250, - output: 320, - cacheRead: 180, - costUsd: 0.0042, - }, - // Streaming UI rework: a second, MULTI-STEP turn. Each step persists its own - // assistant row (thinking + text) plus tool_calls tagged with that row's id - // as `stepId`, so the turn timeline reconstructs the real per-step order — - // 深度思考 → answer text → tool trow — instead of one trailing tool group. - // Locks the capture for the new timeline (contrast the legacy stepless turn - // above, which renders tools-before-text). - ...multiStepTurnMessages(now), - ]; -} - -// #646: a running session whose latest turn is a lone user prompt with no -// assistant reply yet — the on-disk shape of "just sent, awaiting first token". -// Paired with a waiting live projection + status `running`, the renderer derives the -// "正在处理…" model-wait indicator on the tail turn and the composer shows Stop. -function processingSession(now: number): SessionHeader { - return header({ - id: PROCESSING_SESSION_ID, - name: '正在处理请求', - connection: 'zai-live', - model: 'glm-5.1', - now, - lastMessageAt: now - 2_000, - status: 'running', - }); -} - -function processingMessages(now: number): StoredMessage[] { - const turnId = 'turn-processing-1'; - return [ - { type: 'user', id: 'msg-processing-user', turnId, ts: now - 2_000, text: '把刚才那批改动整理成一份可交接的变更说明,并指出还需我确认的点。' }, - ]; -} - -function multiStepTurnMessages(now: number): StoredMessage[] { - const turnId = 'turn-fixture-2'; - const step1 = 'msg-assistant-2a'; - const step2 = 'msg-assistant-2b'; - return [ - { type: 'user', id: 'msg-user-2', turnId, ts: now - 6 * 60_000, text: '确认 stream-fade 的环逻辑没有边界问题,然后跑一下单测。', origin: { kind: 'automation', automationId: 'auto-fixture-demo' } }, - { - type: 'tool_call', - id: 'tool-read-fade', - turnId, - ts: now - 6 * 60_000 + 4_000, - toolName: 'Read', - displayName: '读取 stream-fade.ts', - intent: '读取淡入环实现,确认窗口滑动与上限', - stepId: step1, - args: { file_path: 'packages/ui/src/stream-fade.ts' }, - }, - { - type: 'tool_result', - id: 'tool-read-fade-result', - turnId, - ts: now - 6 * 60_000 + 4_600, - toolUseId: 'tool-read-fade', - isError: false, - durationMs: 560, - content: { kind: 'text', text: 'export function updateFadeRing(...) { /* prune + cap */ }' }, - }, - { - type: 'assistant', - id: step1, - turnId, - ts: now - 5 * 60_000, - text: '环逻辑没问题:增长记录批次、超窗剪枝、按上限截断,收缩时整体重置。接下来跑单测确认。', - thinking: { text: 'boundary 取最老存活批次的 start,age 用 now 减去覆盖该 offset 的批次时间,窗口滑动和上限都覆盖了,值得跑一遍测试坐实。' }, - modelId: 'glm-5.1', - }, - { - type: 'tool_call', - id: 'tool-run-fade-tests', - turnId, - ts: now - 5 * 60_000 + 3_000, - toolName: 'Bash', - displayName: '运行 stream-fade 单测', - intent: '执行 node --test 跑淡入环与 tokenizer 单测', - stepId: step2, - args: { cmd: 'node --test dist/main/__tests__/stream-fade.test.js', cwd: '/workspace/maka' }, - }, - { - type: 'tool_result', - id: 'tool-run-fade-tests-result', - turnId, - ts: now - 5 * 60_000 + 5_200, - toolUseId: 'tool-run-fade-tests', - isError: false, - durationMs: 1_930, - content: { - kind: 'terminal', - cwd: '/workspace/maka', - cmd: 'node --test dist/main/__tests__/stream-fade.test.js', - status: 'completed', - exitCode: 0, - output: { - mode: 'pipes', - stdout: 'tests 13\npass 13\nfail 0\n', - stderr: '', - stdoutTruncated: false, - stderrTruncated: false, - redacted: false, - }, - }, - }, - { - type: 'assistant', - id: step2, - turnId, - ts: now - 4 * 60_000, - text: '13 个单测全绿,窗口滑动、乱序快照取龄和上限都被覆盖。边界没有问题。', - thinking: { text: '测试覆盖窗口滑动、乱序 age 查询与上限三类,全过说明剪枝和 cap 的顺序对,可以收尾。' }, - modelId: 'glm-5.1', - }, - ]; -} - -function streamingSession(now: number): SessionHeader { - return header({ - id: STREAMING_SESSION_ID, - name: '后台流式任务', - connection: 'zai-live', - model: 'glm-5', - now, - hasUnread: true, - lastMessageAt: now - 2 * 60_000, - }); -} - -function streamingMessages(now: number): StoredMessage[] { - return [ - { - type: 'user', - id: 'stream-user', - turnId: 'turn-streaming', - ts: now - 2 * 60_000, - text: '后台继续跑一轮诊断,完成后告诉我。', - }, - ]; -} - -function permissionSession(now: number): SessionHeader { - return header({ - id: PERMISSION_SESSION_ID, - name: '危险权限确认', - connection: 'zai-live', - model: 'glm-5.1', - now, - lastMessageAt: now - 4 * 60_000, - }); -} - -function permissionMessages(now: number): StoredMessage[] { - return [ - { - type: 'user', - id: 'permission-user', - turnId: 'turn-permission', - ts: now - 4 * 60_000, - text: '模拟一个需要不可恢复操作权限确认的场景,但不要真的执行。', - }, - { - type: 'tool_call', - id: 'permission-tool', - turnId: 'turn-permission', - ts: now - 4 * 60_000 + 1_000, - toolName: 'Bash', - displayName: '模拟删除命令', - intent: '清理构建产物目录', - args: { command: 'rm -rf ./dist', cwd: '/workspace/maka' }, - }, - ]; -} - -function errorSession(now: number): SessionHeader { - return header({ - id: ERROR_SESSION_ID, - name: '连接失败提示', - connection: 'broken-provider', - model: 'gpt-4o-mini', - now, - lastMessageAt: now - 20 * 60_000, - }); -} - -function errorMessages(now: number): StoredMessage[] { - return [ - { - type: 'user', - id: 'error-user', - turnId: 'turn-error', - ts: now - 20 * 60_000, - text: '这条会话用于验证 chat header 的连接失败提示。', - }, - ]; -} - -function artifactSession(now: number): SessionHeader { - return header({ - id: ARTIFACT_SESSION_ID, - name: '生成文件验收', - connection: 'zai-live', - model: 'glm-5.1', - now, - lastMessageAt: now - 6 * 60_000, - }); -} - -function artifactMessages(now: number): StoredMessage[] { - const turnId = 'turn-artifact'; - return [ - { - type: 'user', - id: 'artifact-user', - turnId, - ts: now - 7 * 60_000, - text: '生成一个 HTML 报告、一个 diff 和一份 Markdown 说明,放到右侧生成文件面板里检查。', - }, - { - type: 'tool_call', - id: 'artifact-tool', - turnId, - ts: now - 7 * 60_000 + 1_000, - toolName: 'Write', - displayName: '写入生成文件', - intent: '生成 report.html / patch.diff / notes.md 三个生成文件', - args: { path: 'artifacts/visual-smoke' }, - }, - { - type: 'assistant', - id: 'artifact-assistant', - turnId, - ts: now - 6 * 60_000, - text: '已生成 3 个生成文件:HTML 报告、补丁 diff 和 Markdown 说明。请在右侧生成文件面板验证预览、大小限制与 HTML 沙箱边界。', - modelId: 'glm-5.1', - }, - ]; -} - -/** - * PR109b workstation-statuses fixture seed. Returns one session per - * SessionStatus group + 4 blocked sub-rows (one per - * SessionBlockedReason), pre-staged with a brief 2-message history so - * the sidebar `lastMessagePreview` renders something realistic. - * - * Order in the array doesn't matter — the renderer's grouping helper - * places them in the locked group order regardless. The active - * session (`WORKSTATION_RUNNING_SESSION_ID`) is chosen as the running - * one so the chat header status badge ("进行中") shows in the - * screenshot alongside the sidebar grouping. - */ -function workstationStatusSessions(now: number): Array<{ header: SessionHeader; messages: StoredMessage[] }> { - const baseLastMessage = now - 2 * 60 * 1000; - const make = (input: { - id: string; - name: string; - status: SessionHeader['status']; - blockedReason?: SessionHeader['blockedReason']; - isArchived?: boolean; - isFlagged?: boolean; - lastMessageOffset: number; - }): { header: SessionHeader; messages: StoredMessage[] } => ({ - header: header({ - id: input.id, - name: input.name, - connection: 'zai-live', - model: 'glm-5.1', - now, - lastMessageAt: baseLastMessage - input.lastMessageOffset, - status: input.status, - ...(input.blockedReason ? { blockedReason: input.blockedReason } : {}), - ...(input.isArchived !== undefined ? { isArchived: input.isArchived } : {}), - ...(input.isFlagged !== undefined ? { isFlagged: input.isFlagged } : {}), - }), - messages: [ - { - type: 'user', - id: `${input.id}-user`, - turnId: `${input.id}-turn`, - ts: baseLastMessage - input.lastMessageOffset - 10_000, - text: `请把「${input.name}」这条工作流的当前状态整理成可交接摘要。`, - }, - { - type: 'assistant', - id: `${input.id}-assistant`, - turnId: `${input.id}-turn`, - ts: baseLastMessage - input.lastMessageOffset, - text: '已记录关键状态、下一步动作和需要人工确认的风险点。', - modelId: 'glm-5.1', - }, - ], - }); - return [ - make({ id: WORKSTATION_RUNNING_SESSION_ID, name: '正在生成报告', status: 'running', lastMessageOffset: 1_000 }), - make({ id: WORKSTATION_WAITING_SESSION_ID, name: '等你确认权限', status: 'waiting_for_user', lastMessageOffset: 60_000 }), - make({ - id: WORKSTATION_BLOCKED_AUTH_SESSION_ID, - name: 'GPT-5 鉴权失败', - status: 'blocked', - blockedReason: 'auth', - lastMessageOffset: 120_000, - }), - make({ - id: WORKSTATION_BLOCKED_PERM_SESSION_ID, - name: '等待权限批准', - status: 'blocked', - blockedReason: 'permission_required', - lastMessageOffset: 180_000, - }), - make({ - id: WORKSTATION_BLOCKED_TOOL_SESSION_ID, - name: '工具调用失败', - status: 'blocked', - blockedReason: 'tool_failed', - lastMessageOffset: 240_000, - }), - make({ - id: WORKSTATION_BLOCKED_UNKNOWN_SESSION_ID, - name: '运行中断', - status: 'blocked', - blockedReason: 'unknown', - lastMessageOffset: 300_000, - }), - make({ id: WORKSTATION_ACTIVE_SESSION_ID, name: '可继续的会话', status: 'active', lastMessageOffset: 360_000 }), - make({ id: WORKSTATION_REVIEW_SESSION_ID, name: '待审核的长任务输出', status: 'review', lastMessageOffset: 420_000 }), - make({ id: WORKSTATION_DONE_SESSION_ID, name: '完成并已审核', status: 'done', lastMessageOffset: 480_000 }), - make({ - id: WORKSTATION_ARCHIVED_SESSION_ID, - name: '归档的旧会话', - status: 'archived', - isArchived: true, - lastMessageOffset: 7 * 24 * 60 * 60 * 1000, - }), - // @kenji PR109b review: aborted must be visible (collapsed group). - // Seed one so the fixture covers the dormant-but-visible state. - make({ - id: WORKSTATION_ABORTED_SESSION_ID, - name: '已中止的会话', - status: 'aborted', - lastMessageOffset: 14 * 24 * 60 * 60 * 1000, - }), - ]; -} - -/** - * PR109f (g) turn-control-history fixture seed. Returns three sessions - * sharing the same on-disk state: - * - * - `primary` — full turn list covering completed / aborted / failed + - * retry pair + regenerate pair. Used to verify the lineage badges - * (forward + reverse), aborted "(已中断)" marker, and failed-turn - * generalized Chinese banner copy. - * - `branch-visible` — parentSessionId points to primary, so the chat - * header should render "分自 ${primary.name}" when this session is - * active. - * - `branch-orphan` — parentSessionId points to a session id that is - * NOT seeded; renderer's `deriveBranchBanner()` returns undefined - * and no banner is rendered (negative screenshot case). - * - * The three are interchangeable for screenshot purposes — only the - * active session selection in `applyScenarioOverrides` decides which - * one is rendered in the chat surface. - */ -function turnControlSessions(now: number): Array<{ header: SessionHeader; messages: StoredMessage[] }> { - const primaryHeader = header({ - id: TURN_CONTROL_PRIMARY_SESSION_ID, - name: '回合控制示例(原会话)', - connection: 'zai-live', - model: 'glm-5.1', - now, - lastMessageAt: now - 2 * 60_000, - status: 'active', - }); - - const branchVisibleHeader = header({ - id: TURN_CONTROL_BRANCH_VISIBLE_SESSION_ID, - name: '从原会话分出的探索', - connection: 'zai-live', - model: 'glm-5.1', - now, - lastMessageAt: now - 60_000, - status: 'active', - }); - branchVisibleHeader.parentSessionId = TURN_CONTROL_PRIMARY_SESSION_ID; - branchVisibleHeader.branchOfTurnId = 'turn-retry-origin'; - - const branchOrphanHeader = header({ - id: TURN_CONTROL_BRANCH_ORPHAN_SESSION_ID, - name: '父会话已删除的分支', - connection: 'zai-live', - model: 'glm-5.1', - now, - lastMessageAt: now - 30_000, - status: 'active', - }); - // Intentionally references a session id never written to disk so the - // renderer must render no banner (negative screenshot case). - branchOrphanHeader.parentSessionId = TURN_CONTROL_ORPHAN_PARENT_ID; - branchOrphanHeader.branchOfTurnId = 'turn-deleted-origin'; - - return [ - { header: primaryHeader, messages: turnControlPrimaryMessages(now) }, - { header: branchVisibleHeader, messages: turnControlBranchMessages(now, 'visible') }, - { header: branchOrphanHeader, messages: turnControlBranchMessages(now, 'orphan') }, - ]; -} - -/** - * Primary-session message log covering every turn-control surface in - * one fixture. The turn IDs are short, human-readable strings so the - * lineage-badge copy (e.g. "重新生成自 turn turn-ret") stays stable across - * regenerations. - * - * Turns: - * 1. `turn-baseline` — user+assistant, completed - * 2. `turn-aborted` — user+assistant (partial)+turn_state(aborted) - * 3. `turn-retry-origin` — user+assistant, completed (origin of retry) - * 4. `turn-retry-new` — user+assistant, completed; retriedFromTurnId = origin - * 5. `turn-regen-origin` — user+assistant, completed (origin of regenerate) - * 6. `turn-regen-new` — user+assistant, completed; regeneratedFromTurnId = origin - * 7. `turn-failed` — user+assistant (partial)+turn_state(failed, errorClass='timeout') - * - * Note: turn_state messages are appended last in each turn bucket so - * `deriveTurnRecords()` reads the final status correctly. - */ -function turnControlPrimaryMessages(now: number): StoredMessage[] { - const messages: StoredMessage[] = []; - let cursor = now - 60 * 60_000; // start an hour ago, walk forward - - const tickUser = 10_000; - const tickAssistant = 15_000; - const tickState = 1_000; - - // 1. completed baseline - cursor += tickUser; - messages.push({ - type: 'user', - id: 'msg-baseline-user', - turnId: 'turn-baseline', - ts: cursor, - text: '帮我看一下当前回合状态截图覆盖了哪些情况。', - }); - cursor += tickAssistant; - messages.push({ - type: 'assistant', - id: 'msg-baseline-assistant', - turnId: 'turn-baseline', - ts: cursor, - text: '当前展示的是已完成的基础回合,可作为截图基线。', - modelId: 'glm-5.1', - }); - cursor += tickState; - messages.push({ - type: 'turn_state', - id: 'state-baseline', - turnId: 'turn-baseline', - ts: cursor, - status: 'completed', - partialOutputRetained: true, - }); - - // 2. aborted (partial assistant text + turn_state=aborted) - cursor += tickUser; - messages.push({ - type: 'user', - id: 'msg-aborted-user', - turnId: 'turn-aborted', - ts: cursor, - text: '执行一个长任务但提前中止。', - }); - cursor += tickAssistant; - messages.push({ - type: 'assistant', - id: 'msg-aborted-assistant', - turnId: 'turn-aborted', - ts: cursor, - text: '正在分析项目结构……', - modelId: 'glm-5.1', - }); - cursor += tickState; - messages.push({ - type: 'turn_state', - id: 'state-aborted', - turnId: 'turn-aborted', - ts: cursor, - status: 'aborted', - abortedAt: cursor, - partialOutputRetained: true, - }); - - // 3. retry origin - cursor += tickUser; - messages.push({ - type: 'user', - id: 'msg-retry-origin-user', - turnId: 'turn-retry-origin', - ts: cursor, - text: '生成一份初稿。', - }); - cursor += tickAssistant; - messages.push({ - type: 'assistant', - id: 'msg-retry-origin-assistant', - turnId: 'turn-retry-origin', - ts: cursor, - text: '初稿 v1。', - modelId: 'glm-5.1', - }); - cursor += tickState; - messages.push({ - type: 'turn_state', - id: 'state-retry-origin', - turnId: 'turn-retry-origin', - ts: cursor, - status: 'completed', - partialOutputRetained: true, - }); - - // 4. retry new (forward "重新生成自 turn-retry-origin" + reverse "已重新生成 → turn-retry-new") - cursor += tickUser; - messages.push({ - type: 'user', - id: 'msg-retry-new-user', - turnId: 'turn-retry-new', - ts: cursor, - text: '再生成一遍。', - }); - cursor += tickAssistant; - messages.push({ - type: 'assistant', - id: 'msg-retry-new-assistant', - turnId: 'turn-retry-new', - ts: cursor, - text: '初稿 v2,包含修订建议。', - modelId: 'glm-5.1', - }); - cursor += tickState; - messages.push({ - type: 'turn_state', - id: 'state-retry-new', - turnId: 'turn-retry-new', - ts: cursor, - status: 'completed', - retriedFromTurnId: 'turn-retry-origin', - partialOutputRetained: true, - }); - - // 5. regenerate origin - cursor += tickUser; - messages.push({ - type: 'user', - id: 'msg-regen-origin-user', - turnId: 'turn-regen-origin', - ts: cursor, - text: '换个角度回答。', - }); - cursor += tickAssistant; - messages.push({ - type: 'assistant', - id: 'msg-regen-origin-assistant', - turnId: 'turn-regen-origin', - ts: cursor, - text: '答案 A(保留供对比)。', - modelId: 'glm-5.1', - }); - cursor += tickState; - messages.push({ - type: 'turn_state', - id: 'state-regen-origin', - turnId: 'turn-regen-origin', - ts: cursor, - status: 'completed', - partialOutputRetained: true, - }); - - // 6. regenerate new - cursor += tickUser; - messages.push({ - type: 'user', - id: 'msg-regen-new-user', - turnId: 'turn-regen-new', - ts: cursor, - text: '再生成一个并行回答。', - }); - cursor += tickAssistant; - messages.push({ - type: 'assistant', - id: 'msg-regen-new-assistant', - turnId: 'turn-regen-new', - ts: cursor, - text: '答案 B(与答案 A 并列)。', - modelId: 'glm-5.1', - }); - cursor += tickState; - messages.push({ - type: 'turn_state', - id: 'state-regen-new', - turnId: 'turn-regen-new', - ts: cursor, - status: 'completed', - regeneratedFromTurnId: 'turn-regen-origin', - partialOutputRetained: true, - }); - - // 7. failed (errorClass='timeout' → generalized copy "请求超时") - cursor += tickUser; - messages.push({ - type: 'user', - id: 'msg-failed-user', - turnId: 'turn-failed', - ts: cursor, - text: '运行一个长查询。', - }); - cursor += tickAssistant; - messages.push({ - type: 'assistant', - id: 'msg-failed-assistant', - turnId: 'turn-failed', - ts: cursor, - text: '开始查询数据……', - modelId: 'glm-5.1', - }); - cursor += tickState; - messages.push({ - type: 'turn_state', - id: 'state-failed', - turnId: 'turn-failed', - ts: cursor, - status: 'failed', - errorClass: 'timeout', - partialOutputRetained: true, - }); - - return messages; -} - -/** - * Minimal message log for branch sessions. Branches start with a - * single completed turn so the chat surface has visible content, but - * we don't reproduce every parent turn (that would defeat the point of - * the screenshot — banner-vs-no-banner is the contract under test). - */ -function turnControlBranchMessages(now: number, kind: 'visible' | 'orphan'): StoredMessage[] { - const turnId = `turn-${kind}-branch`; - const userText = kind === 'visible' - ? '在分支会话里继续这条思路。' - : '父会话已经被删除,但分支自身还在。'; - const assistantText = kind === 'visible' - ? '已切到分支会话。点击顶部 banner 可以跳回原会话。' - : '分支保留了本地内容,但跳回链接已失效。'; - return [ - { - type: 'user', - id: `msg-${kind}-user`, - turnId, - ts: now - 2 * 60_000, - text: userText, - }, - { - type: 'assistant', - id: `msg-${kind}-assistant`, - turnId, - ts: now - 90_000, - text: assistantText, - modelId: 'glm-5.1', - }, - { - type: 'turn_state', - id: `state-${kind}-branch`, - turnId, - ts: now - 89_000, - status: 'completed', - partialOutputRetained: true, - }, - ]; -} - -/** - * PR-SIDEBAR-IA-0 Phase 1: long sidebar fixture. - * - * Seeds `LONG_SIDEBAR_SESSION_COUNT` (60) sessions so the sidebar - * scroll fix is verifiable end-to-end: - * - * - In a narrow window, the list must scroll without pushing the - * footer (Settings + Version info) off-screen. - * - The inner `.maka-list-stack` scroll container must engage. - * - The fixture is deterministic: titles only differ by index; - * timestamps walk backwards from `now` so the FIRST session - * (`...-00`) is the newest and gets sorted to the top. - * - * Each session contains a single short user/assistant exchange so - * the message file is well-formed but visually inert. The screenshot - * baseline focuses on the sidebar, not the chat surface. - */ -function longSidebarSessions(now: number): Array<{ header: SessionHeader; messages: StoredMessage[] }> { - const seeds: Array<{ header: SessionHeader; messages: StoredMessage[] }> = []; - for (let i = 0; i < LONG_SIDEBAR_SESSION_COUNT; i++) { - const idSuffix = String(i).padStart(2, '0'); - const sessionId = LONG_SIDEBAR_SESSION_PREFIX + idSuffix; - // First session is newest; subsequent walk backwards in 5-minute - // increments so the sort is stable and predictable. - const lastMessageAt = now - i * 5 * 60_000; - const sessionHeader = header({ - id: sessionId, - name: '会话 ' + idSuffix, - connection: 'zai-live', - model: 'glm-5.1', - now, - lastMessageAt, - status: 'active', - }); - const userTs = lastMessageAt - 30_000; - const assistantTs = lastMessageAt; - const messages: StoredMessage[] = [ - { - type: 'user', - id: 'msg-long-user-' + idSuffix, - turnId: 'turn-long-' + idSuffix, - ts: userTs, - text: '示例对话 ' + idSuffix, - }, - { - type: 'assistant', - id: 'msg-long-assistant-' + idSuffix, - turnId: 'turn-long-' + idSuffix, - ts: assistantTs, - text: '已把第 ' + idSuffix + ' 条研究记录归档到当前工作流,侧边栏应保持稳定滚动位置。', - modelId: 'glm-5.1', - }, - ]; - seeds.push({ header: sessionHeader, messages }); - } - return seeds; -} - -function header(input: { - id: string; - name: string; - connection: string; - model: string; - now: number; - lastMessageAt: number; - hasUnread?: boolean; - /** - * Override default `backend: 'ai-sdk'`. Used by stale-sessions fixture - * to seed FakeBackend + legacy backend kinds. SessionHeader's BackendKind - * union allows widening via `as unknown` for legacy values like - * 'claude' that no longer exist in the type. - */ - backend?: SessionHeader['backend'] | 'claude'; - connectionLocked?: boolean; - /** - * PR109b workstation-statuses fixture: override default - * `status: 'active'` so seeded sessions land in every status group. - */ - status?: SessionHeader['status']; - blockedReason?: SessionHeader['blockedReason']; - isArchived?: boolean; - isFlagged?: boolean; -}): SessionHeader { - return { - id: input.id, - workspaceRoot: 'visual-smoke', - cwd: '/workspace/maka', - createdAt: input.now - 3_600_000, - lastUsedAt: input.lastMessageAt, - lastMessageAt: input.lastMessageAt, - name: input.name, - titleIsManual: true, - isFlagged: input.isFlagged ?? false, - labels: [], - isArchived: input.isArchived ?? false, - status: input.status ?? 'active', - ...(input.blockedReason ? { blockedReason: input.blockedReason } : {}), - statusUpdatedAt: input.lastMessageAt, - hasUnread: input.hasUnread ?? false, - // Legacy backend kinds like 'claude' aren't in the current BackendKind - // union but are needed for the stale-sessions reproduction. Forward - // the value verbatim into the JSONL so the renderer sees exactly what - // a real legacy workspace would have on disk. - backend: (input.backend ?? 'ai-sdk') as SessionHeader['backend'], - llmConnectionSlug: input.connection, - connectionLocked: input.connectionLocked ?? true, - model: input.model, - permissionMode: 'ask', - schemaVersion: 1, - }; -} - -// Stale-sessions fixture seeds three sessions reproducing the on-disk -// state that triggered the P0 (WAWQAQ workspace had `fake-claude` + -// `backend=fake` sessions sitting next to a healthy `zai-coding-plan` -// one). Locks the @kenji active-stale pill gate (active session is -// intentionally one of the stale ones). -function staleFakeSession(now: number): SessionHeader { - return header({ - id: STALE_FAKE_SESSION_ID, - name: '旧的本地模拟会话', - connection: 'fake', - model: 'fake-model', - now, - lastMessageAt: now - 4 * 24 * 3_600_000, - backend: 'fake', - connectionLocked: false, - }); -} - -function staleLegacySession(now: number): SessionHeader { - return header({ - id: STALE_LEGACY_SESSION_ID, - name: '旧的 Claude 连接会话', - connection: 'fake-claude', - model: 'claude-3-sonnet', - now, - lastMessageAt: now - 7 * 24 * 3_600_000, - backend: 'claude' as SessionHeader['backend'], - connectionLocked: true, - }); -} - -function healthySession(now: number): SessionHeader { - return header({ - id: HEALTHY_SESSION_ID, - name: '正常会话(Z.ai Live)', - connection: 'zai-live', - model: 'glm-5.1', - now, - lastMessageAt: now - 12 * 60_000, - backend: 'ai-sdk', - }); -} - -function staleFakeMessages(now: number): StoredMessage[] { - const turnId = 'stale-fake-turn-1'; - return [ - { - type: 'user', - id: 'stale-fake-msg-1', - turnId, - ts: now - 4 * 24 * 3_600_000, - text: '这是旧的本地模拟会话,发送时应该会切换到当前默认连接。', - }, - { - type: 'assistant', - id: 'stale-fake-msg-2', - turnId, - ts: now - 4 * 24 * 3_600_000 + 2_000, - text: '这是旧的本地模拟会话留下的回复文本。', - modelId: 'fake-model', - }, - ]; -} - -function staleLegacyMessages(now: number): StoredMessage[] { - const turnId = 'stale-legacy-turn-1'; - return [ - { - type: 'user', - id: 'stale-legacy-msg-1', - turnId, - ts: now - 7 * 24 * 3_600_000, - text: '这是历史 Claude 连接留下的会话。原连接 fake-claude 已不在连接列表里。', - }, - { - type: 'assistant', - id: 'stale-legacy-msg-2', - turnId, - ts: now - 7 * 24 * 3_600_000 + 3_000, - text: '这条历史会话需要切换到当前可用模型后才能继续发送。', - modelId: 'claude-3-sonnet', - }, - ]; -} - -function healthyMessages(now: number): StoredMessage[] { - const turnId = 'healthy-turn-1'; - return [ - { - type: 'user', - id: 'healthy-msg-1', - turnId, - ts: now - 12 * 60_000, - text: '这是正常的 ai-sdk + zai-live 会话,sidebar 应该没有 "已过期" pill。', - }, - { - type: 'assistant', - id: 'healthy-msg-2', - turnId, - ts: now - 12 * 60_000 + 1_500, - text: '当前连接健康,后续发送会继续使用这个会话固定的 GLM 模型。', - modelId: 'glm-5.1', - }, - ]; -} - -async function writeSession(workspaceRoot: string, session: SessionHeader, messages: StoredMessage[]): Promise { - const dir = join(workspaceRoot, 'sessions', session.id); - await mkdir(dir, { recursive: true }); - await writeFile( - join(dir, 'session.jsonl'), - [session, ...messages].map((entry) => JSON.stringify(entry)).join('\n') + '\n', - 'utf8', - ); -} - -async function writeArtifacts(workspaceRoot: string, now: number, scenario: VisualSmokeScenario): Promise { - const root = join(workspaceRoot, 'artifacts'); - // PR-UI-RENDER-3a-smoke: dedicated preview scenarios get their - // own short artifact list (single artifact each) so the - // ArtifactPane default selection deterministically picks the one - // the screenshot is meant to capture. The `sizeBytesOverride` - // field bypasses the post-write `stat().size` overwrite so we - // can claim 3MB in metadata without writing 3MB to disk. - type ArtifactSpec = { - id: string; - name: string; - kind: ArtifactRecord['kind']; - mimeType?: string; - content: string | Uint8Array; - status?: ArtifactRecord['status']; - skipFile?: boolean; - /** - * @kenji review @msg fc9753b9 oversize fixture: when this is - * set, the post-write `stat().size` is NOT used to overwrite - * the recorded `sizeBytes`. Lets us seed a 3MB-claim artifact - * without consuming 3MB of disk in the test workspace. - * Only valid alongside `skipFile: true` to avoid metadata/file - * drift. - */ - sizeBytesOverride?: number; - }; - // 1x1 transparent PNG (67 bytes). Smallest valid PNG that - // `readBinary` will sniff back as `image/png`. Used by - // `artifact-preview-image` to exercise the registry's happy path. - const tinyPngBytes = Uint8Array.from([ - 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, - 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, - 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, - 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, - 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, - 0x54, 0x78, 0x9c, 0x62, 0x00, 0x01, 0x00, 0x00, - 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, - 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, - 0x42, 0x60, 0x82, - ]); - - // PR-UI-RENDER-3a-smoke: dedicated single-artifact specs per - // scenario. Returning early keeps these scenarios from inheriting - // the standard html/diff/notes list (which would shuffle the - // default selection). - if (scenario === 'artifact-preview-image') { - await writeArtifactSpecs(root, now, [ - { - id: 'artifact-preview-image', - name: 'screenshot.png', - kind: 'image', - mimeType: 'image/png', - content: tinyPngBytes, - }, - ]); - return; - } - if (scenario === 'artifact-preview-unsupported') { - await writeArtifactSpecs(root, now, [ - { - id: 'artifact-preview-unsupported', - name: 'portrait.heic', - // kind: 'image' makes the resolver enter the image branch; - // image/heic is NOT in the allowlist so L1 returns - // `unsupported(mime_disallowed)`. readBinary is NEVER called - // for this scenario. - kind: 'image', - mimeType: 'image/heic', - content: Uint8Array.from([0x00, 0x00, 0x00, 0x20, 0x66, 0x74, 0x79, 0x70]), - }, - ]); - return; - } - if (scenario === 'artifact-preview-oversize') { - await writeArtifactSpecs(root, now, [ - { - id: 'artifact-preview-oversize', - name: 'huge.png', - kind: 'image', - mimeType: 'image/png', - // 3MB claim — past the 2MB cap. L1 resolver rejects via - // sizeBytes before readBinary is even attempted; the - // is what - // renders. We skip the file so the test workspace stays - // small (and so a stat overwrite doesn't undo our claim). - content: Uint8Array.from([]), - skipFile: true, - sizeBytesOverride: 3 * 1024 * 1024, - }, - ]); - return; - } - const specs: Array = [ - { - id: 'artifact-report', - name: 'report.html', - kind: 'html' as const, - mimeType: 'text/html', - content: [ - '', - '', - '', - 'Maka 生成文件自检报告', - '', - '

    生成文件面板自检报告

    ', - '

    这个 HTML 生成文件用于验证 sandboxed iframe view-only 预览。

    ', - '

    外部链接应被禁用

    ', - '', - '', - ].join('\n'), - }, - { - id: 'artifact-patch', - name: 'patch.diff', - kind: 'diff' as const, - mimeType: 'text/x-diff', - content: [ - 'diff --git a/apps/desktop/src/renderer/ArtifactPane.tsx b/apps/desktop/src/renderer/ArtifactPane.tsx', - 'new file mode 100644', - '--- /dev/null', - '+++ b/apps/desktop/src/renderer/ArtifactPane.tsx', - '@@ -0,0 +1,4 @@', - '+export function ArtifactPane() {', - '+ return