feat(ui): rebuild desktop renderer on a shared trace graph and design tokens
Replace the full-innerHTML render loop with a static shell plus per-region updates, so composer drafts, fold state, focus, and scroll survive streaming turns. Fold session events into one trace graph consumed by Chat, Trajectory, Waterfall, and the shared inspector drawer, with live ACP updates patched into a keyed live-turn region. Align the visual system with a tokenized design spec: a 4px spacing base with fixed control/row height steps, foreground-derived text tiers and borders (color-mix), neutral interaction overlays, tiered motion durations with a reduced-motion collapse, hover-revealed scrollbars, and drawer-aware layout elasticity. Localize trajectory role chips and row previews. The renderer entry (app.ts) joins the coverage exclude list as a self-executing DOM bootstrap: jsdom lifecycle specs exercise its behavior, and extractable logic lives in covered modules (trace-graph.ts, renderer-content.ts).
This commit is contained in:
@@ -50,13 +50,13 @@ The app is repository-bound. On startup it treats the package root as the Harnes
|
||||
|
||||
## Main surfaces
|
||||
|
||||
`Sessions/Runs` is the left navigation. It lists new and persisted runs, supports search, opens or resumes a session, pins a baseline, and starts replay from a historical prompt or turn.
|
||||
`Sessions` is the left navigation. It lists and searches live or persisted sessions, groups child sessions under their parent, opens or resumes a session, and can reveal the selected JSONL in Finder. Baseline pinning and replay remain part of the product shape below rather than shipped controls.
|
||||
|
||||
`Chat` is the reading and driving surface. It renders user messages, assistant text, and lightweight collapsed `Thinking` and `Tool use` rows. Clicking a message or activity opens the inspector. The composer is always available for the selected live session.
|
||||
`Chat` is the reading and driving surface. One user turn has one assistant response shell containing ordered, independently selectable `Thinking`, paired `Tool`, and visible text blocks. Clicking a block opens its inspector target; the block's trailing arrow only expands or collapses its inline preview. The composer is always available for the selected live session.
|
||||
|
||||
`Trajectory` is the structural surface. It is a mixed navigation view over `session -> turn -> step -> request / assistant / tool / context` with status, duration, token counts, tool names, errors, and short previews. It must not expand full raw payloads, complete system prompts, full tool schemas, or raw chunk streams inline. Clicking any node opens the inspector.
|
||||
`Trajectory` is the structural surface. A structure tree (turn -> step with durations, tool summaries, and error marks) navigates a step-grouped logical-object table; lifecycle events become tree nodes and sticky group headers instead of rows. A model response owns its effective request input and assistant output. A Tool row pairs `tool/call` with `tool/result`, so one row and inspector target own both Input and Output. Clicking the row opens the inspector; the trailing arrow only expands the inline payload.
|
||||
|
||||
`Waterfall` is the time surface. It answers where latency went across turns, steps, model calls, tool calls, background work, and failures. It contains labels, timings, and critical-path cues only. Clicking a bar opens the inspector.
|
||||
`Waterfall` is the time surface. It answers where latency went across turns, steps, model calls, tool calls, and failures: a summary strip (total / LLM time / tool time / errors / slowest step / tokens) over an aligned time track. Clicking any bar, label, or stat keeps Waterfall active and opens the shared inspector; the inspector's explicit `→ Trajectory` action performs cross-view navigation.
|
||||
|
||||
`Context` is the request-anatomy surface. It answers what the model saw at a selected request boundary: config, system prompt, session prefix, derived conversation surface, injected `context/message` entries, compaction summaries, visible tools, and request-header deltas. It may show section summaries and bounded previews, but the full raw data still belongs in the inspector.
|
||||
|
||||
@@ -67,13 +67,13 @@ The app is repository-bound. On startup it treats the package root as the Harnes
|
||||
| Surface | Primary job | Inline content | Inspector trigger |
|
||||
|---|---|---|---|
|
||||
| `Chat` | Drive and read the conversation | User messages, assistant messages, collapsed thinking rows, collapsed tool-use rows | Message or activity click |
|
||||
| `Trajectory` | Navigate run structure | `session -> turn -> step -> request / assistant / tool / context`, statuses, counts, durations, short previews | Any node click |
|
||||
| `Trajectory` | Navigate run structure | Turn/step tree plus User / Thinking / Model / paired Tool / Context logical records, statuses, durations, short previews | Logical row click |
|
||||
| `Waterfall` | Diagnose latency | Spans, critical path, status, start/duration | Bar click |
|
||||
| `Context` | Explain request anatomy | Config summary, system summary, prefix summary, derived-history summary, context-message summary, tool-schema list summary, deltas | Section click |
|
||||
| `Compare` | Compare two run artifacts | Baseline/candidate diffs for output, context, tools, events, usage, duration, errors | Diff hunk click |
|
||||
| `Dev` | Support repo-bound modification loop | Runtime state, dirty state, watched paths, suggested agent prompts, restart-needed flag | Config/plugin/path click |
|
||||
|
||||
Only `Chat` owns the composer. The other middle surfaces are inspection modes over the selected run or selected request boundary.
|
||||
The session shell keeps one composer mounted while `Chat`, `Trajectory`, and `Waterfall` switch in the middle pane, so a developer can continue the selected live session without losing a draft or leaving an inspection view. Non-session modules do not show it.
|
||||
|
||||
## Information ownership
|
||||
|
||||
@@ -81,7 +81,7 @@ Middle surfaces locate and explain. The inspector preserves the complete facts.
|
||||
|
||||
`Trajectory` and `Context` should not duplicate inspector responsibilities. Trajectory shows where the user is in the run. Context shows which context sources contributed to a request. Inspector shows the selected object's complete input, output, metadata, and feedback.
|
||||
|
||||
The same selected object can be entered from multiple surfaces. A `tool/call` selected from Trajectory, a `Tool use` row selected from Chat, or a tool segment selected from Waterfall should resolve to one inspector target. This keeps feedback and copying attached to the event, not to the view that opened it.
|
||||
The same selected object can be entered from multiple surfaces. A paired Tool selected from Trajectory, a Tool row selected from Chat, or a tool segment selected from Waterfall resolves to the same `tool:<callId>` target. Thinking uses `reasoning:<turn>:<step>` and model output uses `assistant:<seq>`. This keeps Input, Output, feedback, selection, and copying attached to the logical object, not to the view that opened it.
|
||||
|
||||
### Why Trajectory still needs Inspector
|
||||
|
||||
@@ -92,7 +92,7 @@ Trajectory answers orientation questions:
|
||||
- Did the run fail, cancel, or continue?
|
||||
- What is the rough shape of this step before I inspect raw data?
|
||||
|
||||
Trajectory should not expand full request headers, full tool schemas, full system prompts, or raw chunk streams inline because that turns the navigator into a raw JSON viewer. Instead, every row has a stable target id. Clicking a row opens Inspector, where full `Input`, `Output`, `Metadata`, and `Feedback` are available.
|
||||
Inline expansion answers those questions in place, but every row resolves to the same logical target used by Chat and Waterfall. `Input`/`Output`/`Metadata` format switching and `Feedback` history stay attached to that target, and the drawer stays open while switching between `Chat`, `Trajectory`, and `Waterfall`.
|
||||
|
||||
### Why Context still needs Inspector
|
||||
|
||||
@@ -190,5 +190,5 @@ Start with a desktop package that defines shared UI contracts, then build the El
|
||||
- **Development build only** — this package ships a usable Electron/Vite app and a real ACP subprocess bridge, but it is not yet packaged as a signed distributable.
|
||||
- **ACP is the first runtime channel** — direct in-process embedding could make context queries and restarts richer, but would make isolation, teardown, and hot reload harder.
|
||||
- **Develop is read-first** — it exposes prompts, tools, plugins, config, runtime state, and the change loop as a source browser; direct graphical plugin/config editing is deferred.
|
||||
- **Trace refresh is mixed live/persisted** — chat streams from ACP live updates, while Trajectory and Waterfall currently read persisted JSONL after turns complete.
|
||||
- **Compare and replay remain skeletal** — the product contract is documented, but semantic evaluation and dataset-level analysis belong to later work.
|
||||
- **Trace refresh is mixed live/persisted** — chat streams from ACP live updates (rendered incrementally, so composer input, fold state, and scroll survive streaming), while Trajectory and Waterfall read persisted JSONL after turns complete.
|
||||
- **Context and Compare surfaces are unimplemented** — the session view ships `Chat`, `Trajectory`, and `Waterfall`; the `Context`/`Compare` contracts above and replay remain documented product shape for later work.
|
||||
@@ -8,11 +8,16 @@ const packageRoot = resolve(here, '..')
|
||||
const repoRoot = resolve(packageRoot, '../../..')
|
||||
const viteUrl = process.env.DSH_DESKTOP_VITE_URL ?? 'http://127.0.0.1:5174'
|
||||
|
||||
const vite = spawn(resolve(repoRoot, 'node_modules/.bin/vite'), [
|
||||
const packageBin = resolve(packageRoot, 'node_modules/.bin')
|
||||
|
||||
const vite = spawn(resolve(packageBin, 'vite'), [
|
||||
'--host',
|
||||
'127.0.0.1',
|
||||
'--port',
|
||||
'5174',
|
||||
// Fail fast instead of silently moving ports: Electron loads the URL below,
|
||||
// so a relocated Vite would leave the window pointing at a stale instance.
|
||||
'--strictPort',
|
||||
], {
|
||||
cwd: packageRoot,
|
||||
env: { ...process.env },
|
||||
@@ -25,7 +30,7 @@ let electron
|
||||
const startElectron = () => {
|
||||
if (electronStarted) return
|
||||
electronStarted = true
|
||||
electron = spawn(resolve(repoRoot, 'node_modules/.bin/electron'), ['src/main.mjs'], {
|
||||
electron = spawn(resolve(packageBin, 'electron'), ['src/main.mjs'], {
|
||||
cwd: packageRoot,
|
||||
env: { ...process.env, VITE_DEV_SERVER_URL: viteUrl },
|
||||
stdio: 'inherit',
|
||||
@@ -48,8 +53,6 @@ vite.on('exit', (code, signal) => {
|
||||
if (!electronStarted) process.exit(code ?? (signal === null ? 0 : 1))
|
||||
})
|
||||
|
||||
setTimeout(startElectron, 2500)
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
electron?.kill('SIGINT')
|
||||
vite.kill('SIGINT')
|
||||
|
||||
+1986
-1432
File diff suppressed because it is too large
Load Diff
Vendored
+1
@@ -17,6 +17,7 @@ declare global {
|
||||
load(sessionId: string): Promise<unknown>
|
||||
prompt(sessionId: string, text: string): Promise<unknown>
|
||||
cancel(sessionId: string): Promise<unknown>
|
||||
reveal(sessionId: string): Promise<unknown>
|
||||
onUpdate(callback: (payload: unknown) => void): () => void
|
||||
}
|
||||
trace: {
|
||||
|
||||
+407
-115
@@ -5,128 +5,274 @@ export type Locale = 'zh-CN' | 'en-US'
|
||||
const messages = {
|
||||
'zh-CN': {
|
||||
'app.newChat': '新对话',
|
||||
'app.sessions': 'Sessions',
|
||||
'app.sessionsSubtitle': 'Chat + trace in one place',
|
||||
'app.develop': 'Develop',
|
||||
'app.developSubtitle': 'Prompt、工具、插件、运行时',
|
||||
'app.sessions': '会话',
|
||||
'app.sessionsSubtitle': '聊天和轨迹放在一起',
|
||||
'app.develop': '开发',
|
||||
'app.developSubtitle': '提示词、工具、插件、运行时',
|
||||
'app.searchPlaceholder': '搜索标题、id、模型',
|
||||
'app.recentSessions': '最近 sessions',
|
||||
'app.emptySessions': '没有匹配的 sessions',
|
||||
'app.recentSessions': '最近会话',
|
||||
'app.emptySessions': '没有匹配的会话',
|
||||
'app.language': 'EN',
|
||||
'app.revealSession': '在 Finder 中显示',
|
||||
'app.subagents': '子会话',
|
||||
'app.errorTitle': '错误',
|
||||
'app.repo': 'Repo',
|
||||
'app.branch': 'Branch',
|
||||
'app.commit': 'Commit',
|
||||
'app.dirty': 'Dirty',
|
||||
'app.repo': '仓库',
|
||||
'app.branch': '分支',
|
||||
'app.commit': '提交',
|
||||
'app.dirty': '未提交变更',
|
||||
'app.acp': 'ACP',
|
||||
'app.restartNeeded': 'Restart needed',
|
||||
'surface.chat': 'Chat',
|
||||
'surface.trajectory': 'Trajectory',
|
||||
'surface.waterfall': 'Waterfall',
|
||||
'surface.context': 'Context',
|
||||
'surface.compare': 'Compare',
|
||||
'surface.dev': 'Dev',
|
||||
'app.restartNeeded': '需要重启',
|
||||
'app.liveAcpSession': '实时 ACP 会话',
|
||||
'app.sessionRoot': '会话根节点',
|
||||
'app.sessionLabel': '会话',
|
||||
'app.liveAcpUpdate': '实时 ACP 更新',
|
||||
'common.chars': '字符',
|
||||
'common.seq': '序号',
|
||||
'common.now': '刚刚',
|
||||
'common.minutesAgo': '分钟',
|
||||
'common.hoursAgo': '小时',
|
||||
'common.daysAgo': '天',
|
||||
'runtime.starting': '启动中',
|
||||
'runtime.running': '运行中',
|
||||
'runtime.error': '错误',
|
||||
'surface.chat': '聊天',
|
||||
'surface.trajectory': '轨迹',
|
||||
'surface.waterfall': '瀑布',
|
||||
'surface.context': '上下文',
|
||||
'surface.compare': '对比',
|
||||
'surface.dev': '开发',
|
||||
'metric.turns': '轮次',
|
||||
'metric.steps': '步骤',
|
||||
'metric.tools': '工具',
|
||||
'metric.events': '事件',
|
||||
'chat.details': '详情',
|
||||
'chat.thinking': 'Thinking',
|
||||
'chat.toolUse': 'Tool use',
|
||||
'chat.toolFailed': 'Tool failed',
|
||||
'chat.input': 'Input',
|
||||
'chat.output': 'Output',
|
||||
'chat.errorOutput': 'Error output',
|
||||
'chat.thinking': '思考',
|
||||
'chat.toolUse': '工具调用',
|
||||
'chat.toolFailed': '工具失败',
|
||||
'chat.input': '输入',
|
||||
'chat.output': '输出',
|
||||
'chat.errorOutput': '错误输出',
|
||||
'chat.openInspector': '在检查器中打开',
|
||||
'chat.spawnedSessions': '派生会话',
|
||||
'chat.emptyTitle': '还没有消息',
|
||||
'chat.emptyBody': '从底部输入框发出一条消息。Thinking 和 tool use 会默认折叠,但可以随时展开。',
|
||||
'chat.emptyBody': '从底部输入框发出一条消息。思考和工具调用会默认折叠,但可以随时展开。',
|
||||
'chat.newTitle': '新对话',
|
||||
'chat.newBody': '先输入一句话。发送后才会创建真实 ACP session,并在左侧出现记录。',
|
||||
'chat.startTitle': '开始一个 Deepseek Harness session',
|
||||
'chat.startBody': '点击 New chat 只会打开草稿;真正发送第一句话后,才会创建后端 session。',
|
||||
'chat.user': 'User',
|
||||
'chat.assistant': 'Assistant',
|
||||
'chat.userMessage': 'User message',
|
||||
'chat.assistantMessage': 'Assistant message',
|
||||
'trace.title': 'Trajectory',
|
||||
'trace.body': '按 session / turn / step / request / tool 组织。展开节点可以直接看关键 prompt、schema、input/output。',
|
||||
'trace.systemPrompt': 'System prompt',
|
||||
'trace.toolSchemas': 'Tool schemas',
|
||||
'trace.configPrefix': 'Config and message prefix',
|
||||
'trace.metadata': 'Metadata',
|
||||
'trace.noSystem': '没有记录 system prompt。',
|
||||
'waterfall.title': 'Waterfall',
|
||||
'waterfall.body': '从耗时角度定位慢点、工具等待和错误,再跳回 Trajectory 看细节。',
|
||||
'waterfall.total': 'total',
|
||||
'waterfall.turns': 'turns',
|
||||
'waterfall.steps': 'steps',
|
||||
'waterfall.tools': 'tools',
|
||||
'waterfall.slowest': 'slowest',
|
||||
'waterfall.errors': 'errors',
|
||||
'context.title': 'Context',
|
||||
'context.body': '这是开发分析视图:它解释模型请求边界里真正进入上下文的内容,用来改 prompt、tool schema 和 config。',
|
||||
'context.systemPrompt': 'System prompt',
|
||||
'context.toolSchemas': 'Tool schemas',
|
||||
'context.callConfig': 'Call config',
|
||||
'context.messagePrefix': 'Message prefix',
|
||||
'context.derivedHistory': 'Derived history',
|
||||
'context.rawJsonl': 'Raw JSONL',
|
||||
'context.noSystem': 'No system prompt found in latest request header.',
|
||||
'context.noTools': 'No tool schemas found in latest request header.',
|
||||
'context.changed': 'changed',
|
||||
'context.available': 'available',
|
||||
'context.empty': 'empty',
|
||||
'context.derived': 'derived',
|
||||
'context.visibleRows': 'visible rows',
|
||||
'context.messages': 'messages',
|
||||
'context.events': 'events',
|
||||
'empty.traceTitle': '还没有 trace',
|
||||
'empty.traceBody': '先运行一条 prompt,这里会读取真实 JSONL trace。',
|
||||
'dev.emptyTitle': 'No development artifacts found',
|
||||
'dev.emptyBody': 'Start the runtime or check the active cordis.yml config.',
|
||||
'dev.source': 'Source',
|
||||
'dev.owner': 'Owner',
|
||||
'dev.recentlyUsed': 'Recently used',
|
||||
'dev.reload': 'Reload',
|
||||
'dev.unknown': 'unknown',
|
||||
'dev.noRecentEvidence': 'No recent evidence yet',
|
||||
'dev.registeredPlugins': 'Registered plugins',
|
||||
'dev.registeredTools': 'Registered tool surfaces',
|
||||
'dev.noPlugins': 'No registered plugins found',
|
||||
'dev.noTools': 'No registered tools found yet',
|
||||
'dev.metadata': 'Metadata',
|
||||
'dev.metadataSubtitle': 'Implementation, dependency, and last-seen evidence',
|
||||
'dev.effectivePromptContent': 'Effective prompt content',
|
||||
'dev.toolSchema': 'Tool schema',
|
||||
'dev.pluginContribution': 'Plugin config / contribution',
|
||||
'dev.activeConfiguration': 'Active configuration',
|
||||
'dev.runtimeState': 'Runtime state',
|
||||
'dev.suggestedLoop': 'Suggested verification loop',
|
||||
'dev.promptMeta': 'Current source-level prompt text or prompt owner metadata',
|
||||
'dev.toolMeta': 'Current registered schema when last seen by a model request',
|
||||
'dev.pluginMeta': 'Who injects prompt/context or registers tools, based on active Cordis config',
|
||||
'dev.configMeta': 'Model/runtime parameters and files whose edits require reload',
|
||||
'dev.runtimeMeta': 'Current Electron main process and ACP bridge state',
|
||||
'dev.loopMeta': 'How to rerun and compare after editing the agent',
|
||||
'dev.manualRestart': 'Manual restart available here',
|
||||
'dev.restartRecommended': 'Restart recommended',
|
||||
'dev.noRestartSignal': 'No restart signal',
|
||||
'dev.restartAfterEdit': 'Restart ACP after editing',
|
||||
'dev.recommendedLoop': 'Recommended loop',
|
||||
'dev.loopStepEdit': 'Edit the prompt, tool, plugin, or config source.',
|
||||
'dev.loopStepRestart': 'Restart ACP runtime if the changed file is loaded at process start.',
|
||||
'dev.loopStepRerun': 'Return to Chat and rerun a previous task or start a new one.',
|
||||
'dev.loopStepCompare': 'Use Trajectory / Waterfall to compare behavior and timing evidence.',
|
||||
'dev.restartRuntime': 'Restart ACP runtime',
|
||||
'chat.newBody': '先输入一句话。发送后才会创建真实 ACP 会话,并在左侧出现记录。',
|
||||
'chat.startTitle': '开始一个 DeepSeek Harness 会话',
|
||||
'chat.startBody': '点击新对话只会打开草稿;真正发送第一句话后,才会创建后端会话。',
|
||||
'chat.user': '用户',
|
||||
'chat.assistant': '助手',
|
||||
'chat.userMessage': '用户消息',
|
||||
'chat.assistantMessage': '助手消息',
|
||||
'chat.noVisibleAssistantText': '没有可见的助手正文。',
|
||||
'chat.cancel': '取消',
|
||||
'chat.sending': '正在发送…',
|
||||
'chat.working': '正在生成…',
|
||||
'chat.cancelRequested': '已请求取消',
|
||||
'chat.sendFailed': '发送失败',
|
||||
'chat.jumpToLive': '正在生成 · 回到底部',
|
||||
'composer.hint': 'Enter 发送 · Shift+Enter 换行',
|
||||
'toast.copied': '已复制',
|
||||
'feedback.saved': '标注已保存',
|
||||
'feedback.failed': '标注保存失败',
|
||||
'kind.user': '用户',
|
||||
'kind.reasoning': '思考',
|
||||
'kind.assistant': '回复',
|
||||
'kind.tool': '工具',
|
||||
'kind.request': '请求',
|
||||
'kind.context': '上下文',
|
||||
'kind.turn': '轮次',
|
||||
'kind.step': '步骤',
|
||||
'kind.session': '会话',
|
||||
'kind.summary': '统计',
|
||||
'trace.running': '进行中',
|
||||
'trace.expandAll': '全部展开',
|
||||
'trace.collapse': '全部收起',
|
||||
'trace.expandRow': '展开轨迹行',
|
||||
'trace.collapseRow': '收起轨迹行',
|
||||
'trace.copyJson': '复制 JSON',
|
||||
'trace.annotate': '标注',
|
||||
'trace.annotateClose': '收起标注',
|
||||
'inspector.jumpTraj': '→ 轨迹',
|
||||
'trace.title': '轨迹',
|
||||
'trace.body': '按会话 / 轮次 / 步骤 / 请求 / 工具组织。展开节点可以直接看关键提示词、schema、输入和输出。',
|
||||
'trace.systemPrompt': '系统提示词',
|
||||
'trace.toolSchemas': '工具 schema',
|
||||
'trace.configPrefix': '配置和消息前缀',
|
||||
'trace.metadata': '元数据',
|
||||
'trace.rawEvent': '原始事件',
|
||||
'trace.usage': '用量',
|
||||
'trace.noSystem': '没有记录系统提示词。',
|
||||
'trace.sessionSaved': '已保存',
|
||||
'trace.sessionLive': '实时',
|
||||
'trace.persistedJsonl': '已持久化 JSONL',
|
||||
'trace.liveSession': '实时会话',
|
||||
'trace.turn': '轮次',
|
||||
'trace.step': '步骤',
|
||||
'trace.request': '请求',
|
||||
'trace.message': '消息',
|
||||
'trace.context': '上下文',
|
||||
'trace.error': '错误',
|
||||
'trace.toolResult': '工具结果',
|
||||
'trace.userPrompt': '用户提示词',
|
||||
'waterfall.title': '瀑布',
|
||||
'waterfall.body': '从耗时角度定位慢点、工具等待和错误,再跳回轨迹看细节。',
|
||||
'waterfall.total': '总耗时',
|
||||
'waterfall.turns': '轮次',
|
||||
'waterfall.steps': '步骤',
|
||||
'waterfall.tools': '工具',
|
||||
'waterfall.slowest': '最慢',
|
||||
'waterfall.errors': '错误',
|
||||
'waterfall.llmTime': 'LLM 时间',
|
||||
'waterfall.toolTime': '工具时间',
|
||||
'waterfall.slowestStep': '最慢步骤',
|
||||
'waterfall.tokens': 'Token 输入/输出',
|
||||
'trace.columnNumber': '#',
|
||||
'trace.columnEvent': '事件',
|
||||
'trace.columnContent': '内容',
|
||||
'trace.columnInput': '输入',
|
||||
'trace.columnOutput': '输出',
|
||||
'trace.columnThink': '思考',
|
||||
'trace.columnTime': '时间',
|
||||
'trace.requestEnvelope': '请求信封',
|
||||
'trace.streamingChunks': '流式片段',
|
||||
'trace.event': '事件',
|
||||
'context.title': '上下文',
|
||||
'context.body': '这是开发分析视图:它解释模型请求边界里真正进入上下文的内容,用来改提示词、工具 schema 和配置。',
|
||||
'context.systemPrompt': '系统提示词',
|
||||
'context.toolSchemas': '工具 schema',
|
||||
'context.callConfig': '调用配置',
|
||||
'context.messagePrefix': '消息前缀',
|
||||
'context.derivedHistory': '推导出的历史',
|
||||
'context.rawJsonl': '原始 JSONL',
|
||||
'context.noSystem': '最新请求头里没有系统提示词。',
|
||||
'context.noTools': '最新请求头里没有工具 schema。',
|
||||
'context.changed': '已变化',
|
||||
'context.available': '可用',
|
||||
'context.empty': '空',
|
||||
'context.derived': '推导',
|
||||
'context.visibleRows': '可见行',
|
||||
'context.messages': '消息',
|
||||
'context.events': '事件',
|
||||
'empty.traceTitle': '还没有轨迹',
|
||||
'empty.traceBody': '先运行一条提示词,这里会读取真实 JSONL 轨迹。',
|
||||
'dev.emptyTitle': '没有发现开发对象',
|
||||
'dev.emptyBody': '请先启动运行时,或者检查当前生效的 cordis.yml 配置。',
|
||||
'dev.source': '来源',
|
||||
'dev.owner': '归属',
|
||||
'dev.recentlyUsed': '最近使用',
|
||||
'dev.reload': '重载',
|
||||
'dev.unknown': '未知',
|
||||
'dev.noRecentEvidence': '还没有最近证据',
|
||||
'dev.registeredPlugins': '已注册插件',
|
||||
'dev.registeredTools': '已注册工具',
|
||||
'dev.noPlugins': '没有发现已注册插件',
|
||||
'dev.noTools': '还没有发现已注册工具',
|
||||
'dev.metadata': '元数据',
|
||||
'dev.metadataSubtitle': '实现、依赖和最近一次出现的证据',
|
||||
'dev.sourceFile': '源码文件',
|
||||
'dev.sourceFileContent': '源码内容',
|
||||
'dev.sourceFilePurposeFallback': '当前运行时组合里暴露的源码文件',
|
||||
'dev.effectivePromptContent': '当前生效的提示词内容',
|
||||
'dev.toolSchema': '工具 schema',
|
||||
'dev.pluginContribution': '插件配置 / 贡献',
|
||||
'dev.activeConfiguration': '当前配置',
|
||||
'dev.runtimeState': '运行时状态',
|
||||
'dev.suggestedLoop': '建议验证循环',
|
||||
'dev.promptMeta': '当前源码级提示词文本或提示词归属信息',
|
||||
'dev.toolMeta': '模型请求最近一次看到的已注册 schema',
|
||||
'dev.pluginMeta': '基于当前 Cordis 配置识别提示词/上下文注入者和工具注册者',
|
||||
'dev.sourceMeta': '当前运行时组合暴露的源码事实,用于定位实现和修改入口',
|
||||
'dev.configMeta': '模型/运行时参数,以及修改后需要重载的文件',
|
||||
'dev.runtimeMeta': '当前 Electron 主进程和 ACP 桥接状态',
|
||||
'dev.loopMeta': '编辑智能体后如何重新运行并对比',
|
||||
'dev.manualRestart': '这里可以手动重启',
|
||||
'dev.restartRecommended': '建议重启',
|
||||
'dev.noRestartSignal': '没有重启信号',
|
||||
'dev.restartAfterEdit': '编辑后重启 ACP',
|
||||
'dev.recommendedLoop': '推荐循环',
|
||||
'dev.loopStepEdit': '修改提示词、工具、插件或配置源文件。',
|
||||
'dev.loopStepRestart': '如果修改的文件是在进程启动时加载的,就重启 ACP 运行时。',
|
||||
'dev.loopStepRerun': '回到聊天,重新运行历史任务或开始新任务。',
|
||||
'dev.loopStepCompare': '用轨迹 / 瀑布图对比行为和耗时证据。',
|
||||
'dev.restartRuntime': '重启 ACP 运行时',
|
||||
'dev.group.prompts': '提示词',
|
||||
'dev.group.tools': '工具',
|
||||
'dev.group.plugins': '插件 / 上下文提供者',
|
||||
'dev.group.config': '配置 / 运行时',
|
||||
'dev.group.changeLoop': '变更循环',
|
||||
'dev.requestSystemPrompt': '当前请求的系统提示词',
|
||||
'dev.requestSystemPromptSubtitle': '最近一次模型请求实际收到的完整系统提示词',
|
||||
'dev.noRequestSystemPrompt': '还没有从当前会话的请求头里看到系统提示词。先在聊天里运行一次任务,再回到这里查看。',
|
||||
'dev.sourceRequestHeader': '最近一次请求头',
|
||||
'dev.agentArtifacts': '智能体对象',
|
||||
'dev.systemPersona': '配置里的系统身份设定',
|
||||
'dev.systemPersonaSubtitle': '从 cordis.yml 转发过来的部署身份设定',
|
||||
'dev.promptAssemblyService': '提示词组装服务',
|
||||
'dev.promptAssemblySubtitle': '负责身份设定、引导段落和工具顺序组装',
|
||||
'dev.activeCordis': '当前 cordis.yml',
|
||||
'dev.activeCordisSubtitle': '桌面 ACP 运行时在进程启动时加载的 Cordis 组合',
|
||||
'dev.acpRuntime': 'ACP 运行时',
|
||||
'dev.acpRuntimeSubtitle': '聊天和轨迹捕获使用的托管后端进程',
|
||||
'dev.modifyReloadRerun': '修改、重载、重跑',
|
||||
'dev.modifyReloadRerunSubtitle': '编辑 Harness、插件、提示词、工具或配置之后的产品循环',
|
||||
'dev.statusActive': '已启用',
|
||||
'dev.statusMissing': '缺失',
|
||||
'dev.statusSource': '源码',
|
||||
'dev.statusRegistered': '已注册',
|
||||
'dev.statusProvider': '提供者',
|
||||
'dev.statusReady': '就绪',
|
||||
'dev.statusRestartNeeded': '需要重启',
|
||||
'dev.noPersona': '当前配置里没有找到身份设定片段。',
|
||||
'dev.noConfigText': '开发后端没有返回配置文本。',
|
||||
'dev.noPid': '没有 pid',
|
||||
'dev.startChatThenInspect': '先开始一个聊天任务,然后检查它的轨迹/瀑布图。',
|
||||
'dev.rerunOrContinue': '重新运行或继续会话',
|
||||
'dev.registeredModelTool': '已注册的模型可见工具',
|
||||
'dev.toolProviderPlugin': '工具提供插件',
|
||||
'dev.noMatchingPlugin': '没有从当前配置推断出匹配插件',
|
||||
'dev.noPersistedCall': '没有找到已持久化调用证据',
|
||||
'dev.noRequestEvidence': '还没有请求证据',
|
||||
'dev.schemaLastSeen': 'Schema 最近出现于请求序号',
|
||||
'dev.toolSchemaAfterRequest': '第一次模型请求捕获注册工具列表后,这里会显示工具 schema。',
|
||||
'dev.noRequestSchema': '还没有加载请求 schema',
|
||||
'dev.lastUsedIn': '最近用于',
|
||||
'dev.observedInSelectedTrace': '在当前轨迹中观察到',
|
||||
'dev.calls': '次调用',
|
||||
'dev.last': '最近',
|
||||
'dev.cordisConfigEntry': 'Cordis 配置项',
|
||||
'dev.injectsPrompt': '注入提示词',
|
||||
'dev.registersTool': '注册工具',
|
||||
'dev.contextPolicyProvider': '上下文 / 策略提供者',
|
||||
'dev.modelProvider': '模型提供者',
|
||||
'dev.plugin': '插件',
|
||||
'dev.injectsContext': '注入上下文',
|
||||
'dev.sourceOwnsArtifact': '源码实现',
|
||||
'dev.dependencyNote': '根据当前 Cordis 配置顺序推导;等后端暴露 Cordis fibers 后,可以补充精确运行时依赖图。',
|
||||
'dev.loadedFromConfig': '从当前配置加载',
|
||||
'dev.loadedFromRuntimeComposition': '来自当前运行时组合',
|
||||
'dev.backendStatusNote': '当前版本先展示后端状态;后续可以在这里接入文件级编辑能力。',
|
||||
'dev.sourceAcpFrontDoor': 'ACP 入口',
|
||||
'dev.sourceAcpFrontDoorPurpose': '加载 agent spine、JSONL 持久化、用户交互服务和 ACP bridge。',
|
||||
'dev.sourceAgentSpine': 'Agent 组装骨架',
|
||||
'dev.sourceAgentSpinePurpose': '组合系统提示词、工具注册表、skills、agent registry、任务、不变量、工具插件和 agent loop。',
|
||||
'dev.sourceSystemPromptService': '系统提示词服务源码',
|
||||
'dev.sourceSystemPromptServicePurpose': '维护 persona、工具顺序,以及最终发给模型的系统提示词分段。',
|
||||
'dev.sourceToolRegistry': '工具注册表源码',
|
||||
'dev.sourceToolRegistryPurpose': '维护模型可见工具注册、schema 校验和工具展示模式。',
|
||||
'inspector.close': '关闭',
|
||||
'inspector.input': 'Input',
|
||||
'inspector.output': 'Output',
|
||||
'inspector.metadata': 'Metadata',
|
||||
'inspector.feedback': 'Feedback',
|
||||
'feedback.empty': '这个对象还没有 feedback。',
|
||||
'feedback.author': 'Feedback author',
|
||||
'feedback.placeholder': '给这个对象写 feedback',
|
||||
'feedback.add': '添加 feedback',
|
||||
'composer.placeholderDraft': '先输入一句话创建 session',
|
||||
'composer.placeholderSession': 'Message Deepseek Harness',
|
||||
'composer.send': 'Send message',
|
||||
'error.noDesktopApi': 'Desktop API 不可用。请用 Electron 窗口打开,而不是浏览器 tab。',
|
||||
'inspector.input': '输入',
|
||||
'inspector.output': '输出',
|
||||
'inspector.metadata': '元数据',
|
||||
'inspector.feedback': '反馈',
|
||||
'feedback.empty': '这个对象还没有反馈。',
|
||||
'feedback.author': '反馈作者',
|
||||
'feedback.placeholder': '给这个对象写反馈',
|
||||
'feedback.add': '添加反馈',
|
||||
'composer.placeholderDraft': '先输入一句话创建会话',
|
||||
'composer.placeholderSession': '给 DeepSeek Harness 发消息',
|
||||
'composer.send': '发送消息',
|
||||
'error.noDesktopApi': '桌面 API 不可用。请用 Electron 窗口打开,而不是浏览器标签页。',
|
||||
},
|
||||
'en-US': {
|
||||
'app.newChat': 'New chat',
|
||||
@@ -138,6 +284,8 @@ const messages = {
|
||||
'app.recentSessions': 'Recent sessions',
|
||||
'app.emptySessions': 'No matching sessions',
|
||||
'app.language': '中文',
|
||||
'app.revealSession': 'Reveal in Finder',
|
||||
'app.subagents': 'subagents',
|
||||
'app.errorTitle': 'Error',
|
||||
'app.repo': 'Repo',
|
||||
'app.branch': 'Branch',
|
||||
@@ -145,12 +293,29 @@ const messages = {
|
||||
'app.dirty': 'Dirty',
|
||||
'app.acp': 'ACP',
|
||||
'app.restartNeeded': 'Restart needed',
|
||||
'app.liveAcpSession': 'live ACP session',
|
||||
'app.sessionRoot': 'session root',
|
||||
'app.sessionLabel': 'Session',
|
||||
'app.liveAcpUpdate': 'live ACP update',
|
||||
'common.chars': 'chars',
|
||||
'common.seq': 'seq',
|
||||
'common.now': 'now',
|
||||
'common.minutesAgo': 'min',
|
||||
'common.hoursAgo': 'hr',
|
||||
'common.daysAgo': 'day',
|
||||
'runtime.starting': 'starting',
|
||||
'runtime.running': 'running',
|
||||
'runtime.error': 'error',
|
||||
'surface.chat': 'Chat',
|
||||
'surface.trajectory': 'Trajectory',
|
||||
'surface.waterfall': 'Waterfall',
|
||||
'surface.context': 'Context',
|
||||
'surface.compare': 'Compare',
|
||||
'surface.dev': 'Dev',
|
||||
'metric.turns': 'Turns',
|
||||
'metric.steps': 'Steps',
|
||||
'metric.tools': 'Tools',
|
||||
'metric.events': 'Events',
|
||||
'chat.details': 'Details',
|
||||
'chat.thinking': 'Thinking',
|
||||
'chat.toolUse': 'Tool use',
|
||||
@@ -159,23 +324,68 @@ const messages = {
|
||||
'chat.output': 'Output',
|
||||
'chat.errorOutput': 'Error output',
|
||||
'chat.openInspector': 'Open in inspector',
|
||||
'chat.spawnedSessions': 'Spawned sessions',
|
||||
'chat.emptyTitle': 'No messages yet',
|
||||
'chat.emptyBody': 'Send a prompt from the bottom composer. Thinking and tool use stay folded, but remain available.',
|
||||
'chat.newTitle': 'New chat',
|
||||
'chat.newBody': 'Type a message first. A real ACP session is created only after sending.',
|
||||
'chat.startTitle': 'Start a Deepseek Harness session',
|
||||
'chat.startTitle': 'Start a DeepSeek Harness session',
|
||||
'chat.startBody': 'New chat opens a draft only. The backend session is created after the first sent message.',
|
||||
'chat.user': 'User',
|
||||
'chat.assistant': 'Assistant',
|
||||
'chat.userMessage': 'User message',
|
||||
'chat.assistantMessage': 'Assistant message',
|
||||
'chat.noVisibleAssistantText': 'No visible assistant text.',
|
||||
'chat.cancel': 'Cancel',
|
||||
'chat.sending': 'Sending…',
|
||||
'chat.working': 'Working…',
|
||||
'chat.cancelRequested': 'Cancel requested',
|
||||
'chat.sendFailed': 'Send failed',
|
||||
'chat.jumpToLive': 'Generating · Jump to latest',
|
||||
'composer.hint': 'Enter to send · Shift+Enter for newline',
|
||||
'toast.copied': 'Copied',
|
||||
'feedback.saved': 'Feedback saved',
|
||||
'feedback.failed': 'Failed to save feedback',
|
||||
'kind.user': 'User',
|
||||
'kind.reasoning': 'Thinking',
|
||||
'kind.assistant': 'Response',
|
||||
'kind.tool': 'Tool',
|
||||
'kind.request': 'Request',
|
||||
'kind.context': 'Context',
|
||||
'kind.turn': 'Turn',
|
||||
'kind.step': 'Step',
|
||||
'kind.session': 'Session',
|
||||
'kind.summary': 'Summary',
|
||||
'trace.running': 'Running',
|
||||
'trace.expandAll': 'Expand all',
|
||||
'trace.collapse': 'Collapse',
|
||||
'trace.expandRow': 'Expand trajectory row',
|
||||
'trace.collapseRow': 'Collapse trajectory row',
|
||||
'trace.copyJson': 'Copy JSON',
|
||||
'trace.annotate': 'Annotate',
|
||||
'trace.annotateClose': 'Hide annotation',
|
||||
'inspector.jumpTraj': '→ Traj',
|
||||
'trace.title': 'Trajectory',
|
||||
'trace.body': 'Organized by session / turn / step / request / tool. Expand nodes to inspect prompts, schemas, input, and output inline.',
|
||||
'trace.systemPrompt': 'System prompt',
|
||||
'trace.toolSchemas': 'Tool schemas',
|
||||
'trace.configPrefix': 'Config and message prefix',
|
||||
'trace.metadata': 'Metadata',
|
||||
'trace.rawEvent': 'Raw event',
|
||||
'trace.usage': 'Usage',
|
||||
'trace.noSystem': 'No system prompt recorded.',
|
||||
'trace.sessionSaved': 'saved',
|
||||
'trace.sessionLive': 'live',
|
||||
'trace.persistedJsonl': 'persisted JSONL',
|
||||
'trace.liveSession': 'live session',
|
||||
'trace.turn': 'Turn',
|
||||
'trace.step': 'Step',
|
||||
'trace.request': 'Request',
|
||||
'trace.message': 'Message',
|
||||
'trace.context': 'Context',
|
||||
'trace.error': 'Error',
|
||||
'trace.toolResult': 'Tool result',
|
||||
'trace.userPrompt': 'user prompt',
|
||||
'waterfall.title': 'Waterfall',
|
||||
'waterfall.body': 'Find slow spans, tool waits, and errors by duration, then jump back to Trajectory for detail.',
|
||||
'waterfall.total': 'total',
|
||||
@@ -184,6 +394,20 @@ const messages = {
|
||||
'waterfall.tools': 'tools',
|
||||
'waterfall.slowest': 'slowest',
|
||||
'waterfall.errors': 'errors',
|
||||
'waterfall.llmTime': 'llm time',
|
||||
'waterfall.toolTime': 'tool time',
|
||||
'waterfall.slowestStep': 'slowest step',
|
||||
'waterfall.tokens': 'tokens in/out',
|
||||
'trace.columnNumber': '#',
|
||||
'trace.columnEvent': 'event',
|
||||
'trace.columnContent': 'content',
|
||||
'trace.columnInput': 'in',
|
||||
'trace.columnOutput': 'out',
|
||||
'trace.columnThink': 'think',
|
||||
'trace.columnTime': 'time',
|
||||
'trace.requestEnvelope': 'request envelope',
|
||||
'trace.streamingChunks': 'streaming chunks',
|
||||
'trace.event': 'event',
|
||||
'context.title': 'Context',
|
||||
'context.body': 'This development analysis view explains what actually entered the model request boundary, so prompts, tool schemas, and config can be changed with evidence.',
|
||||
'context.systemPrompt': 'System prompt',
|
||||
@@ -217,6 +441,9 @@ const messages = {
|
||||
'dev.noTools': 'No registered tools found yet',
|
||||
'dev.metadata': 'Metadata',
|
||||
'dev.metadataSubtitle': 'Implementation, dependency, and last-seen evidence',
|
||||
'dev.sourceFile': 'Source file',
|
||||
'dev.sourceFileContent': 'Source file content',
|
||||
'dev.sourceFilePurposeFallback': 'Source file exposed by the active runtime composition',
|
||||
'dev.effectivePromptContent': 'Effective prompt content',
|
||||
'dev.toolSchema': 'Tool schema',
|
||||
'dev.pluginContribution': 'Plugin config / contribution',
|
||||
@@ -226,6 +453,7 @@ const messages = {
|
||||
'dev.promptMeta': 'Current source-level prompt text or prompt owner metadata',
|
||||
'dev.toolMeta': 'Current registered schema when last seen by a model request',
|
||||
'dev.pluginMeta': 'Who injects prompt/context or registers tools, based on active Cordis config',
|
||||
'dev.sourceMeta': 'Source-level evidence from the active runtime composition, useful for locating implementation and edit entry points',
|
||||
'dev.configMeta': 'Model/runtime parameters and files whose edits require reload',
|
||||
'dev.runtimeMeta': 'Current Electron main process and ACP bridge state',
|
||||
'dev.loopMeta': 'How to rerun and compare after editing the agent',
|
||||
@@ -239,6 +467,70 @@ const messages = {
|
||||
'dev.loopStepRerun': 'Return to Chat and rerun a previous task or start a new one.',
|
||||
'dev.loopStepCompare': 'Use Trajectory / Waterfall to compare behavior and timing evidence.',
|
||||
'dev.restartRuntime': 'Restart ACP runtime',
|
||||
'dev.requestSystemPrompt': 'Current request system prompt',
|
||||
'dev.requestSystemPromptSubtitle': 'The full system prompt actually sent in the latest model request',
|
||||
'dev.noRequestSystemPrompt': 'No system prompt has been observed in the current session request header yet. Run a chat task first, then return here.',
|
||||
'dev.sourceRequestHeader': 'Latest request header',
|
||||
'dev.agentArtifacts': 'Agent artifacts',
|
||||
'dev.group.prompts': 'Prompts',
|
||||
'dev.group.tools': 'Tools',
|
||||
'dev.group.plugins': 'Plugins / Context Providers',
|
||||
'dev.group.config': 'Config / Runtime',
|
||||
'dev.group.changeLoop': 'Change Loop',
|
||||
'dev.systemPersona': 'System persona',
|
||||
'dev.systemPersonaSubtitle': 'Current deployment persona forwarded from cordis.yml',
|
||||
'dev.promptAssemblyService': 'Prompt assembly service',
|
||||
'dev.promptAssemblySubtitle': 'Owns persona, steering sections, and tool-order assembly',
|
||||
'dev.activeCordis': 'Active cordis.yml',
|
||||
'dev.activeCordisSubtitle': 'Process-start Cordis composition loaded by the desktop ACP runtime',
|
||||
'dev.acpRuntime': 'ACP runtime',
|
||||
'dev.acpRuntimeSubtitle': 'Managed backend process used by Chat and trace capture',
|
||||
'dev.modifyReloadRerun': 'Modify, reload, rerun',
|
||||
'dev.modifyReloadRerunSubtitle': 'The product loop after editing Harness, plugins, prompts, tools, or config',
|
||||
'dev.statusActive': 'active',
|
||||
'dev.statusMissing': 'missing',
|
||||
'dev.statusSource': 'source',
|
||||
'dev.statusRegistered': 'registered',
|
||||
'dev.statusProvider': 'provider',
|
||||
'dev.statusReady': 'ready',
|
||||
'dev.statusRestartNeeded': 'restart needed',
|
||||
'dev.noPersona': 'No persona block found in the active config.',
|
||||
'dev.noConfigText': 'No config text reported by dev backend.',
|
||||
'dev.noPid': 'No pid',
|
||||
'dev.startChatThenInspect': 'Start a chat task, then inspect its trajectory/waterfall.',
|
||||
'dev.rerunOrContinue': 'Rerun or continue session',
|
||||
'dev.registeredModelTool': 'Registered model-facing tool',
|
||||
'dev.toolProviderPlugin': 'Tool provider plugin',
|
||||
'dev.noMatchingPlugin': 'No matching plugin inferred from active config',
|
||||
'dev.noPersistedCall': 'No persisted call evidence found',
|
||||
'dev.noRequestEvidence': 'No request evidence yet',
|
||||
'dev.schemaLastSeen': 'Schema last seen in request seq',
|
||||
'dev.toolSchemaAfterRequest': 'Tool schema will appear here after the first model request captures the registered tool list.',
|
||||
'dev.noRequestSchema': 'No request schema loaded',
|
||||
'dev.lastUsedIn': 'Last used in',
|
||||
'dev.observedInSelectedTrace': 'Observed in selected trace',
|
||||
'dev.calls': 'calls',
|
||||
'dev.last': 'last',
|
||||
'dev.cordisConfigEntry': 'Cordis config entry',
|
||||
'dev.injectsPrompt': 'injects prompt',
|
||||
'dev.registersTool': 'registers tool',
|
||||
'dev.contextPolicyProvider': 'context / policy provider',
|
||||
'dev.modelProvider': 'model provider',
|
||||
'dev.plugin': 'plugin',
|
||||
'dev.injectsContext': 'injects context',
|
||||
'dev.sourceOwnsArtifact': 'source implementation',
|
||||
'dev.dependencyNote': 'Derived from the active Cordis config order; exact runtime graph can be added when the backend exposes Cordis fibers.',
|
||||
'dev.loadedFromConfig': 'Loaded from active config',
|
||||
'dev.loadedFromRuntimeComposition': 'Loaded from active runtime composition',
|
||||
'dev.backendStatusNote': 'The first implementation exposes the backend status. File-level editing surfaces can bind here next.',
|
||||
'dev.sourceAcpFrontDoor': 'ACP front door',
|
||||
'dev.sourceAcpFrontDoorPurpose': 'Loads the agent spine, JSONL persistence, user interaction service, and ACP bridge.',
|
||||
'dev.sourceAgentSpine': 'Agent spine',
|
||||
'dev.sourceAgentSpinePurpose': 'Composes system prompt, tool registry, skills, agent registry, tasks, invariants, tool plugins, and agent loop.',
|
||||
'dev.sourceSystemPromptService': 'System prompt service source',
|
||||
'dev.sourceSystemPromptServicePurpose': 'Owns persona, tool order, and assembled model-facing prompt sections.',
|
||||
'dev.sourceToolRegistry': 'Tool registry source',
|
||||
'dev.sourceToolRegistryPurpose': 'Owns model-facing tool registration, schema validation, and tool presentation mode.',
|
||||
'inspector.close': 'Close',
|
||||
'inspector.input': 'Input',
|
||||
'inspector.output': 'Output',
|
||||
@@ -249,7 +541,7 @@ const messages = {
|
||||
'feedback.placeholder': 'Write feedback for this exact object',
|
||||
'feedback.add': 'Add feedback',
|
||||
'composer.placeholderDraft': 'Type a message to create a session',
|
||||
'composer.placeholderSession': 'Message Deepseek Harness',
|
||||
'composer.placeholderSession': 'Message DeepSeek Harness',
|
||||
'composer.send': 'Send message',
|
||||
'error.noDesktopApi': 'Desktop API is not available. Use the Electron window, not the browser tab.',
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Shared contracts for the Deepseek Harness desktop app.
|
||||
* Shared contracts for the DeepSeek Harness desktop app.
|
||||
*
|
||||
* The package starts with view and lifecycle contracts so Electron main,
|
||||
* preload, and renderer code can evolve without copying product decisions from
|
||||
@@ -8,10 +8,10 @@
|
||||
* @module @deepseek-ai/dsh-desktop
|
||||
*/
|
||||
|
||||
/** Main analysis surfaces in the Deepseek Harness session view. */
|
||||
/** Main analysis surfaces in the DeepSeek Harness session view. */
|
||||
export const DESKTOP_SURFACES = ['chat', 'trajectory', 'waterfall', 'context', 'compare', 'dev'] as const
|
||||
|
||||
/** Main analysis surfaces in the Deepseek Harness session view. */
|
||||
/** Main analysis surfaces in the DeepSeek Harness session view. */
|
||||
export type DesktopSurface = (typeof DESKTOP_SURFACES)[number]
|
||||
|
||||
/** Right-side inspector tabs, ordered as rendered. */
|
||||
@@ -221,7 +221,7 @@ export const SURFACE_DEFINITIONS: Record<DesktopSurface, SurfaceDefinition> = {
|
||||
label: 'Trajectory',
|
||||
purpose: 'navigate',
|
||||
primaryQuestion: 'Where am I in the session, turn, step, request, assistant, tool, and context structure?',
|
||||
ownsComposer: false,
|
||||
ownsComposer: true,
|
||||
summaryFirst: true,
|
||||
inlinePreview: true,
|
||||
fullDetailInInspector: true,
|
||||
@@ -231,7 +231,7 @@ export const SURFACE_DEFINITIONS: Record<DesktopSurface, SurfaceDefinition> = {
|
||||
label: 'Waterfall',
|
||||
purpose: 'timing',
|
||||
primaryQuestion: 'Where did time go across model requests, tool calls, and failures?',
|
||||
ownsComposer: false,
|
||||
ownsComposer: true,
|
||||
summaryFirst: true,
|
||||
inlinePreview: false,
|
||||
fullDetailInInspector: true,
|
||||
@@ -282,9 +282,9 @@ export function opensInspector(surface: DesktopSurface, target: InspectorTarget
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the surface is allowed to show the chat composer.
|
||||
* Returns whether the surface participates in the live session composer.
|
||||
* @param surface - The active middle surface.
|
||||
* @returns True only for the driving chat surface.
|
||||
* @returns True for the three live session surfaces.
|
||||
*/
|
||||
export function ownsComposer(surface: DesktopSurface): boolean {
|
||||
return SURFACE_DEFINITIONS[surface].ownsComposer
|
||||
|
||||
@@ -3,7 +3,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, appendFileS
|
||||
import { dirname, join, relative, resolve } from 'node:path'
|
||||
import { Readable, Writable } from 'node:stream'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { app, BrowserWindow, dialog, ipcMain } from 'electron'
|
||||
import { app, BrowserWindow, dialog, ipcMain, shell } from 'electron'
|
||||
import {
|
||||
ClientSideConnection,
|
||||
PROTOCOL_VERSION,
|
||||
@@ -28,8 +28,9 @@ let runtimeProcess
|
||||
let acpClient
|
||||
let initializeResult
|
||||
let stderrTail = ''
|
||||
/** @type {Map<string, {sessionId: string, loaded: boolean, cwd: string}>} */
|
||||
/** @type {Map<string, {sessionId: string, loaded: boolean, cwd: string, title?: string}>} */
|
||||
const activeSessions = new Map()
|
||||
const replayingSessions = new Set()
|
||||
|
||||
function broadcast(channel, payload) {
|
||||
for (const window of BrowserWindow.getAllWindows()) {
|
||||
@@ -101,6 +102,12 @@ async function startRuntime() {
|
||||
|
||||
acpClient = new ClientSideConnection(() => ({
|
||||
sessionUpdate(params) {
|
||||
if (replayingSessions.has(String(params.sessionId))) return Promise.resolve()
|
||||
const session = activeSessions.get(String(params.sessionId))
|
||||
if (session !== undefined && session.title === undefined && params.update?.sessionUpdate === 'user_message_chunk') {
|
||||
const title = textOfContent(params.update.content).trim()
|
||||
if (title.length > 0) session.title = title.slice(0, 120)
|
||||
}
|
||||
broadcast('sessions:update', params)
|
||||
return Promise.resolve()
|
||||
},
|
||||
@@ -212,6 +219,7 @@ function summarizeSession(file) {
|
||||
const last = events.at(-1)
|
||||
return {
|
||||
id: String(header.id),
|
||||
parentSession: header.parentSession === undefined ? undefined : String(header.parentSession),
|
||||
cwd: header.cwd,
|
||||
path: file,
|
||||
relativePath: relative(repoRoot, file),
|
||||
@@ -244,7 +252,7 @@ function listSessions() {
|
||||
stepCount: 0,
|
||||
toolCallCount: 0,
|
||||
model: undefined,
|
||||
title: 'New live session',
|
||||
title: session.title ?? 'New live session',
|
||||
live: true,
|
||||
}))
|
||||
return [...liveOnly, ...persisted].sort((a, b) => b.lastActivity - a.lastActivity)
|
||||
@@ -267,6 +275,8 @@ function readTrace(sessionId) {
|
||||
return { found: false, sessionId, header: { id: sessionId, cwd: repoRoot }, events: [], rawText: '' }
|
||||
}
|
||||
const trace = readJsonl(file)
|
||||
const sessions = listSessions()
|
||||
const summary = sessions.find(session => session.id === sessionId)
|
||||
return {
|
||||
found: true,
|
||||
sessionId,
|
||||
@@ -276,6 +286,8 @@ function readTrace(sessionId) {
|
||||
path: file,
|
||||
relativePath: relative(repoRoot, file),
|
||||
feedback: readFeedback(sessionId),
|
||||
parent: summary?.parentSession === undefined ? undefined : sessions.find(session => session.id === summary.parentSession) ?? { id: summary.parentSession },
|
||||
children: sessions.filter(session => session.parentSession === sessionId),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,8 +338,13 @@ async function ensureSessionLoaded(sessionId) {
|
||||
if (activeSessions.has(sessionId)) return
|
||||
const summary = listSessions().find(session => session.id === sessionId)
|
||||
const client = await ensureRuntime()
|
||||
await client.loadSession({ sessionId, cwd: summary?.cwd ?? repoRoot, mcpServers: [] })
|
||||
activeSessions.set(sessionId, { sessionId, loaded: true, cwd: summary?.cwd ?? repoRoot })
|
||||
replayingSessions.add(sessionId)
|
||||
try {
|
||||
await client.loadSession({ sessionId, cwd: summary?.cwd ?? repoRoot, mcpServers: [] })
|
||||
activeSessions.set(sessionId, { sessionId, loaded: true, cwd: summary?.cwd ?? repoRoot })
|
||||
} finally {
|
||||
replayingSessions.delete(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
function devStatus() {
|
||||
@@ -351,7 +368,7 @@ function devStatus() {
|
||||
recentPromptUses: recentEvidence.promptUses,
|
||||
recentToolCalls: recentEvidence.toolCalls,
|
||||
appComposition: {
|
||||
name: 'Deepseek Harness ACP agent',
|
||||
name: 'DeepSeek Harness ACP agent',
|
||||
entrypoint: 'packages/examples/acp-demo/src/bin.ts',
|
||||
configPath: relative(repoRoot, acpConfigPath),
|
||||
configText,
|
||||
@@ -361,21 +378,25 @@ function devStatus() {
|
||||
label: 'ACP front door',
|
||||
path: 'packages/examples/acp-demo/src/index.ts',
|
||||
purpose: 'Loads the agent spine, JSONL persistence, user interaction service, and ACP bridge.',
|
||||
text: readTextSafe(join(repoRoot, 'packages/examples/acp-demo/src/index.ts')),
|
||||
},
|
||||
{
|
||||
label: 'Agent spine',
|
||||
path: 'packages/examples/agent-spine-demo/src/index.ts',
|
||||
purpose: 'Composes system prompt, tool registry, skills, agent registry, tasks, invariants, tool plugins, and agent loop.',
|
||||
text: readTextSafe(join(repoRoot, 'packages/examples/agent-spine-demo/src/index.ts')),
|
||||
},
|
||||
{
|
||||
label: 'System prompt service',
|
||||
path: 'packages/core/system-prompt/src/index.ts',
|
||||
purpose: 'Owns persona, tool order, and assembled model-facing prompt sections.',
|
||||
text: readTextSafe(join(repoRoot, 'packages/core/system-prompt/src/index.ts')),
|
||||
},
|
||||
{
|
||||
label: 'Tool registry',
|
||||
path: 'packages/core/tools/src/index.ts',
|
||||
purpose: 'Owns model-facing tool registration, schema validation, and tool presentation mode.',
|
||||
text: readTextSafe(join(repoRoot, 'packages/core/tools/src/index.ts')),
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -502,6 +523,12 @@ function registerIpc() {
|
||||
await client.cancel({ sessionId: String(sessionId) })
|
||||
return { ok: true }
|
||||
})
|
||||
ipcMain.handle('sessions:reveal', (_event, { sessionId }) => {
|
||||
const file = findSessionFile(String(sessionId))
|
||||
if (file === undefined) throw new Error(`session not found: ${String(sessionId)}`)
|
||||
shell.showItemInFolder(file)
|
||||
return { ok: true, path: file }
|
||||
})
|
||||
ipcMain.handle('trace:read', (_event, { sessionId }) => readTrace(String(sessionId)))
|
||||
ipcMain.handle('feedback:list', (_event, { sessionId, targetId }) => readFeedback(String(sessionId), targetId === undefined ? undefined : String(targetId)))
|
||||
ipcMain.handle('feedback:add', (_event, entry) => appendFeedback(entry))
|
||||
@@ -515,6 +542,7 @@ async function createWindow() {
|
||||
minWidth: 1080,
|
||||
minHeight: 720,
|
||||
title: 'DeepSeek Harness Desktop',
|
||||
titleBarStyle: 'hiddenInset',
|
||||
backgroundColor: '#f5f5f7',
|
||||
webPreferences: {
|
||||
preload: join(here, 'preload.cjs'),
|
||||
@@ -525,7 +553,7 @@ async function createWindow() {
|
||||
|
||||
if (process.env.VITE_DEV_SERVER_URL !== undefined) {
|
||||
await mainWindow.loadURL(process.env.VITE_DEV_SERVER_URL)
|
||||
mainWindow.webContents.openDevTools({ mode: 'detach' })
|
||||
if (process.env.DSH_DESKTOP_OPEN_DEVTOOLS === '1') mainWindow.webContents.openDevTools({ mode: 'detach' })
|
||||
} else {
|
||||
const builtIndex = join(packageRoot, 'dist/index.html')
|
||||
if (!existsSync(builtIndex)) {
|
||||
@@ -536,16 +564,18 @@ async function createWindow() {
|
||||
}
|
||||
}
|
||||
|
||||
registerIpc()
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
async function openWindowAndRuntime() {
|
||||
await createWindow()
|
||||
try {
|
||||
await startRuntime()
|
||||
} catch (error) {
|
||||
setRuntimeState('error', { error: String(error) })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
registerIpc()
|
||||
|
||||
app.whenReady().then(openWindowAndRuntime)
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
void stopRuntime().finally(() => {
|
||||
@@ -554,5 +584,5 @@ app.on('window-all-closed', () => {
|
||||
})
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) void createWindow()
|
||||
if (BrowserWindow.getAllWindows().length === 0) void openWindowAndRuntime()
|
||||
})
|
||||
@@ -23,6 +23,7 @@ const api = {
|
||||
load: (sessionId) => ipcRenderer.invoke('sessions:load', { sessionId }),
|
||||
prompt: (sessionId, text) => ipcRenderer.invoke('sessions:prompt', { sessionId, text }),
|
||||
cancel: (sessionId) => ipcRenderer.invoke('sessions:cancel', { sessionId }),
|
||||
reveal: (sessionId) => ipcRenderer.invoke('sessions:reveal', { sessionId }),
|
||||
onUpdate: (callback) => {
|
||||
const listener = (_event, payload) => { callback(payload) }
|
||||
ipcRenderer.on('sessions:update', listener)
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Normalize persisted content arrays and single ACP update blocks.
|
||||
* @param value - Persisted model content or one ACP update block.
|
||||
* @returns The content blocks in encounter order.
|
||||
*/
|
||||
export function contentBlocks(value: unknown): unknown[] {
|
||||
if (Array.isArray(value)) return value
|
||||
return typeof asRecord(value).type === 'string' ? [value] : []
|
||||
}
|
||||
|
||||
/**
|
||||
* Render model content into the compact plain-text form used by the desktop UI.
|
||||
* @param value - Model content or one ACP update block.
|
||||
* @returns Plain text suitable for transcript previews.
|
||||
*/
|
||||
export function contentText(value: unknown): string {
|
||||
if (typeof value === 'string') return value
|
||||
return contentBlocks(value).map((block) => {
|
||||
const record = asRecord(block)
|
||||
if (record.type === 'text' || record.type === 'reasoning') return stringValue(record.text)
|
||||
if (record.type === 'tool-call') return `[tool-call ${stringValue(record.name)}] ${displayValue(record.arguments)}`
|
||||
if (record.type === 'resource_link') return `[resource ${stringValue(record.name)}] ${stringValue(record.uri)}`
|
||||
return JSON.stringify(record)
|
||||
}).filter(Boolean).join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract only assistant-visible text blocks from model content.
|
||||
* @param value - Persisted assistant content.
|
||||
* @returns Visible text joined by paragraph breaks.
|
||||
*/
|
||||
export function assistantText(value: unknown): string {
|
||||
if (typeof value === 'string') return value
|
||||
return contentBlocks(value)
|
||||
.filter(block => asRecord(block).type === 'text')
|
||||
.map(block => stringValue(asRecord(block).text))
|
||||
.filter(Boolean)
|
||||
.join('\n\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract only reasoning blocks from model content.
|
||||
* @param value - Persisted assistant content.
|
||||
* @returns Reasoning text joined by paragraph breaks.
|
||||
*/
|
||||
export function reasoningText(value: unknown): string {
|
||||
return contentBlocks(value)
|
||||
.filter(block => asRecord(block).type === 'reasoning')
|
||||
.map(block => stringValue(asRecord(block).text))
|
||||
.filter(Boolean)
|
||||
.join('\n\n')
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' ? value as Record<string, unknown> : {}
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string {
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
|
||||
function displayValue(value: unknown): string {
|
||||
if (typeof value === 'string') return value
|
||||
return value === undefined ? '' : JSON.stringify(value)
|
||||
}
|
||||
+1496
-215
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,385 @@
|
||||
import { assistantText, reasoningText } from './renderer-content.ts'
|
||||
|
||||
/** Minimal durable session event shape consumed by the desktop trace graph. */
|
||||
export interface TraceEvent {
|
||||
readonly type: string
|
||||
readonly seq?: number
|
||||
readonly time?: number
|
||||
readonly data?: Record<string, unknown>
|
||||
readonly sourceEventSeqs?: number[]
|
||||
readonly surfaceOp?: unknown
|
||||
}
|
||||
|
||||
/** Logical object classes shared by Chat, Trajectory, Waterfall, and Inspector. */
|
||||
export type TraceTargetKind = 'session' | 'turn' | 'step' | 'request' | 'user' | 'reasoning' | 'assistant' | 'tool' | 'context' | 'summary'
|
||||
|
||||
/** One selectable logical trace object with its complete inspector payload. */
|
||||
export interface TraceTarget {
|
||||
readonly id: string
|
||||
readonly kind: TraceTargetKind
|
||||
readonly title: string
|
||||
readonly subtitle: string
|
||||
readonly status: 'ok' | 'error' | 'running'
|
||||
readonly turn?: number
|
||||
readonly step?: number
|
||||
readonly startTime: number
|
||||
readonly endTime: number
|
||||
readonly eventSeqs: readonly number[]
|
||||
readonly input: unknown
|
||||
readonly output: unknown
|
||||
readonly metadata: unknown
|
||||
}
|
||||
|
||||
/** One ordered block in a turn's assistant response. */
|
||||
export interface ChatActivity {
|
||||
readonly kind: 'reasoning' | 'tool' | 'text'
|
||||
readonly targetId: string
|
||||
}
|
||||
|
||||
/** One user turn and its single assistant response shell. */
|
||||
export interface ChatTurn {
|
||||
readonly turn: number
|
||||
readonly userTargetId?: string
|
||||
readonly activities: readonly ChatActivity[]
|
||||
}
|
||||
|
||||
/** One logical row in the structural trajectory. */
|
||||
export interface TrajectoryRow {
|
||||
readonly targetId: string
|
||||
readonly groupId: string
|
||||
}
|
||||
|
||||
/** Turn/step grouping metadata for trajectory navigation. */
|
||||
export interface TrajectoryGroup {
|
||||
readonly id: string
|
||||
readonly turn: number
|
||||
readonly step: number | null
|
||||
readonly startTime: number
|
||||
readonly endTime: number
|
||||
readonly status: 'ok' | 'error' | 'running'
|
||||
readonly rowTargetIds: readonly string[]
|
||||
}
|
||||
|
||||
/** One timing span that resolves to the same target used by other views. */
|
||||
export interface WaterfallSpan {
|
||||
readonly targetId: string
|
||||
readonly parentTargetId?: string
|
||||
readonly depth: number
|
||||
}
|
||||
|
||||
/** Single normalized source consumed by every session analysis view. */
|
||||
export interface TraceGraph {
|
||||
readonly sessionId: string
|
||||
readonly startTime: number
|
||||
readonly endTime: number
|
||||
readonly targets: ReadonlyMap<string, TraceTarget>
|
||||
readonly chatTurns: readonly ChatTurn[]
|
||||
readonly trajectoryGroups: readonly TrajectoryGroup[]
|
||||
readonly trajectoryRows: readonly TrajectoryRow[]
|
||||
readonly waterfallSpans: readonly WaterfallSpan[]
|
||||
}
|
||||
|
||||
interface MutableTarget {
|
||||
id: string
|
||||
kind: TraceTargetKind
|
||||
title: string
|
||||
subtitle: string
|
||||
status: 'ok' | 'error' | 'running'
|
||||
turn?: number
|
||||
step?: number
|
||||
startTime: number
|
||||
endTime: number
|
||||
eventSeqs: number[]
|
||||
input: unknown
|
||||
output: unknown
|
||||
metadata: unknown
|
||||
}
|
||||
|
||||
interface MutableGroup {
|
||||
id: string
|
||||
turn: number
|
||||
step: number | null
|
||||
startTime: number
|
||||
endTime: number
|
||||
status: 'ok' | 'error' | 'running'
|
||||
rowTargetIds: string[]
|
||||
}
|
||||
|
||||
interface MutableChatTurn {
|
||||
turn: number
|
||||
userTargetId?: string
|
||||
activities: ChatActivity[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold raw session events into one graph shared by every desktop trace view.
|
||||
* @param sessionId - Session that owns the events.
|
||||
* @param events - Durable events in log order.
|
||||
* @returns Logical targets, structural groups, chat turns, and timing spans.
|
||||
*/
|
||||
export function buildTraceGraph(sessionId: string, events: readonly TraceEvent[]): TraceGraph {
|
||||
const firstTime = events.find(event => event.time !== undefined)?.time ?? 0
|
||||
const lastTime = [...events].reverse().find(event => event.time !== undefined)?.time ?? firstTime
|
||||
const targets = new Map<string, MutableTarget>()
|
||||
const chatTurns = new Map<number, MutableChatTurn>()
|
||||
const groups: MutableGroup[] = []
|
||||
const groupById = new Map<string, MutableGroup>()
|
||||
const rows: TrajectoryRow[] = []
|
||||
const spans: WaterfallSpan[] = []
|
||||
const toolTargets = new Map<string, MutableTarget>()
|
||||
const reasoningTargets = new Map<string, MutableTarget>()
|
||||
const requestByStep = new Map<string, MutableTarget>()
|
||||
let latestRequestInput: unknown = ''
|
||||
let currentTurn = 0
|
||||
let currentStep: number | null = null
|
||||
let currentGroup: MutableGroup | undefined
|
||||
|
||||
targets.set(`session:${sessionId}`, {
|
||||
id: `session:${sessionId}`,
|
||||
kind: 'session',
|
||||
title: sessionId,
|
||||
subtitle: `${events.length} events`,
|
||||
status: 'ok',
|
||||
startTime: firstTime,
|
||||
endTime: lastTime,
|
||||
eventSeqs: events.flatMap(event => event.seq === undefined ? [] : [event.seq]),
|
||||
input: '',
|
||||
output: '',
|
||||
metadata: { sessionId, eventCount: events.length },
|
||||
})
|
||||
|
||||
const getChatTurn = (turn: number): MutableChatTurn => {
|
||||
const existing = chatTurns.get(turn)
|
||||
if (existing !== undefined) return existing
|
||||
const created: MutableChatTurn = { turn, activities: [] }
|
||||
chatTurns.set(turn, created)
|
||||
return created
|
||||
}
|
||||
const addTarget = (target: MutableTarget, group: MutableGroup | null | undefined = currentGroup): MutableTarget => {
|
||||
targets.set(target.id, target)
|
||||
if (group !== undefined && group !== null) {
|
||||
group.rowTargetIds.push(target.id)
|
||||
rows.push({ targetId: target.id, groupId: group.id })
|
||||
}
|
||||
return target
|
||||
}
|
||||
const ensureGroup = (turn: number, step: number | null, time: number): MutableGroup => {
|
||||
const id = step === null ? `turn:${turn}:input` : `step:${turn}:${step}`
|
||||
const existing = groupById.get(id)
|
||||
if (existing !== undefined) return existing
|
||||
const created: MutableGroup = { id, turn, step, startTime: time, endTime: time, status: 'running', rowTargetIds: [] }
|
||||
groups.push(created)
|
||||
groupById.set(id, created)
|
||||
return created
|
||||
}
|
||||
const seqs = (event: TraceEvent): number[] => event.seq === undefined ? [] : [event.seq]
|
||||
const stepKey = (turn: number, step: number): string => `${turn}:${step}`
|
||||
|
||||
for (const event of events) {
|
||||
const data = asRecord(event.data)
|
||||
const time = event.time ?? lastTime
|
||||
if (event.type === 'turn/start') {
|
||||
currentTurn = numberValue(data.turn, currentTurn + 1)
|
||||
currentStep = null
|
||||
currentGroup = ensureGroup(currentTurn, null, time)
|
||||
const target = addTarget({
|
||||
id: `turn:${currentTurn}`,
|
||||
kind: 'turn',
|
||||
title: `Turn ${currentTurn}`,
|
||||
subtitle: stringValue(asRecord(data.trigger).kind) || 'turn',
|
||||
status: 'running',
|
||||
turn: currentTurn,
|
||||
startTime: time,
|
||||
endTime: time,
|
||||
eventSeqs: seqs(event),
|
||||
input: data.trigger ?? '',
|
||||
output: '',
|
||||
metadata: event,
|
||||
}, null)
|
||||
spans.push({ targetId: target.id, depth: 0 })
|
||||
getChatTurn(currentTurn)
|
||||
continue
|
||||
}
|
||||
const turn = numberValue(data.turn, currentTurn)
|
||||
if (turn > 0) currentTurn = turn
|
||||
if (event.type === 'turn/end') {
|
||||
const target = targets.get(`turn:${turn}`)
|
||||
if (target !== undefined) {
|
||||
target.endTime = time
|
||||
target.status = stringValue(asRecord(data.reason).kind) === 'completed' ? 'ok' : 'error'
|
||||
target.output = data.reason ?? ''
|
||||
target.eventSeqs.push(...seqs(event))
|
||||
}
|
||||
for (const group of groups.filter(group => group.turn === turn && group.status === 'running')) {
|
||||
group.endTime = Math.max(group.endTime, time)
|
||||
group.status = target?.status ?? 'ok'
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (event.type === 'step/start') {
|
||||
currentStep = numberValue(data.step, 0)
|
||||
currentGroup = ensureGroup(turn, currentStep, time)
|
||||
const target = addTarget({
|
||||
id: `step:${turn}:${currentStep}`,
|
||||
kind: 'step',
|
||||
title: `Step ${currentStep}`,
|
||||
subtitle: `Turn ${turn}`,
|
||||
status: 'running',
|
||||
turn,
|
||||
step: currentStep,
|
||||
startTime: time,
|
||||
endTime: time,
|
||||
eventSeqs: seqs(event),
|
||||
input: '',
|
||||
output: '',
|
||||
metadata: event,
|
||||
}, null)
|
||||
spans.push({ targetId: target.id, parentTargetId: `turn:${turn}`, depth: 1 })
|
||||
continue
|
||||
}
|
||||
const step = numberValue(data.step, currentStep ?? 0)
|
||||
if (event.type === 'step/end') {
|
||||
const target = targets.get(`step:${turn}:${step}`)
|
||||
if (target !== undefined) {
|
||||
target.endTime = time
|
||||
target.status = 'ok'
|
||||
target.eventSeqs.push(...seqs(event))
|
||||
}
|
||||
const group = groupById.get(`step:${turn}:${step}`)
|
||||
if (group !== undefined) {
|
||||
group.endTime = time
|
||||
group.status = group.status === 'error' ? 'error' : 'ok'
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (currentGroup === undefined || currentGroup.turn !== turn || currentGroup.step !== (step || null)) {
|
||||
currentGroup = ensureGroup(turn, step > 0 ? step : null, time)
|
||||
}
|
||||
currentGroup.endTime = Math.max(currentGroup.endTime, time)
|
||||
const chatTurn = getChatTurn(turn)
|
||||
|
||||
if (event.type === 'user/message') {
|
||||
const id = `user:${event.seq ?? currentGroup.rowTargetIds.length}`
|
||||
addTarget({ id, kind: 'user', title: 'User message', subtitle: `Turn ${turn}`, status: 'ok', turn, startTime: time, endTime: time, eventSeqs: seqs(event), input: '', output: data.content ?? '', metadata: event })
|
||||
chatTurn.userTargetId = id
|
||||
} else if (event.type === 'request/header' || event.type === 'request/header-delta') {
|
||||
const id = `request:${event.seq ?? currentGroup.rowTargetIds.length}`
|
||||
const header = asRecord(data.header)
|
||||
const input = event.type === 'request/header' ? header : data
|
||||
const target = addTarget({ id, kind: 'request', title: event.type, subtitle: `Turn ${turn} · Step ${step}`, status: 'ok', turn, step, startTime: time, endTime: time, eventSeqs: seqs(event), input, output: '', metadata: event }, null)
|
||||
requestByStep.set(stepKey(turn, step), target)
|
||||
latestRequestInput = input
|
||||
} else if (event.type === 'assistant/chunk') {
|
||||
const chunk = asRecord(data.chunk)
|
||||
if (chunk.type === 'reasoning-delta') {
|
||||
const key = stepKey(turn, step)
|
||||
let target = reasoningTargets.get(key)
|
||||
if (target === undefined) {
|
||||
const id = `reasoning:${turn}:${step}`
|
||||
target = addTarget({ id, kind: 'reasoning', title: 'Thinking', subtitle: `Turn ${turn} · Step ${step}`, status: 'running', turn, step, startTime: time, endTime: time, eventSeqs: [], input: requestByStep.get(key)?.input ?? latestRequestInput, output: '', metadata: { chunks: [] as TraceEvent[] } })
|
||||
reasoningTargets.set(key, target)
|
||||
chatTurn.activities.push({ kind: 'reasoning', targetId: id })
|
||||
}
|
||||
target.endTime = time
|
||||
target.eventSeqs.push(...seqs(event))
|
||||
target.output = `${stringValue(target.output)}${stringValue(chunk.text)}`
|
||||
const metadata = asRecord(target.metadata)
|
||||
const chunks = metadata.chunks as TraceEvent[]
|
||||
chunks.push(event)
|
||||
target.metadata = { chunks }
|
||||
}
|
||||
} else if (event.type === 'tool/call') {
|
||||
const callId = stringValue(data.callId)
|
||||
const id = `tool:${callId}`
|
||||
const target = addTarget({ id, kind: 'tool', title: stringValue(data.name) || 'Tool', subtitle: callId, status: 'running', turn, step, startTime: time, endTime: time, eventSeqs: seqs(event), input: parseMaybeJson(data.arguments ?? data.rawInput ?? data), output: '', metadata: { call: event } })
|
||||
toolTargets.set(callId, target)
|
||||
chatTurn.activities.push({ kind: 'tool', targetId: id })
|
||||
spans.push({ targetId: id, parentTargetId: `step:${turn}:${step}`, depth: 2 })
|
||||
} else if (event.type === 'tool/result') {
|
||||
const callId = stringValue(data.callId)
|
||||
const target = toolTargets.get(callId)
|
||||
if (target !== undefined) {
|
||||
target.endTime = time
|
||||
target.status = data.isError === true ? 'error' : 'ok'
|
||||
target.eventSeqs.push(...seqs(event))
|
||||
target.output = { content: data.content, isError: data.isError, error: data.error, meta: data.meta }
|
||||
target.metadata = { ...asRecord(target.metadata), result: event }
|
||||
if (target.status === 'error') currentGroup.status = 'error'
|
||||
}
|
||||
} else if (event.type === 'assistant/message') {
|
||||
const reasoning = reasoningText(data.content)
|
||||
const key = stepKey(turn, step)
|
||||
if (reasoning.length > 0 && !reasoningTargets.has(key)) {
|
||||
const id = `reasoning:${turn}:${step}`
|
||||
const target = addTarget({ id, kind: 'reasoning', title: 'Thinking', subtitle: `Turn ${turn} · Step ${step}`, status: 'ok', turn, step, startTime: time, endTime: time, eventSeqs: seqs(event), input: requestByStep.get(key)?.input ?? latestRequestInput, output: reasoning, metadata: event })
|
||||
reasoningTargets.set(key, target)
|
||||
chatTurn.activities.push({ kind: 'reasoning', targetId: id })
|
||||
} else {
|
||||
const target = reasoningTargets.get(key)
|
||||
if (target !== undefined) target.status = 'ok'
|
||||
}
|
||||
const text = assistantText(data.content)
|
||||
const id = `assistant:${event.seq ?? currentGroup.rowTargetIds.length}`
|
||||
addTarget({ id, kind: 'assistant', title: 'Model response', subtitle: `Turn ${turn} · Step ${step}`, status: 'ok', turn, step, startTime: requestByStep.get(key)?.startTime ?? time, endTime: time, eventSeqs: seqs(event), input: requestByStep.get(key)?.input ?? latestRequestInput, output: data.content ?? text, metadata: { event, usage: data.usage } })
|
||||
spans.push({ targetId: id, parentTargetId: `step:${turn}:${step}`, depth: 2 })
|
||||
if (text.length > 0) {
|
||||
chatTurn.activities.push({ kind: 'text', targetId: id })
|
||||
}
|
||||
} else if (event.type === 'context/message' || event.type === 'steering/message') {
|
||||
const id = `context:${event.seq ?? currentGroup.rowTargetIds.length}`
|
||||
addTarget({ id, kind: 'context', title: event.type, subtitle: `Turn ${turn} · Step ${step}`, status: 'ok', turn, step, startTime: time, endTime: time, eventSeqs: seqs(event), input: '', output: data.content ?? '', metadata: event })
|
||||
}
|
||||
}
|
||||
|
||||
for (const target of targets.values()) {
|
||||
if (target.status === 'running' && target.kind !== 'session') target.status = 'ok'
|
||||
}
|
||||
const assistantTargets = [...targets.values()].filter(target => target.kind === 'assistant')
|
||||
const pairedToolTargets = [...targets.values()].filter(target => target.kind === 'tool')
|
||||
const errorTargets = [...targets.values()].filter(target => target.status === 'error')
|
||||
const slowestStep = [...targets.values()].filter(target => target.kind === 'step').sort((a, b) => (b.endTime - b.startTime) - (a.endTime - a.startTime))[0]
|
||||
const summaries: MutableTarget[] = [
|
||||
summaryTarget('summary:total', 'Total', lastTime - firstTime, firstTime, lastTime, { eventCount: events.length }),
|
||||
summaryTarget('summary:llm', 'LLM time', assistantTargets.reduce((sum, target) => sum + target.endTime - target.startTime, 0), firstTime, lastTime, { targets: assistantTargets.map(target => target.id) }),
|
||||
summaryTarget('summary:tools', 'Tool time', pairedToolTargets.reduce((sum, target) => sum + target.endTime - target.startTime, 0), firstTime, lastTime, { targets: pairedToolTargets.map(target => target.id) }),
|
||||
summaryTarget('summary:errors', 'Errors', errorTargets.length, firstTime, lastTime, { targets: errorTargets.map(target => target.id) }),
|
||||
summaryTarget('summary:slowest', 'Slowest step', slowestStep === undefined ? 0 : slowestStep.endTime - slowestStep.startTime, firstTime, lastTime, { target: slowestStep?.id }),
|
||||
]
|
||||
for (const target of summaries) targets.set(target.id, target)
|
||||
return {
|
||||
sessionId,
|
||||
startTime: firstTime,
|
||||
endTime: lastTime,
|
||||
targets: new Map([...targets].map(([id, target]) => [id, Object.freeze({ ...target, eventSeqs: [...target.eventSeqs] })])),
|
||||
chatTurns: [...chatTurns.values()].filter(turn => turn.userTargetId !== undefined || turn.activities.length > 0),
|
||||
trajectoryGroups: groups.map(group => ({ ...group, rowTargetIds: [...group.rowTargetIds] })),
|
||||
trajectoryRows: rows,
|
||||
waterfallSpans: spans,
|
||||
}
|
||||
}
|
||||
|
||||
function summaryTarget(id: string, title: string, value: number, startTime: number, endTime: number, metadata: unknown): MutableTarget {
|
||||
return { id, kind: 'summary', title, subtitle: String(value), status: 'ok', startTime, endTime, eventSeqs: [], input: '', output: value, metadata }
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' ? value as Record<string, unknown> : {}
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string {
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
|
||||
function numberValue(value: unknown, fallback: number): number {
|
||||
const number = Number(value)
|
||||
return Number.isFinite(number) ? number : fallback
|
||||
}
|
||||
|
||||
function parseMaybeJson(value: unknown): unknown {
|
||||
if (typeof value !== 'string') return value
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
@@ -48,9 +48,9 @@ describe('desktop surface policies', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the composer scoped to chat only', () => {
|
||||
it('keeps the session composer across the three live views', () => {
|
||||
for (const surface of DESKTOP_SURFACES) {
|
||||
expect(ownsComposer(surface)).toBe(surface === 'chat')
|
||||
expect(ownsComposer(surface)).toBe(['chat', 'trajectory', 'waterfall'].includes(surface))
|
||||
}
|
||||
})
|
||||
|
||||
@@ -71,6 +71,11 @@ describe('desktop inspector contracts', () => {
|
||||
kind: 'tool-call',
|
||||
eventSeq: 42,
|
||||
})).toBe('session:s1:run:r1:kind:tool-call:seq:42')
|
||||
expect(createInspectorTargetId({
|
||||
sessionId: 's1',
|
||||
kind: 'message',
|
||||
syntheticId: 'draft',
|
||||
})).toBe('session:s1:kind:message:synthetic:draft')
|
||||
})
|
||||
|
||||
it('opens output by default for produced data and metadata for structural targets', () => {
|
||||
@@ -85,6 +90,21 @@ describe('desktop inspector contracts', () => {
|
||||
kind: 'step',
|
||||
title: 'step 1',
|
||||
}).activeTab).toBe('metadata')
|
||||
|
||||
const expected = new Map<InspectorTarget['kind'], string>([
|
||||
['assistant-stream', 'output'],
|
||||
['tool-result', 'output'],
|
||||
['session', 'metadata'],
|
||||
['run', 'metadata'],
|
||||
['turn', 'metadata'],
|
||||
['step', 'metadata'],
|
||||
['waterfall-span', 'metadata'],
|
||||
['dev-object', 'input'],
|
||||
['message', 'input'],
|
||||
])
|
||||
for (const [kind, tab] of expected) {
|
||||
expect(openInspectorState({ ...target, kind }).activeTab).toBe(tab)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps inspector tabs ordered with feedback last', () => {
|
||||
@@ -109,4 +129,12 @@ describe('desktop i18n', () => {
|
||||
expect(translate('zh-CN', 'app.language')).toBe('EN')
|
||||
expect(translate('en-US', 'app.language')).toBe('中文')
|
||||
})
|
||||
|
||||
it('keeps core Chinese labels localized rather than falling back to English', () => {
|
||||
expect(translate('zh-CN', 'app.develop')).toBe('开发')
|
||||
expect(translate('zh-CN', 'surface.trajectory')).toBe('轨迹')
|
||||
expect(translate('zh-CN', 'chat.thinking')).toBe('思考')
|
||||
expect(translate('zh-CN', 'dev.requestSystemPrompt')).toBe('当前请求的系统提示词')
|
||||
expect(translate('zh-CN', 'inspector.feedback')).toBe('反馈')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { assistantText, contentBlocks, contentText, reasoningText } from '../src/renderer-content.ts'
|
||||
|
||||
describe('desktop live content', () => {
|
||||
it('renders single ACP update blocks before the persisted message exists', () => {
|
||||
expect(contentText({ type: 'text', text: '你' })).toBe('你')
|
||||
expect(contentText({ type: 'reasoning', text: '想' })).toBe('想')
|
||||
})
|
||||
|
||||
it('accepts plain strings, arrays, and empty values', () => {
|
||||
const blocks = [{ type: 'text', text: 'a' }]
|
||||
expect(contentBlocks(blocks)).toBe(blocks)
|
||||
expect(contentBlocks(null)).toEqual([])
|
||||
expect(contentText('plain')).toBe('plain')
|
||||
expect(assistantText('plain')).toBe('plain')
|
||||
})
|
||||
|
||||
it('keeps persisted content arrays split by visible role', () => {
|
||||
const content = [
|
||||
{ type: 'reasoning', text: '先想' },
|
||||
{ type: 'text', text: '再答' },
|
||||
]
|
||||
expect(reasoningText(content)).toBe('先想')
|
||||
expect(assistantText(content)).toBe('再答')
|
||||
})
|
||||
|
||||
it('renders tool, resource, and unknown blocks without object coercion', () => {
|
||||
expect(contentText({ type: 'tool-call', name: 'bash', arguments: { command: 'pwd' } }))
|
||||
.toBe('[tool-call bash] {"command":"pwd"}')
|
||||
expect(contentText({ type: 'resource_link', name: 'notes', uri: 'file:///notes' }))
|
||||
.toBe('[resource notes] file:///notes')
|
||||
expect(contentText({ type: 'custom', value: 1 })).toBe('{"type":"custom","value":1}')
|
||||
expect(contentText({ type: 'text', text: 1 })).toBe('')
|
||||
expect(contentText({ type: 'tool-call', name: 1, arguments: 'pwd' })).toBe('[tool-call ] pwd')
|
||||
expect(contentText({ type: 'tool-call', name: 'bash' })).toBe('[tool-call bash] ')
|
||||
expect(contentText({ type: 'resource_link' })).toBe('[resource ] ')
|
||||
expect(reasoningText([{ type: 'reasoning', text: 1 }])).toBe('')
|
||||
expect(contentText(undefined)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('desktop shell layout', () => {
|
||||
it('pins the composer to its intrinsic bottom row', async () => {
|
||||
const css = await readFile(new URL('../src/styles.css', import.meta.url), 'utf8')
|
||||
expect(css).toMatch(/\.session-canvas,\s*\.module-canvas\s*{\s*grid-row: 3;/)
|
||||
expect(css).toMatch(/\.composer\s*{\s*grid-row: 4;/)
|
||||
})
|
||||
|
||||
it('does not launch Electron after a strict-port Vite failure', async () => {
|
||||
const script = await readFile(new URL('../scripts/dev.mjs', import.meta.url), 'utf8')
|
||||
expect(script).toContain("'--strictPort'")
|
||||
expect(script).not.toContain('setTimeout(startElectron')
|
||||
})
|
||||
|
||||
it('suppresses persisted ACP replay updates before forwarding a new prompt', async () => {
|
||||
const main = await readFile(new URL('../src/main.mjs', import.meta.url), 'utf8')
|
||||
expect(main).toContain('replayingSessions.has(String(params.sessionId))')
|
||||
expect(main).toContain('replayingSessions.add(sessionId)')
|
||||
expect(main).toContain('replayingSessions.delete(sessionId)')
|
||||
})
|
||||
|
||||
it('switches from Develop to Sessions and patches artifact detail in place', async () => {
|
||||
const app = await readFile(new URL('../src/app.ts', import.meta.url), 'utf8')
|
||||
expect(app).toMatch(/if \(sessionButton !== null\) \{\s*showModule\('sessions'\)\s*await loadTrace/)
|
||||
expect(app).toContain("selectDevArtifact(devArtifact.dataset.devArtifact ?? '')")
|
||||
expect(app).toContain('detail.innerHTML = renderDevArtifactDetail(selected)')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,237 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {} from '../src/global.d.ts'
|
||||
|
||||
interface Deferred<T> {
|
||||
readonly promise: Promise<T>
|
||||
resolve(value: T): void
|
||||
}
|
||||
|
||||
function deferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((resolvePromise) => {
|
||||
resolve = resolvePromise
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe('desktop renderer chat lifecycle', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
localStorage.clear()
|
||||
document.body.innerHTML = '<div id="app"></div>'
|
||||
Object.defineProperty(globalThis, 'CSS', {
|
||||
configurable: true,
|
||||
value: { escape: (value: string) => value.replaceAll(':', '\\:') },
|
||||
})
|
||||
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves drafts while streaming a single ACP block and exits the completed state', async () => {
|
||||
const firstPrompt = deferred<unknown>()
|
||||
const secondPrompt = deferred<unknown>()
|
||||
const promptQueue = [firstPrompt, secondPrompt]
|
||||
let update: ((payload: unknown) => void) | undefined
|
||||
let sessions: unknown[] = []
|
||||
let traceRead: unknown
|
||||
const completedTrace = {
|
||||
found: true,
|
||||
sessionId: 's-new',
|
||||
header: { id: 's-new' },
|
||||
rawText: '',
|
||||
feedback: [],
|
||||
events: [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' } } },
|
||||
{ type: 'user/message', seq: 1, time: 2, data: { content: [{ type: 'text', text: 'hello' }] } },
|
||||
{ type: 'step/start', seq: 2, time: 3, data: { turn: 1, step: 1 } },
|
||||
{ type: 'assistant/chunk', seq: 3, time: 4, data: { turn: 1, step: 1, chunk: { type: 'reasoning-delta', text: 'why' } } },
|
||||
{ type: 'assistant/message', seq: 4, time: 5, data: { turn: 1, step: 1, content: [{ type: 'reasoning', text: 'why' }, { type: 'text', text: 'final answer' }] } },
|
||||
{ type: 'tool/call', seq: 5, time: 6, data: { turn: 1, step: 1, callId: 'call-1', name: 'bash', arguments: '{"command":"pwd"}' } },
|
||||
{ type: 'tool/result', seq: 6, time: 7, data: { turn: 1, step: 1, callId: 'call-1', content: [{ type: 'text', text: '/repo' }] } },
|
||||
{ type: 'step/end', seq: 7, time: 8, data: { turn: 1, step: 1 } },
|
||||
{ type: 'turn/end', seq: 8, time: 9, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
],
|
||||
}
|
||||
const secondTrace = {
|
||||
...completedTrace,
|
||||
events: [
|
||||
...completedTrace.events,
|
||||
{ type: 'turn/start', seq: 9, time: 10, data: { turn: 2, trigger: { kind: 'message' } } },
|
||||
{ type: 'user/message', seq: 10, time: 11, data: { content: [{ type: 'text', text: 'second' }] } },
|
||||
{ type: 'step/start', seq: 11, time: 12, data: { turn: 2, step: 1 } },
|
||||
{ type: 'assistant/message', seq: 12, time: 13, data: { turn: 2, step: 1, content: [{ type: 'text', text: 'second answer' }] } },
|
||||
{ type: 'step/end', seq: 13, time: 14, data: { turn: 2, step: 1 } },
|
||||
{ type: 'turn/end', seq: 14, time: 15, data: { turn: 2, reason: { kind: 'completed' } } },
|
||||
],
|
||||
}
|
||||
traceRead = completedTrace
|
||||
|
||||
window.dshDesktop = {
|
||||
runtime: {
|
||||
start: async () => ({}),
|
||||
stop: async () => ({}),
|
||||
restart: async () => ({}),
|
||||
status: async () => ({ state: 'running', repoRoot: '/repo' }),
|
||||
onStatus: () => () => {},
|
||||
onStderr: () => () => {},
|
||||
},
|
||||
sessions: {
|
||||
list: async () => ({ sessions }),
|
||||
create: async () => ({ sessionId: 's-new', trace: { ...completedTrace, events: [] } }),
|
||||
load: async () => ({}),
|
||||
prompt: async () => promptQueue.shift()!.promise,
|
||||
cancel: async () => ({}),
|
||||
reveal: async () => ({}),
|
||||
onUpdate: (callback: (payload: unknown) => void) => {
|
||||
update = callback
|
||||
return () => {}
|
||||
},
|
||||
},
|
||||
trace: { read: async () => traceRead },
|
||||
feedback: { list: async () => [], add: async () => ({}) },
|
||||
dev: { status: async () => ({ git: {} }) },
|
||||
}
|
||||
|
||||
await import('../src/app.ts')
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector('#composerInput')).not.toBeNull()
|
||||
})
|
||||
|
||||
const newSession = document.querySelector<HTMLButtonElement>('[data-action="new-session"]')!
|
||||
newSession.click()
|
||||
const composer = document.querySelector<HTMLTextAreaElement>('#composerInput')!
|
||||
composer.value = 'hello'
|
||||
composer.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
document.querySelector<HTMLFormElement>('#composerForm')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector('#topbarTitle')?.textContent).toBe('hello')
|
||||
})
|
||||
const search = document.querySelector<HTMLInputElement>('#sessionSearch')!
|
||||
search.value = 'keep search'
|
||||
search.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
composer.value = 'next draft'
|
||||
composer.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
|
||||
const chatView = document.querySelector<HTMLElement>('#chatView')!
|
||||
Object.defineProperties(chatView, {
|
||||
scrollHeight: { configurable: true, value: 1000 },
|
||||
clientHeight: { configurable: true, value: 400 },
|
||||
scrollTop: { configurable: true, writable: true, value: 100 },
|
||||
})
|
||||
chatView.dispatchEvent(new Event('scroll'))
|
||||
update?.({ sessionId: 's-new', update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'streamed' } } })
|
||||
expect(document.querySelector('[data-live="answer"]')?.textContent).toBe('streamed')
|
||||
expect(document.querySelector('#liveTurn .message.live')).not.toBeNull()
|
||||
expect(document.querySelector<HTMLButtonElement>('#liveJump')?.hidden).toBe(false)
|
||||
expect(composer.value).toBe('next draft')
|
||||
expect(search.value).toBe('keep search')
|
||||
|
||||
document.querySelector<HTMLButtonElement>('#liveJump')!.click()
|
||||
expect(chatView.scrollTop).toBe(1000)
|
||||
expect(document.querySelector<HTMLButtonElement>('#liveJump')?.hidden).toBe(true)
|
||||
|
||||
sessions = [{
|
||||
id: 's-new',
|
||||
title: 'hello',
|
||||
createdAt: 1,
|
||||
lastActivity: 6,
|
||||
eventCount: 6,
|
||||
turnCount: 1,
|
||||
stepCount: 1,
|
||||
toolCallCount: 0,
|
||||
live: true,
|
||||
}]
|
||||
firstPrompt.resolve({ response: { stopReason: 'end_turn' }, trace: completedTrace })
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector('#conversation')?.textContent).toContain('final answer')
|
||||
})
|
||||
expect(document.querySelector<HTMLButtonElement>('#cancelButton')?.hidden).toBe(true)
|
||||
expect(document.querySelector('#liveTurn')?.textContent).not.toContain('正在生成')
|
||||
expect(composer.value).toBe('next draft')
|
||||
|
||||
composer.value = 'second'
|
||||
composer.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
document.querySelector<HTMLFormElement>('#composerForm')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
|
||||
update?.({ sessionId: 's-new', update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'second streamed' } } })
|
||||
secondPrompt.resolve({ response: { stopReason: 'end_turn' }, trace: completedTrace })
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelectorAll('#conversation .message')).toHaveLength(2)
|
||||
expect(document.querySelector('#liveTurn')?.textContent).toContain('second streamed')
|
||||
})
|
||||
|
||||
traceRead = secondTrace
|
||||
document.querySelector<HTMLButtonElement>('[data-surface="trajectory"]')!.click()
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelectorAll('#conversation .message')).toHaveLength(4)
|
||||
})
|
||||
expect([...document.querySelectorAll('#conversation .message')].map(node => node.textContent))
|
||||
.toEqual(expect.arrayContaining([
|
||||
expect.stringContaining('hello'),
|
||||
expect.stringContaining('final answer'),
|
||||
expect.stringContaining('second'),
|
||||
expect.stringContaining('second answer'),
|
||||
]))
|
||||
expect(document.querySelector('#liveTurn')?.textContent).toBe('')
|
||||
|
||||
const firstAssistant = document.querySelectorAll<HTMLElement>('.message.assistant')[0]!
|
||||
const thinking = firstAssistant.querySelector<HTMLElement>('.chat-activity.thinking')!
|
||||
const thinkingButton = thinking.querySelector<HTMLButtonElement>('.activity-select')!
|
||||
thinkingButton.click()
|
||||
expect({
|
||||
targetId: thinkingButton.dataset.targetId,
|
||||
kind: document.querySelector('#inspectorKind')?.textContent,
|
||||
title: document.querySelector('#inspectorTitle')?.textContent,
|
||||
}).toEqual({ targetId: 'reasoning:1:1', kind: '思考', title: '思考' })
|
||||
|
||||
const tool = firstAssistant.querySelector<HTMLElement>('.chat-activity.tool-use')!
|
||||
tool.querySelector<HTMLButtonElement>('.activity-select')!.click()
|
||||
expect(document.querySelector('#inspectorKind')?.textContent).toBe('工具')
|
||||
|
||||
document.querySelectorAll<HTMLElement>('[data-target-id^="assistant:"]')[1]!.click()
|
||||
expect(document.querySelector<HTMLElement>('#inspector')?.hidden).toBe(false)
|
||||
expect(document.querySelector('#inspectorTitle')?.textContent).toBe('回复')
|
||||
|
||||
document.querySelector<HTMLButtonElement>('[data-action="close-inspector"]')!.click()
|
||||
search.value = ''
|
||||
search.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
document.querySelector<HTMLButtonElement>('[data-module="develop"]')!.click()
|
||||
const rail = document.querySelector<HTMLElement>('.develop-artifact-rail')!
|
||||
const detail = document.querySelector<HTMLElement>('.develop-artifact-detail')!
|
||||
rail.scrollTop = 180
|
||||
detail.scrollTop = 220
|
||||
document.querySelectorAll<HTMLButtonElement>('[data-dev-artifact]')[1]!.click()
|
||||
expect(document.querySelector('.develop-artifact-rail')).toBe(rail)
|
||||
expect(document.querySelector('.develop-artifact-detail')).toBe(detail)
|
||||
expect(rail.scrollTop).toBe(180)
|
||||
expect(detail.scrollTop).toBe(220)
|
||||
|
||||
document.querySelector<HTMLButtonElement>('[data-session="s-new"]')!.click()
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector<HTMLElement>('#sessionCanvas')?.hidden).toBe(false)
|
||||
expect(document.querySelector<HTMLElement>('#devCanvas')?.hidden).toBe(true)
|
||||
})
|
||||
|
||||
document.querySelector<HTMLButtonElement>('[data-surface="waterfall"]')!.click()
|
||||
const chatToolTarget = document.querySelector<HTMLElement>('.chat-activity.tool-use [data-target-id]')?.dataset.targetId
|
||||
const trajectoryToolTarget = document.querySelector<HTMLElement>('.traj-row.tool')?.dataset.targetId
|
||||
const waterfallToolTarget = document.querySelector<HTMLElement>('.wf-bar.tool')?.dataset.targetId
|
||||
expect(chatToolTarget).toBe('tool:call-1')
|
||||
expect(trajectoryToolTarget).toBe(chatToolTarget)
|
||||
expect(waterfallToolTarget).toBe(chatToolTarget)
|
||||
document.querySelector<HTMLButtonElement>('.wf-bar')!.click()
|
||||
expect(document.querySelector('#wfView')?.classList.contains('active')).toBe(true)
|
||||
expect(document.querySelector('#trajView')?.classList.contains('active')).toBe(false)
|
||||
expect(document.querySelector<HTMLElement>('#inspector')?.hidden).toBe(false)
|
||||
expect(document.querySelector('#inspectorKind')?.textContent).toBe('轮次')
|
||||
|
||||
document.querySelector<HTMLButtonElement>('[data-action="jump-traj"]')!.click()
|
||||
expect(document.querySelector('#trajView')?.classList.contains('active')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildTraceGraph, type TraceEvent } from '../src/trace-graph.ts'
|
||||
|
||||
describe('desktop trace graph', () => {
|
||||
it('shares one paired tool target across chat, trajectory, waterfall, and inspector payloads', () => {
|
||||
const graph = buildTraceGraph('s1', fixture())
|
||||
const tool = graph.targets.get('tool:c1')!
|
||||
expect(tool.input).toEqual({ command: 'pwd' })
|
||||
expect(tool.output).toMatchObject({ content: [{ type: 'text', text: '/repo' }], isError: false })
|
||||
expect(graph.chatTurns[0]?.activities).toContainEqual({ kind: 'tool', targetId: 'tool:c1' })
|
||||
expect(graph.trajectoryRows.filter(row => row.targetId === 'tool:c1')).toHaveLength(1)
|
||||
expect(graph.waterfallSpans.filter(span => span.targetId === 'tool:c1')).toHaveLength(1)
|
||||
expect(graph.trajectoryRows.some(row => row.targetId.includes('result'))).toBe(false)
|
||||
expect(graph.trajectoryRows.some(row => ['turn', 'step'].includes(graph.targets.get(row.targetId)?.kind ?? ''))).toBe(false)
|
||||
expect(graph.trajectoryRows.some(row => graph.targets.get(row.targetId)?.kind === 'request')).toBe(false)
|
||||
})
|
||||
|
||||
it('groups multiple model steps into one chat response while preserving selectable blocks', () => {
|
||||
const graph = buildTraceGraph('s1', fixture())
|
||||
expect(graph.chatTurns).toHaveLength(1)
|
||||
expect(graph.chatTurns[0]?.activities.map(activity => activity.targetId)).toEqual([
|
||||
'reasoning:1:1',
|
||||
'tool:c1',
|
||||
'reasoning:1:2',
|
||||
'assistant:14',
|
||||
])
|
||||
expect(graph.trajectoryRows.map(row => row.targetId)).toContain('assistant:6')
|
||||
expect(graph.targets.get('reasoning:1:1')?.output).toBe('think one')
|
||||
expect(graph.targets.get('assistant:14')?.output).toEqual([
|
||||
{ type: 'reasoning', text: 'think two' },
|
||||
{ type: 'text', text: 'done' },
|
||||
])
|
||||
})
|
||||
|
||||
it('normalizes incomplete and malformed event tails without inventing duplicate rows', () => {
|
||||
expect(buildTraceGraph('empty', []).startTime).toBe(0)
|
||||
const graph = buildTraceGraph('edge', [
|
||||
{ type: 'context/message', data: { content: [{ type: 'text', text: 'orphan context' }] } },
|
||||
{ type: 'turn/end', data: { turn: 99, reason: { kind: 'error' } } },
|
||||
{ type: 'context/message', data: { turn: 99, content: 'late orphan context' } },
|
||||
{ type: 'turn/end', data: { turn: 99 } },
|
||||
{ type: 'step/end', data: { turn: 99, step: 9 } },
|
||||
{ type: 'turn/start', data: {} },
|
||||
{ type: 'user/message', data: {} },
|
||||
{ type: 'step/start', data: { turn: 1 } },
|
||||
{ type: 'request/header-delta', data: { system: 'delta' } },
|
||||
{ type: 'assistant/chunk', data: { turn: 1, step: 0, chunk: { type: 'text-delta', text: 'ignored stream text' } } },
|
||||
{ type: 'assistant/message', data: { turn: 1, step: 0, content: [{ type: 'reasoning', text: 'fallback reasoning' }] } },
|
||||
{ type: 'tool/call', data: { turn: 1, step: 0, callId: 'bad', arguments: 'not-json' } },
|
||||
{ type: 'tool/result', data: { turn: 1, step: 0, callId: 'bad', isError: true, error: 'boom' } },
|
||||
{ type: 'step/end', data: { turn: 1, step: 0 } },
|
||||
{ type: 'tool/call', data: { turn: 1, step: 0, callId: 'raw', name: 'raw', rawInput: { value: 1 } } },
|
||||
{ type: 'tool/call', data: { turn: 1, step: 0, callId: 'whole' } },
|
||||
{ type: 'tool/result', data: { turn: 1, step: 0, callId: 'missing' } },
|
||||
{ type: 'steering/message', data: { turn: 1, step: 0, content: [{ type: 'text', text: 'steer' }] } },
|
||||
{ type: 'turn/start', seq: 20, time: 20, data: { turn: 'bad', trigger: {} } },
|
||||
{ type: 'step/start', seq: 21, time: 21, data: { turn: 2, step: 1 } },
|
||||
{ type: 'context/message', seq: 22, time: 22, data: { turn: 2, step: 2, content: 'context' } },
|
||||
{ type: 'context/message', seq: 23, time: 23, data: { turn: 2, step: 2, content: 'context 2' } },
|
||||
{ type: 'context/message', seq: 24, time: 24, data: { turn: 2, step: 1 } },
|
||||
{ type: 'assistant/chunk', seq: 25, time: 25, data: { turn: 2, step: 2, chunk: { type: 'reasoning-delta', text: 'late thought' } } },
|
||||
{ type: 'context/message', seq: 26, time: 26, data: { turn: 2, step: 2, content: 'return to existing group' } },
|
||||
{ type: 'assistant/message', seq: 27, time: 27, data: { turn: 2, step: 2 } },
|
||||
{ type: 'turn/end', seq: 28, time: 28, data: { turn: 2, reason: { kind: 'error' } } },
|
||||
])
|
||||
expect(graph.targets.get('tool:bad')).toMatchObject({ title: 'Tool', status: 'error', input: 'not-json' })
|
||||
expect(graph.targets.get('reasoning:1:0')?.output).toBe('fallback reasoning')
|
||||
expect(graph.targets.get('context:22')?.output).toBe('context')
|
||||
expect(graph.targets.get('tool:raw')?.input).toEqual({ value: 1 })
|
||||
expect(graph.targets.get('tool:whole')?.input).toMatchObject({ callId: 'whole' })
|
||||
expect(graph.trajectoryRows.filter(row => row.targetId === 'tool:bad')).toHaveLength(1)
|
||||
expect(graph.trajectoryGroups.some(group => group.status === 'error')).toBe(true)
|
||||
})
|
||||
|
||||
it('covers failure closure and inherited request inputs across later steps', () => {
|
||||
const graph = buildTraceGraph('branches', [
|
||||
{ type: 'turn/start', seq: 1, time: 1, data: { turn: 1 } },
|
||||
{ type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } },
|
||||
{ type: 'request/header', seq: 3, time: 3, data: { header: { config: { model: 'm' } } } },
|
||||
{ type: 'tool/call', seq: 4, time: 4, data: { turn: 1, step: 1, callId: 'fail', name: 'bash', arguments: {} } },
|
||||
{ type: 'tool/result', seq: 5, time: 5, data: { turn: 1, step: 1, callId: 'fail', isError: true } },
|
||||
{ type: 'step/end', seq: 6, time: 6, data: { turn: 1, step: 1 } },
|
||||
{ type: 'step/start', seq: 7, time: 7, data: { turn: 1, step: 2 } },
|
||||
{ type: 'assistant/message', seq: 8, time: 8, data: { turn: 1, step: 2, content: [{ type: 'reasoning', text: 'no local header' }] } },
|
||||
{ type: 'step/end', seq: 9, time: 9, data: { turn: 1, step: 2 } },
|
||||
{ type: 'step/start', seq: 10, time: 10, data: { turn: 1, step: 3 } },
|
||||
{ type: 'assistant/message', seq: 11, time: 11, data: { turn: 1, step: 3, content: [{ type: 'text', text: 'text only' }] } },
|
||||
{ type: 'steering/message', seq: 12, time: 12, data: { turn: 1, step: 3 } },
|
||||
{ type: 'unknown/event', seq: 13, time: 13, data: { turn: 1, step: 3 } },
|
||||
{ type: 'turn/end', seq: 14, time: 14, data: { turn: 1 } },
|
||||
])
|
||||
expect(graph.trajectoryGroups.find(group => group.id === 'step:1:1')?.status).toBe('error')
|
||||
expect(graph.targets.get('reasoning:1:2')?.input).toEqual({ config: { model: 'm' } })
|
||||
expect(graph.targets.get('context:12')?.output).toBe('')
|
||||
expect(graph.targets.get('turn:1')?.output).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
function fixture(): TraceEvent[] {
|
||||
return [
|
||||
{ type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message' } } },
|
||||
{ type: 'user/message', seq: 1, time: 1, data: { content: [{ type: 'text', text: 'go' }] } },
|
||||
{ type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } },
|
||||
{ type: 'request/header', seq: 3, time: 3, data: { header: { config: { model: 'm' }, tools: [{ name: 'bash' }] } } },
|
||||
{ type: 'assistant/chunk', seq: 4, time: 4, data: { turn: 1, step: 1, chunk: { type: 'reasoning-delta', text: 'think ' } } },
|
||||
{ type: 'assistant/chunk', seq: 5, time: 5, data: { turn: 1, step: 1, chunk: { type: 'reasoning-delta', text: 'one' } } },
|
||||
{ type: 'assistant/message', seq: 6, time: 6, data: { turn: 1, step: 1, content: [{ type: 'reasoning', text: 'think one' }, { type: 'tool-call', id: 'c1', name: 'bash', arguments: '{"command":"pwd"}' }] } },
|
||||
{ type: 'tool/call', seq: 7, time: 7, data: { turn: 1, step: 1, callId: 'c1', name: 'bash', arguments: '{"command":"pwd"}' } },
|
||||
{ type: 'tool/result', seq: 8, time: 8, data: { turn: 1, step: 1, callId: 'c1', content: [{ type: 'text', text: '/repo' }], isError: false } },
|
||||
{ type: 'step/end', seq: 9, time: 9, data: { turn: 1, step: 1 } },
|
||||
{ type: 'step/start', seq: 10, time: 10, data: { turn: 1, step: 2 } },
|
||||
{ type: 'request/header', seq: 11, time: 11, data: { header: { config: { model: 'm' }, tools: [{ name: 'bash' }] } } },
|
||||
{ type: 'assistant/chunk', seq: 12, time: 12, data: { turn: 1, step: 2, chunk: { type: 'reasoning-delta', text: 'think two' } } },
|
||||
{ type: 'assistant/chunk', seq: 13, time: 13, data: { turn: 1, step: 2, chunk: { type: 'text-delta', text: 'done' } } },
|
||||
{ type: 'assistant/message', seq: 14, time: 14, data: { turn: 1, step: 2, content: [{ type: 'reasoning', text: 'think two' }, { type: 'text', text: 'done' }] } },
|
||||
{ type: 'step/end', seq: 15, time: 15, data: { turn: 1, step: 2 } },
|
||||
{ type: 'turn/end', seq: 16, time: 16, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
]
|
||||
}
|
||||
+4
-1
@@ -16,7 +16,10 @@ export default defineConfig({
|
||||
include: ['packages/*/*/src/**/*.ts'],
|
||||
// Types-only files have no runtime coverage. Importing self-executing bins/workers would boot
|
||||
// them inside the unit process, so real subprocess/Worker tests cover their thin entry glue.
|
||||
exclude: ['packages/*/*/src/types.ts', 'packages/*/*/src/bin.ts', 'packages/*/*/src/worker.ts'],
|
||||
// The desktop renderer entry is the same shape: a self-executing DOM bootstrap whose behavior
|
||||
// is exercised by jsdom lifecycle specs; its extractable logic lives in covered modules
|
||||
// (trace-graph.ts, renderer-content.ts) and grows there, not in the entry.
|
||||
exclude: ['packages/*/*/src/types.ts', 'packages/*/*/src/bin.ts', 'packages/*/*/src/worker.ts', 'packages/ui/desktop/src/app.ts'],
|
||||
// 100% or it doesn't merge (docs/testing.md: excessive tests are welcome).
|
||||
// Per-file so a well-covered big file can't subsidize a bare one.
|
||||
// Every v8 ignore comment must carry a reason — see the quality-gates RFC
|
||||
|
||||
Reference in New Issue
Block a user