From 649183aa655d546c053ed5c8570096fee8b01891 Mon Sep 17 00:00:00 2001 From: NI0317 Date: Sat, 18 Jul 2026 17:05:46 +0800 Subject: [PATCH] feat(ui): add desktop harness workbench --- packages/ui/desktop/README.md | 184 ++ packages/ui/desktop/index.html | 12 + packages/ui/desktop/package.json | 31 + packages/ui/desktop/scripts/dev.mjs | 60 + packages/ui/desktop/src/app.ts | 2036 +++++++++++++++++ packages/ui/desktop/src/css.d.ts | 1 + packages/ui/desktop/src/global.d.ts | 34 + packages/ui/desktop/src/i18n.ts | 112 + packages/ui/desktop/src/index.ts | 338 +++ packages/ui/desktop/src/main.mjs | 558 +++++ packages/ui/desktop/src/preload.cjs | 44 + packages/ui/desktop/src/styles.css | 1654 +++++++++++++ .../ui/desktop/tests/acp-subprocess.spec.ts | 79 + packages/ui/desktop/tests/index.spec.ts | 103 + packages/ui/desktop/tsconfig.json | 10 + 15 files changed, 5256 insertions(+) create mode 100644 packages/ui/desktop/README.md create mode 100644 packages/ui/desktop/index.html create mode 100644 packages/ui/desktop/package.json create mode 100644 packages/ui/desktop/scripts/dev.mjs create mode 100644 packages/ui/desktop/src/app.ts create mode 100644 packages/ui/desktop/src/css.d.ts create mode 100644 packages/ui/desktop/src/global.d.ts create mode 100644 packages/ui/desktop/src/i18n.ts create mode 100644 packages/ui/desktop/src/index.ts create mode 100644 packages/ui/desktop/src/main.mjs create mode 100644 packages/ui/desktop/src/preload.cjs create mode 100644 packages/ui/desktop/src/styles.css create mode 100644 packages/ui/desktop/tests/acp-subprocess.spec.ts create mode 100644 packages/ui/desktop/tests/index.spec.ts create mode 100644 packages/ui/desktop/tsconfig.json diff --git a/packages/ui/desktop/README.md b/packages/ui/desktop/README.md new file mode 100644 index 0000000000..54a5da839e --- /dev/null +++ b/packages/ui/desktop/README.md @@ -0,0 +1,184 @@ +# @deepseek-ai/dsh-desktop + +Desktop workbench for developing and studying DeepSeek Harness agents. The app is a first-party Electron client bound to this repository, not a generic workspace picker and not a continuation of the localhost prototype under `/Users/tn.shen/Documents/原型`. + +The product loop is: run a task in chat, inspect what happened, modify Harness or a local plugin, restart only the runtime, replay a previous task, compare the new run against the baseline, and repeat. + +## Run it + +From the repository root: + +```bash +pnpm --dir packages/ui/desktop run dev +``` + +This starts a Vite renderer on `http://127.0.0.1:5174`, opens Electron, and starts the real Harness ACP runtime with: + +```bash +node --import tsx packages/examples/acp-demo/src/bin.ts --config examples/acp-agent/cordis.yml +``` + +The renderer talks only to the Electron preload API. The main process owns the ACP subprocess, session JSONL reads, feedback writes, runtime restart, and diagnostics. + +Useful checks: + +```bash +node_modules/.bin/vitest run packages/ui/desktop/tests/index.spec.ts packages/ui/desktop/tests/acp-subprocess.spec.ts +node_modules/.bin/tsc --noEmit --ignoreConfig --module NodeNext --moduleResolution NodeNext --target ES2022 --lib ES2022,DOM --strict --allowImportingTsExtensions packages/ui/desktop/src/index.ts packages/ui/desktop/src/app.ts packages/ui/desktop/src/global.d.ts packages/ui/desktop/src/css.d.ts +``` + +## User journey + +The desktop app is for a Harness developer or researcher who wants to understand and improve one repo-bound agent runtime. + +1. Install and open the app. The app binds to this repository; there is no workspace picker. +2. Start a session in `Chat` and run one or two tasks to prove the runtime works. +3. Switch the same run to `Trajectory`, `Waterfall`, or `Context` to understand what happened behind one message. +4. Click any interesting message, step, tool call, request, timing bar, or context section to open the `Inspector` with complete facts. +5. Use `Dev` to ask the agent to modify Harness, a plugin, a prompt, a tool, or config. The first version seeds chat with repo paths and context instead of exposing a graphical code editor. +6. Restart only the managed Harness runtime subprocess when code/config changes require it. +7. Replay a previous prompt or turn against the changed runtime. +8. Compare the baseline run and candidate run. Keep the new behavior or ask the agent to revert/change it, then repeat. + +## Product shape + +The Electron shell owns the desktop lifecycle while the Harness runtime runs as a managed subprocess. The renderer never talks directly to the filesystem or the model. It calls typed preload APIs; the main process starts the runtime, talks to the Agent Client Protocol (ACP) server over JSON-RPC stdio, and reads session logs through trusted query adapters. + +The default runtime channel is ACP because `dsh-acp` already owns `session/new`, `session/load`, `session/prompt`, `session/cancel`, streaming `session/update`, permission prompts, and user elicitations. The desktop app may add a query side channel for persisted JSONL or `ctx.sessionQuery`, but live chat should not be implemented by polling JSONL. + +The app is repository-bound. On startup it treats the package root as the Harness repository, launches the runtime from that directory, reads `.sessions` from that runtime's persistence config, and marks each run with repository state such as commit, dirty status, runtime config hash, and parent replay metadata. + +## 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. + +`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. + +`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. + +`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. + +`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. + +`Inspector` is a right-side drawer, not a permanent column. It appears only after the user selects a message, trajectory node, waterfall bar, or context section. Its tabs are `Input`, `Output`, `Metadata`, and `Feedback`. It is the only place for complete JSON, JSONL, full schemas, complete system prompts, raw event windows, copy actions, and node-targeted feedback. + +## Surface behavior contract + +| 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 | +| `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. + +## Information ownership + +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. + +### Why Trajectory still needs Inspector + +Trajectory answers orientation questions: + +- Which turn and step produced this behavior? +- Which request triggered which assistant output and tool calls? +- 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. + +### Why Context still needs Inspector + +Context answers request-anatomy questions: + +- What did this request include outside normal chat history? +- Did the system prompt, tools, call config, or session prefix change? +- Which `context/message` or `steering/message` entries were model-visible? +- Was history compacted or replaced before this request? +- Which sections are large enough to matter for token cost? + +Context should show section summaries and bounded previews. The complete system prompt, complete tool schemas, full derived message list, raw `request/header`, and raw `request/header-delta` belong in Inspector. This makes Context useful as a map while preserving the user's requirement that every fact remains reachable. + +## Plugin and development panel + +The `Dev` panel is for changing Harness itself. It is not a full IDE and should not start as a graphical Cordis editor. + +The first version lists Cordis config entries, local `plugins/*` packages when present, runtime status, repository dirty state, and whether a runtime restart is needed. It provides actions to ask the agent to add, modify, or disable a plugin by seeding the current chat with the relevant paths and config context. + +Runtime changes are applied by restarting the managed ACP subprocess, not by restarting Electron. File watching should mark `Restart needed` for changes under `packages/**`, `examples/**`, `plugins/**`, `cordis.yml`, and package manifests. HMR can be explored later, but the reliable path is subprocess restart because ACP stdout is the JSON-RPC protocol channel. + +## Replay and compare + +Trace is looking at the past. Replay is running a past task against the current runtime. Compare is inspecting two run artifacts. + +Replay creates a new run with `parentRunId` or `replayOf` metadata. If the app can only replay the user prompt and cwd rather than reconstructing an exact intermediate state, the UI must label it as prompt replay. It must not imply bit-for-bit session replay unless the runtime can prove it restored the same context boundary. + +Compare is not a normal tab inside one session. It is a mode over two runs: baseline and candidate. The first version compares final output, request header, context sections, tool sequence, tool inputs and outputs, event counts, step counts, usage, duration, and errors. + +## Backend integration plan + +The first implementation uses two backend paths: + +- ACP subprocess for live chat and runtime lifecycle. It maps to `session/new`, `session/load`, `session/prompt`, `session/cancel`, and streamed `session/update`. This is the authoritative path for driving the agent. +- Session query/read adapters for exact inspection. They read live-preferred persisted logs through `listSessions()`, `listEvents(sessionId)`, and bounded raw-event windows for Inspector. + +The renderer receives normalized view models, not raw filesystem paths. The main process owns: + +- Runtime process start, stop, restart, status, stderr diagnostics, and JSON-RPC stdout framing. +- Session/run discovery and run metadata sidecars. +- Trace normalization into chat messages, trajectory nodes, waterfall spans, context sections, compare diffs, and inspector payload refs. +- Feedback writes attached to stable inspector target ids. + +## Trace and context data sources + +The current session log can provide all core facts needed by the UI: + +- `turn/start` and `turn/end` define durable user-visible turn boundaries. +- `step/start` and `step/end` define one model request plus its tool work. +- `request/header` and `request/header-delta` reconstruct model config, system prompt, tools, and `messagePrefix`. +- `user/message`, `assistant/message`, `tool/result`, `context/message`, and `steering/message` define the model-visible surface through `deriveMessages()`. +- `assistant/chunk` preserves token-level streaming fidelity but should usually be folded in Chat and opened in Inspector only when needed. +- `tool/call` and `tool/result` define tool input/output pairs and errors. +- `sourceEventSeqs` and `surfaceOp` explain provenance and compaction/replacement. + +## Minimum implementation plan + +Start with a desktop package that defines shared UI contracts, then build the Electron shell around them. + +1. Add an Electron main process that launches the ACP runtime subprocess from the repository root and owns lifecycle commands: start, stop, restart, status. +2. Add a preload API for sessions, prompts, trace reads, replay, compare, dev status, and feedback. Renderer code must not use Node globals. +3. Build the renderer around the five middle surfaces and the inspector drawer. The renderer should treat all trace/context/compare objects as view models supplied by the main process. +4. Implement session ingestion from ACP live updates and persisted session logs. Normalize selected objects to stable inspector targets. +5. Implement Dev panel actions as agent-seeded chat prompts first. Direct file mutations and graphical Cordis editing are later features. +6. Implement replay by creating a new run from a historical prompt or turn. Attach run metadata and open Compare after the candidate completes. + +## Phase-one acceptance criteria + +- A developer can open the desktop app from this repository without selecting a workspace. +- A developer can create/load sessions and send prompts through the real ACP runtime. +- The same run can switch between `Chat`, `Trajectory`, `Waterfall`, and `Context`. +- Clicking any middle-surface object opens the Inspector drawer with `Input`, `Output`, `Metadata`, and `Feedback`, with Feedback last. +- Complete system prompts, tool schemas, raw event windows, and JSON/JSONL are reachable from Inspector. +- Feedback defaults the author to `shentuni` and persists against the selected target. +- Runtime restart does not close the Electron shell. +- Replay creates a separate candidate run with lineage metadata. +- Compare operates over two runs, not "inside" one session. + +## Known limitations and deferred work + +This package now ships a usable Electron/Vite development app and a real ACP subprocess bridge. It is still a v1 workbench rather than a packaged distributable. + +The first runtime channel is ACP. Direct in-process embedding would make context queries and restarts richer, but it makes isolation, teardown, and hot reload harder and should wait until the ACP path is working. + +The first Dev panel is agent-assisted. A direct graphical plugin/config editor should come after the app can reliably run, replay, compare, and restart the runtime. + +The current trace/context surfaces read persisted JSONL after turns complete and use ACP live updates for streaming chat. A richer live raw-event side channel would make Trajectory/Context update at token-time rather than after the persistence flush. + +The first Compare view is structural and textual. Semantic evaluation and dataset-level analysis belong to a later evaluation product surface. diff --git a/packages/ui/desktop/index.html b/packages/ui/desktop/index.html new file mode 100644 index 0000000000..b7c85c8e5d --- /dev/null +++ b/packages/ui/desktop/index.html @@ -0,0 +1,12 @@ + + + + + + DeepSeek Harness Desktop + + +
+ + + diff --git a/packages/ui/desktop/package.json b/packages/ui/desktop/package.json new file mode 100644 index 0000000000..67c20abc5d --- /dev/null +++ b/packages/ui/desktop/package.json @@ -0,0 +1,31 @@ +{ + "name": "@deepseek-ai/dsh-desktop", + "description": "Electron desktop workbench plan and UI contracts for DeepSeek Harness development loops", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "src/main.mjs", + "types": "lib/types/index.d.ts", + "scripts": { + "dev": "node scripts/dev.mjs", + "dev:web": "vite --host 127.0.0.1", + "start": "electron src/main.mjs", + "build:ui": "vite build", + "preview": "vite preview --host 127.0.0.1" + }, + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause" +} diff --git a/packages/ui/desktop/scripts/dev.mjs b/packages/ui/desktop/scripts/dev.mjs new file mode 100644 index 0000000000..87227955bf --- /dev/null +++ b/packages/ui/desktop/scripts/dev.mjs @@ -0,0 +1,60 @@ +#!/usr/bin/env node +import { spawn } from 'node:child_process' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const here = dirname(fileURLToPath(import.meta.url)) +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'), [ + '--host', + '127.0.0.1', + '--port', + '5174', +], { + cwd: packageRoot, + env: { ...process.env }, + stdio: ['ignore', 'pipe', 'pipe'], +}) + +let electronStarted = false +let electron + +const startElectron = () => { + if (electronStarted) return + electronStarted = true + electron = spawn(resolve(repoRoot, 'node_modules/.bin/electron'), ['src/main.mjs'], { + cwd: packageRoot, + env: { ...process.env, VITE_DEV_SERVER_URL: viteUrl }, + stdio: 'inherit', + }) + electron.on('exit', (code, signal) => { + vite.kill('SIGTERM') + process.exit(code ?? (signal === null ? 0 : 1)) + }) +} + +const pipeVite = (chunk, stream) => { + const text = chunk.toString() + stream.write(text) + if (text.includes('Local:') || text.includes(viteUrl)) startElectron() +} + +vite.stdout.on('data', chunk => { pipeVite(chunk, process.stdout) }) +vite.stderr.on('data', chunk => { pipeVite(chunk, process.stderr) }) +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') +}) +process.on('SIGTERM', () => { + electron?.kill('SIGTERM') + vite.kill('SIGTERM') +}) diff --git a/packages/ui/desktop/src/app.ts b/packages/ui/desktop/src/app.ts new file mode 100644 index 0000000000..2e6b8d6fd7 --- /dev/null +++ b/packages/ui/desktop/src/app.ts @@ -0,0 +1,2036 @@ +import { + DEFAULT_FEEDBACK_AUTHOR, + INSPECTOR_TABS, + createInspectorTargetId, + defaultInspectorTabForTarget, + type DesktopSurface, + type InspectorTab, + type InspectorTarget, +} from './index.ts' +import { translate, type I18nKey, type Locale } from './i18n.ts' +import './styles.css' + +interface SessionSummary { + readonly id: string + readonly title: string + readonly cwd?: string + readonly relativePath?: string + readonly createdAt: number + readonly lastActivity: number + readonly eventCount: number + readonly turnCount: number + readonly stepCount: number + readonly toolCallCount: number + readonly model?: string + readonly live?: boolean +} + +interface SessionEvent { + readonly type: string + readonly seq?: number + readonly time?: number + readonly data?: Record + readonly sourceEventSeqs?: number[] + readonly surfaceOp?: unknown +} + +interface TracePayload { + readonly found: boolean + readonly sessionId: string + readonly header: Record + readonly events: SessionEvent[] + readonly rawText: string + readonly path?: string + readonly relativePath?: string + readonly feedback?: FeedbackRecord[] +} + +interface FeedbackRecord { + readonly type: string + readonly seq: number + readonly time: number + readonly data: { + readonly sessionId: string + readonly targetId: string + readonly targetTitle: string + readonly targetKind: string + readonly author: string + readonly text: string + } +} + +interface SessionUpdatePayload { + readonly sessionId: string + readonly update: Record +} + +interface ChatRow { + readonly target: InspectorTarget + readonly role: 'user' | 'assistant' | 'thinking' | 'tool' | 'context' | 'system' + readonly title: string + readonly body: string + readonly eventSeqs: readonly number[] + readonly collapsed?: boolean + readonly badge?: string +} + +interface TreeRow { + readonly target: InspectorTarget + readonly depth: number + readonly kind: 'session' | 'turn' | 'step' | 'request' | 'message' | 'thinking' | 'tool' | 'context' | 'error' + readonly title: string + readonly subtitle: string + readonly meta: string + readonly tone?: 'blue' | 'green' | 'amber' | 'red' +} + +interface SpanRow { + readonly target: InspectorTarget + readonly title: string + readonly subtitle: string + readonly startMs: number + readonly durationMs: number + readonly tone?: 'blue' | 'green' | 'amber' | 'red' +} + +interface ContextRow { + readonly target: InspectorTarget + readonly title: string + readonly subtitle: string + readonly preview: string + readonly kind: 'system' | 'tools' | 'messages' | 'config' | 'raw' + readonly changed?: boolean +} + +interface TargetPayload { + readonly input: unknown + readonly output: unknown + readonly metadata: unknown +} + +interface RequestContextSnapshot { + readonly event?: SessionEvent + readonly seq: number + readonly header: Record + readonly delta: Record + readonly system: string + readonly tools: unknown[] + readonly config: unknown + readonly messagePrefix: unknown +} + +interface DevArtifact { + readonly id: string + readonly group: 'Prompts' | 'Tools' | 'Plugins / Context Providers' | 'Config / Runtime' | 'Change Loop' + readonly title: string + readonly subtitle: string + readonly kind: 'prompt' | 'tool' | 'plugin' | 'config' | 'runtime' | 'change' + readonly status?: string + readonly source?: string + readonly owner?: string + readonly value?: unknown + readonly metadata?: unknown + readonly recent?: string +} + +interface DevArtifactGroup { + readonly title: DevArtifact['group'] + readonly artifacts: DevArtifact[] +} + +type AppModule = 'sessions' | 'develop' + +const SESSION_SURFACES: readonly DesktopSurface[] = ['chat', 'trajectory', 'waterfall'] +const initialLocale = (localStorage.getItem('dsh.locale') === 'en-US' ? 'en-US' : 'zh-CN') as Locale + +const state = { + runtime: undefined as unknown, + dev: undefined as unknown, + sessions: [] as SessionSummary[], + selectedSessionId: undefined as string | undefined, + trace: undefined as TracePayload | undefined, + activeModule: 'sessions' as AppModule, + activeSurface: 'chat' as DesktopSurface, + locale: initialLocale, + activeDevArtifactId: undefined as string | undefined, + selectedTarget: undefined as InspectorTarget | undefined, + activeInspectorTab: 'input' as InspectorTab, + busy: false, + error: '', + stderr: '', + query: '', + draftChat: false, + liveRows: new Map(), + pendingAssistant: new Map(), + pendingThinking: new Map(), +} + +function t(key: I18nKey): string { + return translate(state.locale, key) +} + +const appEl = document.querySelector('#app') + +void boot() + +async function boot(): Promise { + if (!hasDesktopApi()) { + state.error = 'Desktop API is not available. Open the Electron window with: pnpm --dir packages/ui/desktop run dev.' + render() + return + } + + window.dshDesktop.runtime.onStatus(payload => { + state.runtime = payload + render() + }) + window.dshDesktop.runtime.onStderr(payload => { + state.stderr = String(asRecord(payload).tail ?? asRecord(payload).text ?? '') + render() + }) + window.dshDesktop.sessions.onUpdate(payload => { + handleSessionUpdate(asSessionUpdate(payload)) + }) + + await Promise.all([refreshRuntime(), refreshDevStatus(), refreshSessions()]) +} + +async function refreshRuntime(): Promise { + if (!hasDesktopApi()) return + try { + state.runtime = await window.dshDesktop.runtime.status() + } catch (error) { + state.error = String(error) + } + render() +} + +async function refreshDevStatus(): Promise { + if (!hasDesktopApi()) return + try { + state.dev = await window.dshDesktop.dev.status() + } catch { + state.dev = undefined + } +} + +async function refreshSessions(preferredId?: string): Promise { + if (!hasDesktopApi()) return + try { + const result = asRecord(await window.dshDesktop.sessions.list()) + state.sessions = Array.isArray(result.sessions) ? result.sessions as SessionSummary[] : [] + state.selectedSessionId = preferredId ?? state.selectedSessionId ?? state.sessions[0]?.id + if (state.selectedSessionId !== undefined) await loadTrace(state.selectedSessionId, false) + } catch (error) { + state.error = String(error) + } + render() +} + +async function loadTrace(sessionId: string, rerender = true): Promise { + state.selectedSessionId = sessionId + state.selectedTarget = undefined + if (!hasDesktopApi()) return + try { + state.trace = asRecord(await window.dshDesktop.trace.read(sessionId)) as unknown as TracePayload + } catch (error) { + state.trace = { found: false, sessionId, header: { id: sessionId }, events: [], rawText: '', feedback: [] } + state.error = String(error) + } + if (rerender) render() +} + +function handleSessionUpdate(payload: SessionUpdatePayload): void { + const update = payload.update + const rows = state.liveRows.get(payload.sessionId) ?? [] + const kind = String(update.sessionUpdate ?? '') + + if (kind === 'agent_message_chunk') { + const text = contentText(update.content) + state.pendingAssistant.set(payload.sessionId, `${state.pendingAssistant.get(payload.sessionId) ?? ''}${text}`) + } else if (kind === 'agent_thought_chunk') { + const text = contentText(update.content) + state.pendingThinking.set(payload.sessionId, `${state.pendingThinking.get(payload.sessionId) ?? ''}${text}`) + } else if (kind === 'tool_call' || kind === 'tool_call_update') { + const toolCallId = String(update.toolCallId ?? `tool-${rows.length}`) + const existing = rows.findIndex(row => row.target.id.endsWith(`synthetic:${toolCallId}`)) + const row = makeSyntheticRow(payload.sessionId, 'tool', String(update.title ?? 'Tool use'), renderValue(update), toolCallId, true) + if (existing >= 0) rows.splice(existing, 1, row) + else rows.push(row) + state.liveRows.set(payload.sessionId, rows) + } else if (kind === 'user_message_chunk') { + rows.push(makeSyntheticRow(payload.sessionId, 'user', 'User', contentText(update.content), `user-${rows.length}`)) + state.liveRows.set(payload.sessionId, rows) + } + + render() +} + +function render(): void { + if (appEl === null) return + const session = currentSession() + appEl.innerHTML = ` +
+ ${renderSidebar()} +
+ ${renderTopbar(session)} + ${renderMainContent(session)} + ${renderBottomArea(session)} +
+ ${state.selectedTarget === undefined ? '' : renderInspector()} +
+ ` + syncSelectionAfterRender() +} + +function renderSidebar(): string { + const sessions = filteredSessions() + return ` + + ` +} + +function renderModuleButton(module: AppModule, title: string, subtitle: string): string { + return ` + + ` +} + +function renderSessionGroup(title: string, sessions: SessionSummary[]): string { + return ` +
+
${escapeHtml(title)}
+
+ ${sessions.map(renderSessionItem).join('') || `
No ${escapeHtml(title.toLowerCase())} sessions
`} +
+
+ ` +} + +function renderSessionItem(session: SessionSummary): string { + const age = formatRelativeTime(session.lastActivity) + return ` + + ` +} + +function renderTopbar(session: SessionSummary | undefined): string { + if (state.activeModule === 'sessions' && state.activeSurface === 'chat') { + return ` +
+
+

${escapeHtml(topbarTitle(session))}

+
+ ${renderSurfaceSwitcher()} +
${renderLanguageToggle()}
+
+ ` + } + return ` +
+
+
Deepseek Harness / ${escapeHtml(moduleTitle())}
+

${escapeHtml(topbarTitle(session))}

+
+ ${state.activeModule === 'sessions' ? renderSurfaceSwitcher() : ''} +
${renderLanguageToggle()}
+
+ ` +} + +function renderLanguageToggle(): string { + return `` +} + +function renderSurfaceSwitcher(): string { + return ` + + ` +} + +function renderMainContent(session: SessionSummary | undefined): string { + if (state.activeModule === 'develop') return renderDevelopModule() + + if (session === undefined) return state.draftChat ? renderDraftChat() : renderEmptySession() + return ` +
+ ${state.error ? `
Error${escapeHtml(state.error)}
` : ''} + ${state.activeSurface === 'chat' ? '' : renderSessionHeader(session)} + ${renderActiveSurface()} +
+ ` +} + +function renderSessionHeader(session: SessionSummary): string { + const trace = state.trace + return ` +
+ +
+ ${renderMetric('Turns', session.turnCount)} + ${renderMetric('Steps', session.stepCount)} + ${renderMetric('Tools', session.toolCallCount)} + ${renderMetric('Events', session.eventCount)} +
+
+ ` +} + +function renderMetric(label: string, value: number): string { + return `${value}${escapeHtml(label)}` +} + +function renderActiveSurface(): string { + if (state.activeSurface === 'trajectory') return renderTrajectorySurface() + if (state.activeSurface === 'waterfall') return renderWaterfallSurface() + return renderChatSurface() +} + +function renderChatSurface(): string { + const rows = chatRows() + return ` +
+ ${rows.map(renderChatRow).join('') || renderEmptyConversation()} +
+ ` +} + +function renderChatRow(row: ChatRow): string { + if (row.role === 'user') { + return ` +
+ + ${renderMarkdown(row.body)} + + +
+ ` + } + if (row.role === 'assistant') { + return ` +
+ ${renderMarkdown(row.body)} + +
+ ` + } + return ` +
+ + ${escapeHtml(row.title)} + ${escapeHtml(truncate(row.body, 150))} + +
+ ${renderFoldBody(row)} +
+
+ ` +} + +function renderFoldBody(row: ChatRow): string { + if (row.role === 'tool') { + const payload = parseMaybeJson(row.body) + const record = asRecord(payload) + const hasStructured = record.input !== undefined || record.output !== undefined || record.error !== undefined + if (hasStructured) { + return ` + ${renderActivitySection(t('chat.input'), record.input ?? record.arguments ?? record.rawInput ?? {})} + ${record.error !== undefined ? renderActivitySection(t('chat.errorOutput'), record.error) : ''} + ${record.output !== undefined ? renderActivitySection(t('chat.output'), record.output) : ''} + + ` + } + } + return ` + +
${escapeHtml(row.body)}
+ ` +} + +function renderActivitySection(title: string, value: unknown): string { + return ` +
+ ${escapeHtml(title)} +
${escapeHtml(formatDevValue(value))}
+
+ ` +} + +function renderTrajectorySurface(): string { + const rows = trajectoryRows() + return ` +
+
+ ${escapeHtml(t('trace.title'))} + ${escapeHtml(t('trace.body'))} +
+
+ ${rows.map(renderTreeRow).join('') || renderEmptyTrace()} +
+
+ ` +} + +function renderTreeRow(row: TreeRow): string { + const detail = renderTrajectoryInlineDetail(row) + if (detail.length > 0) { + return ` +
+ + + ${escapeHtml(row.kind)} + + ${escapeHtml(row.title)} + ${escapeHtml(row.subtitle)} + + ${escapeHtml(row.meta)} + +
+ ${detail} + +
+
+ ` + } + return ` + + ` +} + +function renderTrajectoryInlineDetail(row: TreeRow): string { + const seq = targetSeq(row.target) + const event = (state.trace?.events ?? []).find(candidate => candidate.seq === seq) + if (event === undefined) return '' + const data = asRecord(event.data) + if (event.type === 'request/header' || event.type === 'request/header-delta') { + const header = asRecord(data.header) + const delta = event.type === 'request/header-delta' ? data : {} + const system = String(header.system ?? delta.system ?? '') + const tools = header.tools ?? delta.tools ?? [] + return ` + ${renderTrajectoryCard(t('trace.systemPrompt'), `${system.length} chars`, system || t('trace.noSystem'))} + ${renderTrajectoryCard(t('trace.toolSchemas'), `${Array.isArray(tools) ? tools.length : 0} tools`, tools)} + + ` + } + if (event.type === 'tool/call') { + const callId = String(data.callId ?? '') + const result = (state.trace?.events ?? []).find(candidate => candidate.type === 'tool/result' && asRecord(candidate.data).callId === callId) + return ` + ${renderTrajectoryCard(String(data.name ?? 'tool'), callId, data.arguments ?? data.rawInput ?? data)} + ${result === undefined ? '' : renderTrajectoryCard(asRecord(result.data).isError ? t('chat.errorOutput') : t('chat.output'), callId, asRecord(result.data).content ?? result.data)} + ` + } + if (event.type === 'tool/result') { + return renderTrajectoryCard(asRecord(data).isError ? t('chat.errorOutput') : t('chat.output'), String(data.callId ?? ''), data.content ?? data) + } + return '' +} + +function renderTrajectoryCard(title: string, meta: string, value: unknown): string { + return ` +
+
${escapeHtml(title)}${escapeHtml(meta)}
+
${escapeHtml(formatDevValue(value))}
+
+ ` +} + +function renderWaterfallSurface(): string { + const spans = waterfallRows() + const total = Math.max(1, ...spans.map(span => span.startMs + span.durationMs)) + return ` +
+
+ ${escapeHtml(t('waterfall.title'))} + ${escapeHtml(t('waterfall.body'))} +
+ ${renderWaterfallSummary(spans, total)} +
+ ${spans.map(span => renderSpanRow(span, total)).join('') || renderEmptyTrace()} +
+
+ ` +} + +function renderWaterfallSummary(spans: SpanRow[], total: number): string { + const events = state.trace?.events ?? [] + const slowest = [...spans].sort((a, b) => b.durationMs - a.durationMs)[0] + return ` +
+ ${renderWfStat(t('waterfall.total'), formatMs(total))} + ${renderWfStat(t('waterfall.turns'), String(uniqueTurns().length))} + ${renderWfStat(t('waterfall.steps'), String(events.filter(event => event.type === 'step/start').length))} + ${renderWfStat(t('waterfall.tools'), String(events.filter(event => event.type === 'tool/call').length))} + ${renderWfStat(t('waterfall.errors'), String(events.filter(event => event.type === 'tool/result' && asRecord(event.data).isError).length))} + ${renderWfStat(t('waterfall.slowest'), slowest === undefined ? '-' : `${slowest.title} · ${formatMs(slowest.durationMs)}`)} +
+ ` +} + +function renderWfStat(labelText: string, value: string): string { + return `
${escapeHtml(labelText)}${escapeHtml(value)}
` +} + +function renderSpanRow(span: SpanRow, total: number): string { + const left = (span.startMs / total) * 100 + const width = Math.max(1.5, (span.durationMs / total) * 100) + return ` + + ` +} + +function renderContextSurface(): string { + const rows = contextRows() + return ` +
+
+ Context + 这是开发分析视图:它解释模型请求边界里真正进入上下文的内容,用来改 prompt、tool schema 和 config。 +
+
+ ${rows.map(renderContextCard).join('') || renderEmptyTrace()} +
+
+ ` +} + +function renderContextCard(row: ContextRow): string { + return ` + + ` +} + +function renderDevelopModule(): string { + const groups = developArtifactGroups() + const artifacts = groups.flatMap(group => group.artifacts) + const selected = artifacts.find(artifact => artifact.id === state.activeDevArtifactId) ?? artifacts[0] + return ` +
+
+
+ +
+ ${selected === undefined ? renderEmptyDevBrowser() : renderDevArtifactDetail(selected)} +
+
+
+
+ ` +} + +function renderEmptyDevBrowser(): string { + return ` +
+ No development artifacts found + Start the runtime or check the active cordis.yml config. +
+ ` +} + +function renderDevArtifactGroup(group: DevArtifactGroup, selectedId: string | undefined): string { + return ` +
+

${escapeHtml(group.title)}

+
+ ${group.artifacts.map(artifact => renderDevArtifactButton(artifact, selectedId)).join('')} +
+
+ ` +} + +function renderDevArtifactButton(artifact: DevArtifact, selectedId: string | undefined): string { + return ` + + ` +} + +function renderDevArtifactDetail(artifact: DevArtifact): string { + return ` +
+
+
+ ${escapeHtml(artifact.group)} +

${escapeHtml(artifact.title)}

+

${escapeHtml(artifact.subtitle)}

+
+ ${escapeHtml(artifact.status ?? artifact.kind)} +
+
+ ${renderDevFact('Source', artifact.source ?? 'unknown')} + ${renderDevFact('Owner', artifact.owner ?? 'unknown')} + ${renderDevFact('Recently used', artifact.recent ?? 'No recent evidence yet')} + ${renderDevFact('Reload', reloadLabelForArtifact(artifact))} +
+ ${renderDevCodePanel(contentTitleForArtifact(artifact), contentMetaForArtifact(artifact), artifact.value ?? '')} + ${artifact.id === 'prompt:persona' ? renderDevRegistrySnapshot() : ''} + ${artifact.metadata === undefined ? '' : renderDevCodePanel('Metadata', 'Implementation, dependency, and last-seen evidence', artifact.metadata)} + ${artifact.kind === 'runtime' ? renderRuntimePanel() : ''} + ${artifact.kind === 'change' ? renderChangeLoopPanel() : ''} +
+ ` +} + +function renderDevFact(labelText: string, value: string): string { + return ` +
+ ${escapeHtml(labelText)} + ${escapeHtml(value)} +
+ ` +} + +function renderDevRegistrySnapshot(): string { + const groups = developArtifactGroups() + const plugins = groups.find(group => group.title === 'Plugins / Context Providers')?.artifacts ?? [] + const tools = groups.find(group => group.title === 'Tools')?.artifacts ?? [] + return ` +
+
+

Registered plugins

+ ${renderDevPillList(plugins, 'No registered plugins found')} +
+
+

Registered tool surfaces

+ ${renderDevPillList(tools, 'No registered tools found yet')} +
+
+ ` +} + +function renderDevPillList(artifacts: DevArtifact[], empty: string): string { + if (artifacts.length === 0) return `

${escapeHtml(empty)}

` + return ` +
    + ${artifacts.map(artifact => ` +
  • + ${escapeHtml(artifact.title)} + ${escapeHtml(artifact.status ?? artifact.kind)} +
  • + `).join('')} +
+ ` +} + +function contentTitleForArtifact(artifact: DevArtifact): string { + if (artifact.kind === 'prompt') return 'Effective prompt content' + if (artifact.kind === 'tool') return 'Tool schema' + if (artifact.kind === 'plugin') return 'Plugin config / contribution' + if (artifact.kind === 'config') return 'Active configuration' + if (artifact.kind === 'runtime') return 'Runtime state' + return 'Suggested verification loop' +} + +function contentMetaForArtifact(artifact: DevArtifact): string { + if (artifact.kind === 'prompt') return 'Current source-level prompt text or prompt owner metadata' + if (artifact.kind === 'tool') return 'Current registered schema when last seen by a model request' + if (artifact.kind === 'plugin') return 'Who injects prompt/context or registers tools, based on active Cordis config' + if (artifact.kind === 'config') return 'Model/runtime parameters and files whose edits require reload' + if (artifact.kind === 'runtime') return 'Current Electron main process and ACP bridge state' + return 'How to rerun and compare after editing the agent' +} + +function reloadLabelForArtifact(artifact: DevArtifact): string { + if (artifact.kind === 'runtime') return 'Manual restart available here' + if (artifact.kind === 'change') return String(asRecord(state.dev).restartNeeded ?? false) === 'true' ? 'Restart recommended' : 'No restart signal' + if (artifact.kind === 'config' || artifact.kind === 'prompt' || artifact.kind === 'tool' || artifact.kind === 'plugin') return 'Restart ACP after editing' + return 'unknown' +} + +function renderChangeLoopPanel(): string { + return ` +
+ Recommended loop +
    +
  1. Edit the prompt, tool, plugin, or config source.
  2. +
  3. Restart ACP runtime if the changed file is loaded at process start.
  4. +
  5. Return to Chat and rerun a previous task or start a new one.
  6. +
  7. Use Trajectory / Waterfall to compare behavior and timing evidence.
  8. +
+
+ ` +} + +function developArtifactGroups(): DevArtifactGroup[] { + const dev = asRecord(state.dev) + const composition = asRecord(dev.appComposition) + const sourceFiles = Array.isArray(composition.sourceFiles) ? composition.sourceFiles : [] + const plugins = Array.isArray(composition.plugins) ? composition.plugins : [] + const recentPromptUses = Array.isArray(dev.recentPromptUses) ? dev.recentPromptUses : [] + const recentToolCalls = Array.isArray(dev.recentToolCalls) ? dev.recentToolCalls : [] + const request = latestRequestContext() + const persona = extractPersonaFromConfig(String(composition.configText ?? '')) + const promptOwner = sourceFiles.find(file => asRecord(file).label === 'System prompt service') + const toolRegistryOwner = sourceFiles.find(file => asRecord(file).label === 'Tool registry') + const promptArtifacts: DevArtifact[] = [ + { + id: 'prompt:persona', + group: 'Prompts', + kind: 'prompt', + title: 'System persona', + subtitle: 'Current deployment persona forwarded from cordis.yml', + status: persona.length > 0 ? 'active' : 'missing', + source: String(composition.configPath ?? 'examples/acp-agent/cordis.yml'), + owner: '@deepseek-ai/dsh-acp-demo -> @deepseek-ai/dsh-system-prompt', + value: persona || 'No persona block found in the active config.', + metadata: promptOwner ?? { path: 'packages/system-prompt/system-prompt/src/index.ts' }, + recent: recentPromptSummary(recentPromptUses, request), + }, + { + id: 'prompt:assembly', + group: 'Prompts', + kind: 'prompt', + title: 'Prompt assembly service', + subtitle: 'Owns persona, steering sections, and tool-order assembly', + status: 'source', + source: String(asRecord(promptOwner).path ?? 'packages/system-prompt/system-prompt/src/index.ts'), + owner: '@deepseek-ai/dsh-system-prompt', + value: promptOwner ?? { path: 'packages/system-prompt/system-prompt/src/index.ts' }, + metadata: { + lastSeenSystemPromptChars: request.system.length, + messagePrefix: request.messagePrefix, + recentPromptUses, + }, + recent: recentPromptSummary(recentPromptUses, request), + }, + ] + const toolArtifacts = buildToolArtifacts(request, plugins, toolRegistryOwner, recentToolCalls) + const pluginArtifacts = plugins.map(plugin => pluginToArtifact(plugin)) + const configArtifacts: DevArtifact[] = [ + { + id: 'config:cordis', + group: 'Config / Runtime', + kind: 'config', + title: 'Active cordis.yml', + subtitle: 'Process-start Cordis composition loaded by the desktop ACP runtime', + status: 'active', + source: String(composition.configPath ?? 'examples/acp-agent/cordis.yml'), + owner: 'packages/examples/acp-demo/src/bin.ts', + value: composition.configText ?? 'No config text reported by dev backend.', + metadata: { + entrypoint: composition.entrypoint ?? 'packages/examples/acp-demo/src/bin.ts', + model: modelFromConfigText(String(composition.configText ?? '')), + watchedPaths: dev.watchedPaths ?? [], + }, + recent: runtimeLabel(), + }, + { + id: 'runtime:acp', + group: 'Config / Runtime', + kind: 'runtime', + title: 'ACP runtime', + subtitle: 'Managed backend process used by Chat and trace capture', + status: runtimeLabel(), + source: 'packages/ui/desktop/src/main.mjs', + owner: 'Electron main process', + value: { runtime: state.runtime, dev: state.dev }, + metadata: { + repo: runtimeRepoRoot(), + dirty: gitField('dirty'), + branch: gitField('branch'), + commit: gitField('commit'), + }, + recent: String(asRecord(state.runtime).pid ?? 'No pid'), + }, + ] + const changeArtifacts: DevArtifact[] = [ + { + id: 'change:loop', + group: 'Change Loop', + kind: 'change', + title: 'Modify, reload, rerun', + subtitle: 'The product loop after editing Harness, plugins, prompts, tools, or config', + status: String(dev.restartNeeded ?? false) === 'true' ? 'restart needed' : 'ready', + source: runtimeRepoRoot(), + owner: 'Deepseek Harness desktop', + value: { + git: dev.git, + restartNeeded: dev.restartNeeded ?? false, + suggestedNextRun: state.selectedSessionId === undefined ? 'Start a chat task, then inspect its trajectory/waterfall.' : `Rerun or continue session ${state.selectedSessionId}`, + }, + metadata: { + selectedSessionId: state.selectedSessionId, + sessionsAvailable: state.sessions.length, + }, + recent: gitSummary(), + }, + ] + return [ + { title: 'Prompts', artifacts: promptArtifacts }, + { title: 'Tools', artifacts: toolArtifacts }, + { title: 'Plugins / Context Providers', artifacts: pluginArtifacts }, + { title: 'Config / Runtime', artifacts: configArtifacts }, + { title: 'Change Loop', artifacts: changeArtifacts }, + ] +} + +function buildToolArtifacts(request: RequestContextSnapshot, plugins: unknown[], owner: unknown, recentToolCalls: unknown[]): DevArtifact[] { + if (request.tools.length > 0) { + return request.tools.map((tool, index) => { + const record = asRecord(tool) + const name = String(record.name ?? record.functionName ?? `tool_${index + 1}`) + const schema = record.input_schema ?? record.inputSchema ?? record.parameters ?? record.schema ?? tool + const plugin = plugins.find(candidate => toolLikelyOwnedByPlugin(name, asRecord(candidate))) + const recent = recentToolCalls.find(call => String(asRecord(call).name) === name) + return { + id: `tool:${name}`, + group: 'Tools', + kind: 'tool', + title: name, + subtitle: String(record.description ?? record.summary ?? 'Registered model-facing tool'), + status: 'registered', + source: toolImplementationSource(name, plugin), + owner: String(asRecord(plugin).name ?? asRecord(owner).path ?? '@deepseek-ai/dsh-tools'), + value: schema, + metadata: { + fullTool: tool, + ownerPlugin: plugin ?? 'No matching plugin inferred from active config', + registry: owner ?? { path: 'packages/tools/tools/src/index.ts' }, + recentToolCall: recent ?? 'No persisted call evidence found', + }, + recent: recentToolSummary(recent) || (request.event === undefined ? 'No request evidence yet' : `Schema last seen in request seq ${request.seq}`), + } satisfies DevArtifact + }) + } + const toolPlugins = plugins.filter(plugin => { + const record = asRecord(plugin) + const id = String(record.id ?? '') + const name = String(record.name ?? '') + return id.includes('tool') || name.includes('tool') || id === 'bash' || id.includes('subagent') || id.includes('workflow') + }) + return toolPlugins.map(plugin => { + const record = asRecord(plugin) + const id = String(record.id ?? 'tool') + const recent = recentToolCalls.find(call => { + const name = String(asRecord(call).name) + return toolLikelyOwnedByPlugin(name, record) || id.includes(name) + }) + return { + id: `tool-plugin:${id}`, + group: 'Tools', + kind: 'tool', + title: id, + subtitle: String(record.name ?? 'Tool provider plugin'), + status: 'provider', + source: toolImplementationSource(id, plugin), + owner: String(record.name ?? '@deepseek-ai/dsh-tools'), + value: String(record.configPreview ?? '').trim() || 'Tool schema will appear here after the first model request captures the registered tool list.', + metadata: { + plugin, + registry: owner ?? { path: 'packages/tools/tools/src/index.ts' }, + recentToolCall: recent ?? 'No persisted call evidence found', + }, + recent: recentToolSummary(recent) || 'No request schema loaded', + } + }) +} + +function recentPromptSummary(recentPromptUses: unknown[], request: RequestContextSnapshot): string { + const latest = asRecord(recentPromptUses[0]) + if (latest.sessionId !== undefined) return `Last used in ${shortId(String(latest.sessionId))} · seq ${String(latest.seq ?? '?')}` + if (request.event !== undefined) return `Observed in selected trace · seq ${request.seq}` + return 'No request evidence yet' +} + +function recentToolSummary(call: unknown): string { + const record = asRecord(call) + if (record.name === undefined) return '' + return `${String(record.count ?? 0)} calls · last ${shortId(String(record.lastSessionId ?? 'session'))} seq ${String(record.lastSeq ?? '?')}` +} + +function pluginToArtifact(plugin: unknown): DevArtifact { + const record = asRecord(plugin) + const id = String(record.id ?? 'plugin') + const name = String(record.name ?? '') + const role = pluginRole(record) + return { + id: `plugin:${id}`, + group: 'Plugins / Context Providers', + kind: 'plugin', + title: id, + subtitle: name || 'Cordis config entry', + status: role, + source: String(record.source ?? 'examples/acp-agent/cordis.yml'), + owner: name || id, + value: String(record.configPreview ?? '').trim() || { id, name }, + metadata: { + injectsPrompt: role.includes('prompt'), + registersTool: role.includes('tool'), + injectsContext: role.includes('context') || id.includes('hooks') || id.includes('repeat'), + dependencyNote: 'Derived from the active Cordis config order; exact runtime graph can be added when the backend exposes Cordis fibers.', + }, + recent: 'Loaded from active config', + } +} + +function pluginRole(plugin: Record): string { + const id = String(plugin.id ?? '') + const name = String(plugin.name ?? '') + if (id.includes('tool') || name.includes('tool') || id === 'bash' || id.includes('workflow') || id.includes('subagent')) return 'registers tool' + if (id.includes('prompt') || name.includes('prompt') || id === 'acp-agent') return 'injects prompt' + if (id.includes('hooks') || id.includes('guard') || id.includes('permission')) return 'context / policy provider' + if (id.includes('llm')) return 'model provider' + return 'plugin' +} + +function toolLikelyOwnedByPlugin(toolName: string, plugin: Record): boolean { + const id = String(plugin.id ?? '').replace(/^tool-/, '') + const name = String(plugin.name ?? '') + return toolName.includes(id) || id.includes(toolName) || name.includes(toolName) +} + +function toolImplementationSource(toolName: string, plugin: unknown): string { + const packageName = String(asRecord(plugin).name ?? '') + if (packageName.startsWith('@deepseek-ai/dsh-')) return `packages/${packageName.replace('@deepseek-ai/dsh-', '').replace(/^tool-/, 'tool-')}/` + if (toolName === 'bash') return 'packages/bash/tool-bash/src/index.ts' + if (toolName === 'subagent' || toolName === 'subagent_fork') return 'packages/agent/tool-subagent/src/index.ts' + return 'packages/tools/tools/src/index.ts' +} + +function modelFromConfigText(text: string): string { + const match = text.match(/^\s*model:\s*(.+?)\s*$/m) + return match?.[1] ?? 'unknown' +} + +function extractPersonaFromConfig(text: string): string { + const match = text.match(/persona:\s*\|\n([\s\S]*?)(?:\n\S|\n\s*#|$)/) + if (match === null) return '' + return match[1] + .split('\n') + .map(line => line.replace(/^ {6}/, '')) + .join('\n') + .trim() +} + +function renderRuntimePanel(): string { + return ` +
+
${renderKeyValue('Repo', shortPath(runtimeRepoRoot()))}${renderKeyValue('Branch', gitField('branch'))}${renderKeyValue('Commit', gitField('commit'))}${renderKeyValue('Dirty', gitField('dirty'))}${renderKeyValue('ACP', runtimeLabel())}${renderKeyValue('Restart needed', String(asRecord(state.dev).restartNeeded ?? false))}
+ +
+ ` +} + +function renderDevCodePanel(title: string, meta: string, value: unknown): string { + return ` +
+
+
+

${escapeHtml(title)}

+

${escapeHtml(meta)}

+
+
+
${escapeHtml(formatDevValue(value))}
+
+ ` +} + +function renderBottomArea(session: SessionSummary | undefined): string { + if (state.activeModule !== 'sessions') { + return `
${escapeHtml(runtimeLabel())}${escapeHtml(gitSummary())}
` + } + return ` +
+ +
+ +
+
+ ` +} + +function renderInspector(): string { + const target = state.selectedTarget! + const feedbackCount = feedbackForTarget().length + return ` + + ` +} + +function renderInspectorTabs(feedbackCount = feedbackForTarget().length): string { + return INSPECTOR_TABS.map(tab => ` + + `).join('') +} + +function renderInspectorBody(): string { + const selected = state.selectedTarget + if (selected === undefined) return '' + if (state.activeInspectorTab === 'feedback') { + const feedback = feedbackForTarget() + return ` + + + ` + } + const payload = payloadForTarget(selected) + const value = state.activeInspectorTab === 'input' ? payload.input : state.activeInspectorTab === 'output' ? payload.output : payload.metadata + return `
${escapeHtml(JSON.stringify(value, null, 2))}
` +} + +function renderInspectorOnly(): void { + const tabs = document.querySelector('.inspector-tabs') + const body = document.querySelector('.inspector-body') + if (tabs === null || body === null) { + render() + return + } + tabs.innerHTML = renderInspectorTabs() + body.innerHTML = renderInspectorBody() +} + +function renderEmptySession(): string { + return ` +
+

${escapeHtml(t('chat.startTitle'))}

+

${escapeHtml(t('chat.startBody'))}

+ +
+ ` +} + +function renderDraftChat(): string { + return ` +
+
+
+

${escapeHtml(t('chat.newTitle'))}

+

${escapeHtml(t('chat.newBody'))}

+
+
+
+ ` +} + +function renderEmptyConversation(): string { + return ` +
+

${escapeHtml(t('chat.emptyTitle'))}

+

${escapeHtml(t('chat.emptyBody'))}

+
+ ` +} + +function renderEmptyTrace(): string { + return `

${escapeHtml(t('empty.traceTitle'))}

${escapeHtml(t('empty.traceBody'))}

` +} + +function renderKeyValue(key: string, value: unknown): string { + return `
${escapeHtml(key)}
${escapeHtml(String(value))}
` +} + +function filteredSessions(): SessionSummary[] { + const query = state.query.trim().toLowerCase() + if (query.length === 0) return state.sessions + return state.sessions.filter(session => [ + session.id, + session.title, + session.cwd ?? '', + session.relativePath ?? '', + session.model ?? '', + ].join('\n').toLowerCase().includes(query)) +} + +function chatRows(): ChatRow[] { + const sessionId = state.selectedSessionId + if (sessionId === undefined) return [] + const fromTrace = rowsFromEvents(state.trace?.events ?? []) + const live = flushLiveDrafts(sessionId) + if (fromTrace.length === 0) return live + return [...fromTrace, ...live.filter(row => row.eventSeqs.length === 0)] +} + +function rowsFromEvents(events: readonly SessionEvent[]): ChatRow[] { + const rows: ChatRow[] = [] + const reasoningByStep = new Map() + const seenReasoningStep = new Set() + const toolResults = new Map() + + for (const event of events) { + if (event.type === 'tool/result') { + const callId = String(asRecord(event.data).callId ?? '') + if (callId.length > 0) toolResults.set(callId, event) + } + } + + for (const event of events) { + if (event.type === 'assistant/chunk') { + const chunk = asRecord(asRecord(event.data).chunk) + if (chunk.type === 'reasoning-delta') { + const key = `${asRecord(event.data).turn}:${asRecord(event.data).step}` + const previous = reasoningByStep.get(key) + reasoningByStep.set(key, { + text: `${previous?.text ?? ''}${String(chunk.text ?? '')}`, + seq: previous?.seq ?? event.seq ?? 0, + }) + } + } + } + + for (const event of events) { + const seq = event.seq ?? rows.length + const data = asRecord(event.data) + if (event.type === 'user/message') { + rows.push({ target: makeTarget('message', 'User message', seq, `seq ${seq}`), role: 'user', title: 'User', body: contentText(data.content), eventSeqs: [seq] }) + } else if (event.type === 'assistant/message') { + const key = `${data.turn}:${data.step}` + const reasoning = reasoningByStep.get(key) + if (reasoning !== undefined && !seenReasoningStep.has(key)) { + seenReasoningStep.add(key) + rows.push({ + target: makeTarget('assistant-stream', 'Thinking', reasoning.seq, `turn ${String(data.turn)} step ${String(data.step)}`), + role: 'thinking', + title: 'Thinking', + body: reasoning.text, + eventSeqs: [reasoning.seq], + collapsed: true, + badge: 'folded', + }) + } + rows.push({ target: makeTarget('message', 'Assistant message', seq, `seq ${seq}`), role: 'assistant', title: 'Assistant', body: contentText(data.content), eventSeqs: [seq] }) + } else if (event.type === 'tool/call') { + const callId = String(data.callId ?? '') + const result = toolResults.get(callId) + const resultData = asRecord(result?.data) + rows.push({ + target: makeTarget('tool-call', `Tool · ${String(data.name ?? 'tool')}`, seq, `seq ${seq}`), + role: 'tool', + title: `Tool use · ${String(data.name ?? 'tool')}`, + body: renderValue({ + input: data.arguments ?? data.rawInput ?? data, + output: resultData.content ?? resultData.output, + error: resultData.isError ? resultData.error ?? resultData : undefined, + }), + eventSeqs: [seq], + collapsed: true, + badge: 'tool', + }) + } else if (event.type === 'context/message' || event.type === 'steering/message') { + rows.push({ + target: makeTarget('context-section', event.type, seq, `seq ${seq}`), + role: 'context', + title: event.type, + body: contentText(data.content), + eventSeqs: [seq], + collapsed: true, + badge: 'context', + }) + } + } + return rows +} + +function flushLiveDrafts(sessionId: string): ChatRow[] { + const rows = [...(state.liveRows.get(sessionId) ?? [])] + const thinking = state.pendingThinking.get(sessionId) + if (thinking !== undefined && thinking.length > 0) { + rows.push(makeSyntheticRow(sessionId, 'thinking', 'Thinking', thinking, 'thinking-live', true)) + } + const assistant = state.pendingAssistant.get(sessionId) + if (assistant !== undefined && assistant.length > 0) { + rows.push(makeSyntheticRow(sessionId, 'assistant', 'Assistant', assistant, 'assistant-live')) + } + return rows +} + +function makeSyntheticRow( + sessionId: string, + role: ChatRow['role'], + title: string, + body: string, + syntheticId: string, + collapsed = false, +): ChatRow { + const targetKind = role === 'tool' ? 'tool-call' : role === 'thinking' ? 'assistant-stream' : 'message' + return { + target: { + id: createInspectorTargetId({ sessionId, kind: targetKind, syntheticId }), + kind: targetKind, + title, + subtitle: 'live ACP update', + }, + role, + title, + body, + eventSeqs: [], + collapsed, + badge: collapsed ? 'live' : undefined, + } +} + +function trajectoryRows(): TreeRow[] { + const events = state.trace?.events ?? [] + const rows: TreeRow[] = [] + const sessionId = state.selectedSessionId + if (sessionId !== undefined) { + rows.push({ + target: makeTarget('session', `Session ${sessionId}`, 0, state.trace?.relativePath ?? 'session root'), + depth: 0, + kind: 'session', + title: `Session ${shortId(sessionId)}`, + subtitle: `${events.length} events · ${state.trace?.found ? 'persisted JSONL' : 'live session'}`, + meta: state.trace?.found ? 'saved' : 'live', + tone: 'blue', + }) + } + + for (const event of events) { + const seq = event.seq ?? 0 + const data = asRecord(event.data) + if (event.type === 'turn/start') rows.push(treeRow('turn', `Turn ${String(data.turn)}`, summarizeTrigger(data.trigger), seq, 1, undefined, 'blue')) + else if (event.type === 'step/start') rows.push(treeRow('step', `Step ${String(data.step)}`, `turn ${String(data.turn)}`, seq, 2)) + else if (event.type === 'request/header' || event.type === 'request/header-delta') rows.push(treeRow('request', event.type, summarizeHeaderEvent(event), seq, 3, 'request', 'amber')) + else if (event.type === 'assistant/message') rows.push(treeRow('message', 'Assistant message', truncate(contentText(data.content), 130), seq, 3, 'message')) + else if (event.type === 'tool/call') rows.push(treeRow('tool', `Tool · ${String(data.name ?? 'tool')}`, truncate(renderValue(data.arguments ?? data.rawInput ?? data), 140), seq, 3, 'tool-call', 'green')) + else if (event.type === 'tool/result') rows.push(treeRow(data.isError ? 'error' : 'tool', 'Tool result', truncate(contentText(data.content), 140), seq, 3, 'tool-result', data.isError ? 'red' : 'green')) + else if (event.type === 'context/message' || event.type === 'steering/message') rows.push(treeRow('context', event.type, truncate(contentText(data.content), 140), seq, 3, 'context-section', 'blue')) + } + return rows +} + +function treeRow( + kind: TreeRow['kind'], + title: string, + subtitle: string, + seq: number, + depth: number, + targetKind: InspectorTarget['kind'] = kind === 'tool' ? 'tool-call' : kind === 'thinking' ? 'assistant-stream' : kind === 'context' ? 'context-section' : kind === 'error' ? 'tool-result' : kind, + tone?: TreeRow['tone'], +): TreeRow { + return { + target: makeTarget(targetKind, title, seq, `seq ${seq}`), + depth, + kind, + title, + subtitle, + meta: `seq ${seq}`, + tone, + } +} + +function waterfallRows(): SpanRow[] { + const events = state.trace?.events ?? [] + const first = events.find(event => typeof event.time === 'number')?.time ?? Date.now() + const starts = new Map() + const spans: SpanRow[] = [] + for (const event of events) { + const data = asRecord(event.data) + if (event.type === 'turn/start') starts.set(`turn:${String(data.turn)}`, event) + if (event.type === 'step/start') starts.set(`step:${String(data.turn)}:${String(data.step)}`, event) + if (event.type === 'turn/end') { + const start = starts.get(`turn:${String(data.turn)}`) + if (start) spans.push(timedRow(`Turn ${String(data.turn)}`, start, event, 'turn', first, 'blue')) + } + if (event.type === 'step/end') { + const start = starts.get(`step:${String(data.turn)}:${String(data.step)}`) + if (start) spans.push(timedRow(`Step ${String(data.step)}`, start, event, 'step', first)) + } + if (event.type === 'tool/call') { + const result = events.find(candidate => candidate.type === 'tool/result' && asRecord(candidate.data).callId === data.callId) + spans.push(timedRow(`Tool · ${String(data.name ?? data.callId ?? 'tool')}`, event, result ?? event, 'tool-call', first, 'green')) + } + } + return spans.sort((a, b) => a.startMs - b.startMs) +} + +function timedRow(title: string, start: SessionEvent, end: SessionEvent, kind: InspectorTarget['kind'], zero: number, tone?: SpanRow['tone']): SpanRow { + const durationMs = Math.max(0, (end.time ?? start.time ?? 0) - (start.time ?? 0)) + return { + target: makeTarget(kind, title, start.seq ?? 0, `seq ${start.seq ?? 0}`), + title, + subtitle: `${eventTime(start)} → ${eventTime(end)}`, + startMs: Math.max(0, (start.time ?? 0) - zero), + durationMs, + tone, + } +} + +function latestRequestContext(): RequestContextSnapshot { + const event = [...(state.trace?.events ?? [])].reverse().find(candidate => candidate.type === 'request/header' || candidate.type === 'request/header-delta') + const data = asRecord(event?.data) + const header = asRecord(data.header) + const delta = event?.type === 'request/header-delta' ? data : {} + const systemValue = header.system ?? delta.system ?? '' + const toolsValue = header.tools ?? delta.tools ?? [] + return { + event, + seq: event?.seq ?? 0, + header, + delta, + system: typeof systemValue === 'string' ? systemValue : renderValue(systemValue), + tools: Array.isArray(toolsValue) ? toolsValue : [], + config: header.config ?? delta.config ?? {}, + messagePrefix: header.messagePrefix ?? delta.messagePrefix ?? [], + } +} + +function contextRows(): ContextRow[] { + const events = state.trace?.events ?? [] + const request = latestRequestContext() + const rows: ContextRow[] = [] + if (request.event !== undefined) { + rows.push(contextRow('System prompt', `${request.system.length} chars`, request.system || 'No system prompt found in latest request header.', 'system', 'request', request.seq, Boolean(request.delta.system))) + rows.push(contextRow('Tool schemas', `${request.tools.length} tools`, request.tools.length > 0 ? request.tools.map(tool => String(asRecord(tool).name ?? 'tool')).join(', ') : 'No tool schemas found in latest request header.', 'tools', 'request', request.seq, Boolean(request.delta.tools))) + rows.push(contextRow('Call config', request.config === undefined ? 'empty' : 'available', renderValue(request.config), 'config', 'request', request.seq, Boolean(request.delta.config))) + rows.push(contextRow('Message prefix', Array.isArray(request.messagePrefix) ? `${request.messagePrefix.length} messages` : 'derived', renderValue(request.messagePrefix), 'messages', 'request', request.seq, Boolean(request.delta.messagePrefix))) + } + const modelVisible = rowsFromEvents(events).filter(row => row.role === 'user' || row.role === 'assistant' || row.role === 'context') + rows.push(contextRow('Derived history', `${modelVisible.length} visible rows`, modelVisible.map(row => `${row.title}: ${truncate(row.body, 80)}`).join('\n'), 'messages', 'context-section', 0)) + rows.push(contextRow('Raw JSONL', `${events.length} events`, state.trace?.rawText ?? '', 'raw', 'session', 0)) + return rows +} + +function contextRow( + title: string, + subtitle: string, + preview: string, + kind: ContextRow['kind'], + targetKind: InspectorTarget['kind'], + seq: number, + changed = false, +): ContextRow { + return { + target: makeTarget(targetKind, title, seq, `seq ${seq}`), + title, + subtitle, + preview: truncate(preview, 240), + kind, + changed, + } +} + +function payloadForTarget(target: InspectorTarget): TargetPayload { + const events = state.trace?.events ?? [] + const seq = Number(target.id.match(/seq:(\d+)/)?.[1] ?? Number.NaN) + const event = Number.isFinite(seq) ? events.find(candidate => candidate.seq === seq) : undefined + const data = asRecord(event?.data) + return { + input: inspectorInput(target, event), + output: inspectorOutput(target, event), + metadata: { + target, + session: state.trace?.header, + event, + eventWindow: Number.isFinite(seq) ? events.filter(candidate => Math.abs((candidate.seq ?? 0) - seq) <= 4) : events.slice(0, 20), + rawJsonlPath: state.trace?.relativePath ?? state.trace?.path, + runtime: state.runtime, + dev: state.dev, + surface: state.activeSurface, + module: state.activeModule, + dataKeys: Object.keys(data), + }, + } +} + +function inspectorInput(target: InspectorTarget, event: SessionEvent | undefined): unknown { + if (target.kind === 'request') return latestRequestHeader() + if (target.kind === 'dev-object') return devObjectPayload(target) + return event?.data ?? target +} + +function inspectorOutput(target: InspectorTarget, event: SessionEvent | undefined): unknown { + if (target.kind === 'session') return { trace: state.trace, feedback: state.trace?.feedback ?? [] } + if (target.kind === 'context-section' || target.kind === 'request') return contextRows() + if (target.kind === 'dev-object') return devObjectPayload(target) + return event ?? target +} + +function latestRequestHeader(): unknown { + const request = latestRequestContext() + return request.event?.data ?? {} +} + +function devObjectPayload(target: InspectorTarget): unknown { + const request = latestRequestContext() + const syntheticId = target.id.match(/synthetic:(.+)$/)?.[1] ?? '' + if (syntheticId === 'system-prompt') return request.system + if (syntheticId === 'message-prefix') return request.messagePrefix + if (syntheticId === 'derived-history') { + return rowsFromEvents(state.trace?.events ?? []) + .filter(row => row.role === 'user' || row.role === 'assistant' || row.role === 'context') + .map(row => ({ role: row.role, title: row.title, body: row.body, eventSeqs: row.eventSeqs })) + } + if (syntheticId === 'raw-jsonl') return state.trace?.rawText ?? '' + if (syntheticId === 'request-header') return request.header + if (syntheticId === 'call-config') return request.config + if (syntheticId.startsWith('tool-')) { + const index = Number(syntheticId.match(/^tool-(\d+)-/)?.[1] ?? Number.NaN) + if (Number.isFinite(index)) return request.tools[index] ?? target + } + if (syntheticId === 'config-path') return asRecord(state.runtime).configPath + if (syntheticId === 'watched-paths') return asRecord(state.dev).watchedPaths + if (syntheticId === 'dev-status') return state.dev + if (syntheticId === 'runtime-status') return { runtime: state.runtime, dev: state.dev } + return { + target, + repoRoot: runtimeRepoRoot(), + configPath: asRecord(state.runtime).configPath, + watchedPaths: asRecord(state.dev).watchedPaths, + note: 'The first implementation exposes the backend status. File-level editing surfaces can bind here next.', + } +} + +function feedbackForTarget(): FeedbackRecord[] { + const selected = state.selectedTarget + if (selected === undefined) return [] + return (state.trace?.feedback ?? []).filter(record => record.data?.targetId === selected.id) +} + +document.addEventListener('click', event => { + const element = event.target instanceof Element ? event.target : undefined + + const module = element?.closest('[data-module]')?.dataset.module as AppModule | undefined + if (module !== undefined) { + state.activeModule = module + state.selectedTarget = undefined + render() + return + } + + const surface = element?.closest('[data-surface]')?.dataset.surface as DesktopSurface | undefined + if (surface !== undefined) { + state.activeSurface = surface + render() + return + } + + const sessionId = element?.closest('[data-session]')?.dataset.session + if (sessionId !== undefined) { + state.activeModule = 'sessions' + state.draftChat = false + void loadTrace(sessionId) + return + } + + const devArtifactId = element?.closest('[data-dev-artifact]')?.dataset.devArtifact + if (devArtifactId !== undefined) { + state.activeDevArtifactId = devArtifactId + render() + return + } + + const targetId = element?.closest('[data-target]')?.dataset.target + if (targetId !== undefined) { + const target = findTarget(targetId) + state.selectedTarget = target + state.activeInspectorTab = target === undefined ? 'input' : defaultInspectorTabForTarget(target) + render() + return + } + + const inspectorTab = element?.closest('[data-inspector-tab]')?.dataset.inspectorTab + if (inspectorTab !== undefined) { + state.activeInspectorTab = inspectorTab as InspectorTab + renderInspectorOnly() + return + } + + const action = element?.closest('[data-action]')?.dataset.action + if (action === 'toggle-locale') { + state.locale = state.locale === 'zh-CN' ? 'en-US' : 'zh-CN' + localStorage.setItem('dsh.locale', state.locale) + render() + return + } + + if (action === 'close-selection') { + state.selectedTarget = undefined + render() + } else if (action === 'new-session') { + startDraftChat() + } else if (action === 'reload-trace' && state.selectedSessionId !== undefined) { + void loadTrace(state.selectedSessionId).then(() => refreshSessions(state.selectedSessionId)) + } else if (action === 'restart-runtime') { + if (!hasDesktopApi()) { + state.error = 'Desktop API is not available. Use the Electron window, not the browser tab.' + render() + return + } + void window.dshDesktop.runtime.restart().then(refreshRuntime) + } +}) + +document.addEventListener('input', event => { + const input = event.target instanceof HTMLInputElement ? event.target : undefined + if (input?.dataset.search === 'true') { + state.query = input.value + render() + return + } + const textarea = event.target instanceof HTMLTextAreaElement ? event.target : undefined + if (textarea !== undefined && textarea.closest('[data-prompt-form="true"]') !== null) { + autosizeComposer(textarea) + updateComposerSubmit(textarea) + } +}) + +document.addEventListener('submit', event => { + const form = event.target instanceof HTMLFormElement ? event.target : undefined + if (form?.dataset.promptForm === 'true') { + event.preventDefault() + if (state.busy) return + const prompt = String(new FormData(form).get('prompt') ?? '').trim() + if (prompt.length > 0) void sendPrompt(prompt, form) + } else if (form?.dataset.feedbackForm === 'true') { + event.preventDefault() + void addFeedback(form) + } +}) + +document.addEventListener('keydown', event => { + const textarea = event.target instanceof HTMLTextAreaElement ? event.target : undefined + if (textarea !== undefined && textarea.closest('[data-prompt-form="true"]') !== null) { + if (event.key !== 'Enter' || event.shiftKey || event.metaKey || event.ctrlKey || event.altKey) return + event.preventDefault() + const form = textarea.closest('[data-prompt-form="true"]') + if (form !== null) form.requestSubmit() + return + } + +}) + +function autosizeComposer(textarea: HTMLTextAreaElement): void { + textarea.style.height = '0px' + textarea.style.height = `${Math.min(132, Math.max(42, textarea.scrollHeight))}px` +} + +function updateComposerSubmit(textarea: HTMLTextAreaElement): void { + const form = textarea.closest('[data-prompt-form="true"]') + const button = form?.querySelector('button[type="submit"]') + if (button !== undefined && button !== null) { + button.disabled = state.busy || !hasDesktopApi() || textarea.value.trim().length === 0 + } +} + +function startDraftChat(): void { + state.activeModule = 'sessions' + state.activeSurface = 'chat' + state.selectedSessionId = undefined + state.trace = undefined + state.selectedTarget = undefined + state.draftChat = true + state.error = '' + render() + focusComposer() +} + +async function createBackendSession(): Promise { + if (!hasDesktopApi()) { + state.error = 'Desktop API is not available. Use the Electron window, not the browser tab.' + render() + return undefined + } + try { + const result = asRecord(await window.dshDesktop.sessions.create()) + state.selectedSessionId = String(result.sessionId) + state.trace = result.trace as TracePayload + state.draftChat = false + return state.selectedSessionId + } catch (error) { + state.error = String(error) + render() + return undefined + } +} + +async function sendPrompt(prompt: string, form: HTMLFormElement): Promise { + if (!hasDesktopApi()) { + state.error = 'Desktop API is not available. Use the Electron window, not the browser tab.' + render() + return + } + if (state.selectedSessionId === undefined) await createBackendSession() + if (state.selectedSessionId === undefined) return + const sessionId = state.selectedSessionId + form.reset() + const textarea = form.querySelector('textarea[name="prompt"]') + if (textarea !== null) autosizeComposer(textarea) + const rows = state.liveRows.get(sessionId) ?? [] + rows.push(makeSyntheticRow(sessionId, 'user', 'User', prompt, `user-${Date.now()}`)) + state.liveRows.set(sessionId, rows) + state.pendingAssistant.delete(sessionId) + state.pendingThinking.delete(sessionId) + state.busy = true + state.error = '' + render() + try { + const result = asRecord(await window.dshDesktop.sessions.prompt(sessionId, prompt)) + state.trace = result.trace as TracePayload + state.liveRows.delete(sessionId) + state.pendingAssistant.delete(sessionId) + state.pendingThinking.delete(sessionId) + await refreshSessions(sessionId) + } catch (error) { + state.error = String(error) + } finally { + state.busy = false + render() + focusComposer() + } +} + +function focusComposer(): void { + window.requestAnimationFrame(() => { + document.querySelector('.composer textarea')?.focus() + }) +} + +async function addFeedback(form: HTMLFormElement): Promise { + if (!hasDesktopApi()) return + if (state.selectedSessionId === undefined || state.selectedTarget === undefined) return + const data = new FormData(form) + const text = String(data.get('text') ?? '').trim() + if (text.length === 0) return + await window.dshDesktop.feedback.add({ + sessionId: state.selectedSessionId, + targetId: state.selectedTarget.id, + targetTitle: state.selectedTarget.title, + targetKind: state.selectedTarget.kind, + author: String(data.get('author') ?? DEFAULT_FEEDBACK_AUTHOR), + text, + }) + await loadTrace(state.selectedSessionId, false) + state.activeInspectorTab = 'feedback' + render() +} + +function findTarget(id: string): InspectorTarget | undefined { + const targets = [ + ...chatRows().map(row => row.target), + ...trajectoryRows().map(row => row.target), + ...waterfallRows().map(row => row.target), + ...contextRows().map(row => row.target), + ] + return targets.find(target => target.id === id) ?? targetFromId(id) +} + +function selectedClass(target: InspectorTarget): string { + return isSelectedTarget(target) ? 'is-selected' : '' +} + +function isSelectedTarget(target: InspectorTarget): boolean { + const selected = state.selectedTarget + if (selected === undefined) return false + if (selected.id === target.id) return true + const selectedSeq = selected.id.match(/seq:(\d+)/)?.[1] + const targetSeq = target.id.match(/seq:(\d+)/)?.[1] + return selectedSeq !== undefined && selectedSeq === targetSeq +} + +function syncSelectionAfterRender(): void { + if (state.selectedTarget === undefined) return + window.requestAnimationFrame(() => { + const selected = document.querySelector('.is-selected[data-target], .is-selected [data-target]') + selected?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) + }) +} + +function targetFromId(id: string): InspectorTarget | undefined { + const kind = id.match(/kind:([^:]+)/)?.[1] as InspectorTarget['kind'] | undefined + if (kind === undefined) return undefined + return { id, kind, title: label(kind), subtitle: 'derived target' } +} + +function makeTarget(kind: InspectorTarget['kind'], title: string, seq: number, subtitle?: string): InspectorTarget { + const sessionId = state.selectedSessionId ?? state.trace?.sessionId ?? 'unknown' + return { + id: createInspectorTargetId({ sessionId, kind, eventSeq: seq }), + kind, + title, + subtitle: subtitle ?? `seq ${seq}`, + } +} + +function currentSession(): SessionSummary | undefined { + return state.sessions.find(session => session.id === state.selectedSessionId) +} + +function uniqueTurns(): number[] { + const turns = new Set() + for (const event of state.trace?.events ?? []) { + const turn = Number(asRecord(event.data).turn) + if (Number.isFinite(turn)) turns.add(turn) + } + return [...turns].sort((a, b) => a - b) +} + +function moduleTitle(): string { + if (state.activeModule === 'sessions') return 'Sessions' + return 'Develop' +} + +function topbarTitle(session: SessionSummary | undefined): string { + if (state.activeModule !== 'sessions') return moduleTitle() + return session?.title || 'Sessions' +} + +function surfaceLabel(surface: DesktopSurface): string { + if (surface === 'chat') return t('surface.chat') + if (surface === 'trajectory') return t('surface.trajectory') + if (surface === 'waterfall') return t('surface.waterfall') + if (surface === 'context') return 'Context' + if (surface === 'compare') return 'Compare' + return 'Dev' +} + +function runtimeLabel(): string { + const runtime = asRecord(state.runtime) + return `ACP ${String(runtime.state ?? 'starting')}` +} + +function runtimeStateClass(): string { + const runtime = asRecord(state.runtime) + const value = String(runtime.state ?? 'starting') + if (value === 'running') return 'running' + if (value === 'error') return 'error' + return 'starting' +} + +function runtimeRepoRoot(): string { + return String(asRecord(state.runtime).repoRoot ?? '/Users/tn.shen/Documents/deepseek-harness-master') +} + +function gitField(key: string): string { + const git = asRecord(asRecord(state.dev).git) + return String(git[key] ?? 'unknown') +} + +function gitSummary(): string { + return `${gitField('branch')} @ ${gitField('commit')}${gitField('dirty') === 'true' ? ' · dirty' : ''}` +} + +function summarizeHeaderEvent(event: SessionEvent): string { + const data = asRecord(event.data) + const header = asRecord(data.header) + if (event.type === 'request/header') { + const tools = Array.isArray(header.tools) ? header.tools.length : 0 + return `system ${String(header.system ?? '').length} chars · ${tools} tools` + } + return truncate(renderValue(data), 160) +} + +function summarizeTrigger(value: unknown): string { + const text = renderValue(value) + return text === '{}' ? 'user prompt' : truncate(text, 120) +} + +function eventTime(event: SessionEvent): string { + if (typeof event.time !== 'number') return 'unknown' + return new Date(event.time).toLocaleTimeString() +} + +function asSessionUpdate(value: unknown): SessionUpdatePayload { + const record = asRecord(value) + return { + sessionId: String(record.sessionId ?? ''), + update: asRecord(record.update), + } +} + +function hasDesktopApi(): boolean { + return window.dshDesktop !== undefined +} + +function asRecord(value: unknown): Record { + return value !== null && typeof value === 'object' ? value as Record : {} +} + +function contentText(value: unknown): string { + if (!Array.isArray(value)) return '' + return value.map(block => { + const record = asRecord(block) + if (record.type === 'text' || record.type === 'reasoning') return String(record.text ?? '') + if (record.type === 'resource_link') return `[resource ${String(record.name ?? '')}] ${String(record.uri ?? '')}` + return JSON.stringify(record) + }).filter(Boolean).join('\n') +} + +function renderValue(value: unknown): string { + if (typeof value === 'string') return value + return JSON.stringify(value, null, 2) +} + +function parseMaybeJson(value: unknown): unknown { + if (typeof value !== 'string') return value + try { + return JSON.parse(value) + } catch { + return value + } +} + +function formatDevValue(value: unknown): string { + if (typeof value === 'string') return value + if (value === undefined) return 'undefined' + return renderValue(value) +} + +function targetSeq(target: InspectorTarget): number { + return Number(target.id.match(/seq:(\d+)/)?.[1] ?? Number.NaN) +} + +function label(value: string): string { + return value + .split(/[-_]/) + .map(part => part.length === 0 ? part : `${part[0]!.toUpperCase()}${part.slice(1)}`) + .join(' ') +} + +function formatMs(ms: number): string { + return ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${Math.round(ms)}ms` +} + +function formatRelativeTime(time: number): string { + const diff = Date.now() - time + if (!Number.isFinite(diff) || diff < 0) return 'now' + if (diff < 60_000) return 'now' + if (diff < 3_600_000) return `${Math.round(diff / 60_000)}m ago` + if (diff < 86_400_000) return `${Math.round(diff / 3_600_000)}h ago` + return `${Math.round(diff / 86_400_000)}d ago` +} + +function truncate(value: string, limit = 180): string { + const text = value.replace(/\s+/g, ' ').trim() + return text.length > limit ? `${text.slice(0, limit - 1)}...` : text +} + +function shortId(value: string): string { + return value.length > 8 ? value.slice(0, 8) : value +} + +function shortPath(value: string): string { + const parts = value.split('/').filter(Boolean) + if (parts.length <= 2) return value + return `.../${parts.slice(-2).join('/')}` +} + +function escapeHtml(value: string): string { + return String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') +} + +function renderMarkdown(text: string): string { + return escapeHtml(text) + .split(/\n{2,}/) + .map(part => `

${part.replaceAll('\n', '
')}

`) + .join('') +} diff --git a/packages/ui/desktop/src/css.d.ts b/packages/ui/desktop/src/css.d.ts new file mode 100644 index 0000000000..5894ae0b6e --- /dev/null +++ b/packages/ui/desktop/src/css.d.ts @@ -0,0 +1 @@ +declare module '*.css' diff --git a/packages/ui/desktop/src/global.d.ts b/packages/ui/desktop/src/global.d.ts new file mode 100644 index 0000000000..a87d6fd6f8 --- /dev/null +++ b/packages/ui/desktop/src/global.d.ts @@ -0,0 +1,34 @@ +export {} + +declare global { + interface Window { + dshDesktop: { + runtime: { + start(): Promise + stop(): Promise + restart(): Promise + status(): Promise + onStatus(callback: (payload: unknown) => void): () => void + onStderr(callback: (payload: unknown) => void): () => void + } + sessions: { + list(): Promise + create(): Promise + load(sessionId: string): Promise + prompt(sessionId: string, text: string): Promise + cancel(sessionId: string): Promise + onUpdate(callback: (payload: unknown) => void): () => void + } + trace: { + read(sessionId: string): Promise + } + feedback: { + list(sessionId: string, targetId?: string): Promise + add(entry: Record): Promise + } + dev: { + status(): Promise + } + } + } +} diff --git a/packages/ui/desktop/src/i18n.ts b/packages/ui/desktop/src/i18n.ts new file mode 100644 index 0000000000..529c699474 --- /dev/null +++ b/packages/ui/desktop/src/i18n.ts @@ -0,0 +1,112 @@ +export type Locale = 'zh-CN' | 'en-US' + +const messages = { + 'zh-CN': { + 'app.newChat': '新对话', + 'app.sessions': 'Sessions', + 'app.sessionsSubtitle': 'Chat + trace in one处', + 'app.develop': 'Develop', + 'app.developSubtitle': 'Prompt、工具、插件、运行时', + 'app.searchPlaceholder': '搜索标题、id、模型', + 'app.recentSessions': '最近 sessions', + 'app.language': 'EN', + 'surface.chat': 'Chat', + 'surface.trajectory': 'Trajectory', + 'surface.waterfall': 'Waterfall', + 'chat.details': '详情', + 'chat.thinking': 'Thinking', + 'chat.toolUse': 'Tool use', + 'chat.toolFailed': 'Tool failed', + 'chat.input': 'Input', + 'chat.output': 'Output', + 'chat.errorOutput': 'Error output', + 'chat.openInspector': '在检查器中打开', + 'chat.emptyTitle': '还没有消息', + 'chat.emptyBody': '从底部输入框发出一条消息。Thinking 和 tool use 会默认折叠,但可以随时展开。', + 'chat.newTitle': '新对话', + 'chat.newBody': '先输入一句话。发送后才会创建真实 ACP session,并在左侧出现记录。', + 'chat.startTitle': '开始一个 Deepseek Harness session', + 'chat.startBody': '点击 New chat 只会打开草稿;真正发送第一句话后,才会创建后端 session。', + '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', + 'empty.traceTitle': '还没有 trace', + 'empty.traceBody': '先运行一条 prompt,这里会读取真实 JSONL trace。', + 'inspector.close': '关闭', + 'feedback.empty': '这个对象还没有 feedback。', + 'feedback.author': 'Feedback author', + 'feedback.placeholder': '给这个对象写 feedback', + 'feedback.add': '添加 feedback', + 'composer.placeholderDraft': '先输入一句话创建 session', + 'composer.placeholderSession': 'Message Deepseek Harness', + }, + 'en-US': { + 'app.newChat': 'New chat', + 'app.sessions': 'Sessions', + 'app.sessionsSubtitle': 'Chat + trace in one place', + 'app.develop': 'Develop', + 'app.developSubtitle': 'Prompts, tools, plugins, runtime', + 'app.searchPlaceholder': 'Search title, id, model', + 'app.recentSessions': 'Recent sessions', + 'app.language': '中文', + 'surface.chat': 'Chat', + 'surface.trajectory': 'Trajectory', + 'surface.waterfall': 'Waterfall', + 'chat.details': 'Details', + 'chat.thinking': 'Thinking', + 'chat.toolUse': 'Tool use', + 'chat.toolFailed': 'Tool failed', + 'chat.input': 'Input', + 'chat.output': 'Output', + 'chat.errorOutput': 'Error output', + 'chat.openInspector': 'Open in inspector', + '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.startBody': 'New chat opens a draft only. The backend session is created after the first sent message.', + '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.noSystem': 'No system prompt recorded.', + 'waterfall.title': 'Waterfall', + 'waterfall.body': 'Find slow spans, tool waits, and errors by duration, then jump back to Trajectory for detail.', + 'waterfall.total': 'total', + 'waterfall.turns': 'turns', + 'waterfall.steps': 'steps', + 'waterfall.tools': 'tools', + 'waterfall.slowest': 'slowest', + 'waterfall.errors': 'errors', + 'empty.traceTitle': 'No trace yet', + 'empty.traceBody': 'Run a prompt first; this surface will read the real JSONL trace.', + 'inspector.close': 'Close', + 'feedback.empty': 'No feedback for this object yet.', + 'feedback.author': 'Feedback author', + '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', + }, +} as const + +export type I18nKey = keyof typeof messages['en-US'] + +export function translate(locale: Locale, key: I18nKey): string { + return messages[locale][key] ?? messages['en-US'][key] ?? key +} diff --git a/packages/ui/desktop/src/index.ts b/packages/ui/desktop/src/index.ts new file mode 100644 index 0000000000..76390b0a90 --- /dev/null +++ b/packages/ui/desktop/src/index.ts @@ -0,0 +1,338 @@ +/** + * 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 + * design notes. + * + * @module @deepseek-ai/dsh-desktop + */ + +/** 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. */ +export type DesktopSurface = (typeof DESKTOP_SURFACES)[number] + +/** Right-side inspector tabs, ordered as rendered. */ +export const INSPECTOR_TABS = ['input', 'output', 'metadata', 'feedback'] as const + +/** Right-side inspector tabs. */ +export type InspectorTab = (typeof INSPECTOR_TABS)[number] + +/** What a middle surface is primarily for. */ +export type SurfacePurpose = 'drive' | 'navigate' | 'timing' | 'request-anatomy' | 'diff' | 'development' + +/** Stable class of selectable objects shared by all surfaces. */ +export type InspectorTargetKind = + | 'session' + | 'run' + | 'turn' + | 'step' + | 'request' + | 'message' + | 'assistant-stream' + | 'tool-call' + | 'tool-result' + | 'context-section' + | 'waterfall-span' + | 'dev-object' + +/** Stable selector fields used to derive view-independent inspector ids. */ +export interface InspectorTargetKey { + readonly sessionId: string + readonly runId?: string + readonly kind: InspectorTargetKind + readonly eventSeq?: number + readonly syntheticId?: string +} + +/** A selected object that can open the inspector drawer. */ +export interface InspectorTarget { + /** Stable view-independent id, usually derived from session id and event seq. */ + readonly id: string + /** The selected object's normalized kind. */ + readonly kind: InspectorTargetKind + /** Human-readable title shown in the inspector header. */ + readonly title: string + /** Optional compact subtitle, such as `turn 1 step 2` or a duration. */ + readonly subtitle?: string +} + +/** A visible tab in the inspector for a selected object. */ +export interface InspectorTabState { + readonly tab: InspectorTab + readonly available: boolean + readonly summary?: string + readonly fullPayloadRef?: string + readonly canCopy: boolean +} + +/** Right drawer state. It is absent until the user selects an object. */ +export interface InspectorState { + readonly open: boolean + readonly target?: InspectorTarget + readonly activeTab: InspectorTab + readonly tabs: readonly InspectorTabState[] +} + +/** Decides whether the inspector is the right place for complete detail. */ +export interface SurfacePolicy { + /** True when a surface is mainly for navigation, explanation, or comparison. */ + readonly summaryFirst: boolean + /** True when the surface may show a bounded preview inline. */ + readonly inlinePreview: boolean + /** True when full raw payloads belong only in the inspector. */ + readonly fullDetailInInspector: boolean +} + +/** Static description of one middle surface. */ +export interface SurfaceDefinition extends SurfacePolicy { + readonly id: DesktopSurface + readonly label: string + readonly purpose: SurfacePurpose + readonly primaryQuestion: string + readonly ownsComposer: boolean +} + +/** Lifecycle state for the managed Harness runtime subprocess. */ +export type RuntimeState = 'stopped' | 'starting' | 'running' | 'restart-needed' | 'stopping' | 'error' + +/** Metadata attached to a run so replay and compare can explain provenance. */ +export interface RunArtifact { + readonly runId: string + readonly sessionId: string + readonly createdAt: number + readonly cwd: string + readonly gitCommit?: string + readonly gitDirty?: boolean + readonly runtimeConfigHash?: string + readonly parentRunId?: string + readonly replayOf?: { + readonly runId: string + readonly turn?: number + readonly mode: 'prompt' | 'session-boundary' + } +} + +/** Structural node kinds for the trajectory navigator. */ +export type TrajectoryNodeKind = + | 'session' + | 'turn' + | 'step' + | 'request' + | 'assistant' + | 'tool' + | 'context' + | 'error' + +/** Status shown on structural and timing views. */ +export type NodeStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' | 'unknown' + +/** A bounded structural row. Full payloads stay in the inspector. */ +export interface TrajectoryNode { + readonly id: string + readonly kind: TrajectoryNodeKind + readonly title: string + readonly status: NodeStatus + readonly depth: number + readonly target: InspectorTarget + readonly eventSeqs: readonly number[] + readonly preview?: string + readonly badges?: readonly string[] + readonly children?: readonly TrajectoryNode[] +} + +/** Context sections reconstructed at a selected request boundary. */ +export type ContextSectionKind = + | 'config' + | 'system' + | 'message-prefix' + | 'derived-history' + | 'context-message' + | 'steering-message' + | 'tool-schemas' + | 'compaction' + | 'request-delta' + +/** Summary row in the Context surface. Full text/schema/JSON is inspector-owned. */ +export interface ContextSection { + readonly id: string + readonly kind: ContextSectionKind + readonly title: string + readonly target: InspectorTarget + readonly eventSeqs: readonly number[] + readonly preview?: string + readonly tokenEstimate?: number + readonly changedSincePreviousRequest?: boolean +} + +/** Timing bar shown in Waterfall. */ +export interface WaterfallSpan { + readonly id: string + readonly title: string + readonly target: InspectorTarget + readonly startMs: number + readonly durationMs: number + readonly status: NodeStatus + readonly parentId?: string +} + +/** A user-authored note attached to a stable inspector target. */ +export interface FeedbackEntry { + readonly targetId: string + readonly author: string + readonly body: string + readonly createdAt: number +} + +/** Run pair used by Compare. Compare is not scoped to a single session. */ +export interface ComparePair { + readonly baseline: RunArtifact + readonly candidate: RunArtifact +} + +/** First Dev panel shape: agent-assisted modification plus runtime restart. */ +export interface DevPanelStatus { + readonly runtimeState: RuntimeState + readonly repoDirty: boolean + readonly restartNeeded: boolean + readonly watchedPaths: readonly string[] + readonly suggestedPrompt?: string +} + +export const DEFAULT_FEEDBACK_AUTHOR = 'shentuni' + +/** Default surface definitions for the first Electron implementation. */ +export const SURFACE_DEFINITIONS: Record = { + chat: { + id: 'chat', + label: 'Chat', + purpose: 'drive', + primaryQuestion: 'What did the user and agent say, with thinking and tool use folded into readable activity rows?', + ownsComposer: true, + summaryFirst: true, + inlinePreview: true, + fullDetailInInspector: true, + }, + trajectory: { + id: 'trajectory', + label: 'Trajectory', + purpose: 'navigate', + primaryQuestion: 'Where am I in the session, turn, step, request, assistant, tool, and context structure?', + ownsComposer: false, + summaryFirst: true, + inlinePreview: true, + fullDetailInInspector: true, + }, + waterfall: { + id: 'waterfall', + label: 'Waterfall', + purpose: 'timing', + primaryQuestion: 'Where did time go across model requests, tool calls, and failures?', + ownsComposer: false, + summaryFirst: true, + inlinePreview: false, + fullDetailInInspector: true, + }, + context: { + id: 'context', + label: 'Context', + purpose: 'request-anatomy', + primaryQuestion: 'What exactly contributed to the selected model request boundary?', + ownsComposer: false, + summaryFirst: true, + inlinePreview: true, + fullDetailInInspector: true, + }, + compare: { + id: 'compare', + label: 'Compare', + purpose: 'diff', + primaryQuestion: 'How did a candidate replay/run differ from the chosen baseline run?', + ownsComposer: false, + summaryFirst: true, + inlinePreview: true, + fullDetailInInspector: true, + }, + dev: { + id: 'dev', + label: 'Dev', + purpose: 'development', + primaryQuestion: 'Which prompt, tool, plugin, and config artifacts compose the repo-bound Harness agent, and what needs reload after editing?', + ownsComposer: false, + summaryFirst: true, + inlinePreview: true, + fullDetailInInspector: false, + }, +} + +/** Backward-compatible alias for callers that only need policies. */ +export const SURFACE_POLICIES: Record = SURFACE_DEFINITIONS + +/** Returns whether selecting from a middle surface should open the inspector. */ +export function opensInspector(surface: DesktopSurface, target: InspectorTarget | undefined): boolean { + return target !== undefined && SURFACE_POLICIES[surface].fullDetailInInspector +} + +/** Returns whether the surface is allowed to show the chat composer. */ +export function ownsComposer(surface: DesktopSurface): boolean { + return SURFACE_DEFINITIONS[surface].ownsComposer +} + +/** Full detail belongs in the inspector for trace-analysis surfaces, not the Develop artifact browser. */ +export function fullDetailBelongsInInspector(surface: DesktopSurface): boolean { + return SURFACE_DEFINITIONS[surface].fullDetailInInspector +} + +/** Create a stable, view-independent inspector id. */ +export function createInspectorTargetId(key: InspectorTargetKey): string { + const parts = [`session:${key.sessionId}`] + if (key.runId !== undefined) parts.push(`run:${key.runId}`) + parts.push(`kind:${key.kind}`) + if (key.eventSeq !== undefined) parts.push(`seq:${String(key.eventSeq)}`) + if (key.syntheticId !== undefined) parts.push(`synthetic:${key.syntheticId}`) + return parts.join(':') +} + +/** Pick a useful starting inspector tab for common target kinds. */ +export function defaultInspectorTabForTarget(target: InspectorTarget): InspectorTab { + switch (target.kind) { + case 'assistant-stream': + case 'tool-result': + return 'output' + case 'session': + case 'run': + case 'turn': + case 'step': + case 'waterfall-span': + return 'metadata' + case 'dev-object': + return 'input' + default: + return 'input' + } +} + +/** Build closed drawer state when no node is selected. */ +export function closedInspectorState(): InspectorState { + return { + open: false, + activeTab: 'input', + tabs: [], + } +} + +/** Build an open drawer state for a selected target. */ +export function openInspectorState(target: InspectorTarget): InspectorState { + return { + open: true, + target, + activeTab: defaultInspectorTabForTarget(target), + tabs: INSPECTOR_TABS.map((tab) => ({ + tab, + available: true, + canCopy: tab !== 'feedback', + })), + } +} diff --git a/packages/ui/desktop/src/main.mjs b/packages/ui/desktop/src/main.mjs new file mode 100644 index 0000000000..10cc864d21 --- /dev/null +++ b/packages/ui/desktop/src/main.mjs @@ -0,0 +1,558 @@ +import { spawn, execFileSync } from 'node:child_process' +import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, appendFileSync } from 'node:fs' +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 { + ClientSideConnection, + PROTOCOL_VERSION, + ndJsonStream, +} from '@agentclientprotocol/sdk' + +const here = dirname(fileURLToPath(import.meta.url)) +const packageRoot = resolve(here, '..') +const repoRoot = resolve(packageRoot, '../../..') +const sessionsRoot = resolve(repoRoot, '.sessions') +const feedbackRoot = resolve(sessionsRoot, '.desktop-feedback') +const acpConfigPath = resolve(repoRoot, 'examples/acp-agent/cordis.yml') + +/** @type {BrowserWindow | undefined} */ +let mainWindow + +/** @type {'stopped' | 'starting' | 'running' | 'stopping' | 'error'} */ +let runtimeState = 'stopped' +/** @type {import('node:child_process').ChildProcessWithoutNullStreams | undefined} */ +let runtimeProcess +/** @type {ClientSideConnection | undefined} */ +let acpClient +let initializeResult +let stderrTail = '' +/** @type {Map} */ +const activeSessions = new Map() + +function broadcast(channel, payload) { + for (const window of BrowserWindow.getAllWindows()) { + window.webContents.send(channel, payload) + } +} + +function setRuntimeState(next, extra = {}) { + runtimeState = next + broadcast('runtime:status-update', runtimeStatus(extra)) +} + +function runtimeStatus(extra = {}) { + return { + state: runtimeState, + pid: runtimeProcess?.pid, + repoRoot, + sessionsRoot, + configPath: acpConfigPath, + initialized: initializeResult, + stderrTail, + ...extra, + } +} + +async function ensureRuntime() { + if (acpClient !== undefined && runtimeState === 'running') return acpClient + await startRuntime() + if (acpClient === undefined) throw new Error('ACP runtime did not start') + return acpClient +} + +async function startRuntime() { + if (runtimeState === 'running' || runtimeState === 'starting') return runtimeStatus() + setRuntimeState('starting') + stderrTail = '' + activeSessions.clear() + + runtimeProcess = spawn('node', [ + '--import', + 'tsx', + 'packages/examples/acp-demo/src/bin.ts', + '--config', + relative(repoRoot, acpConfigPath), + ], { + cwd: repoRoot, + env: { ...process.env }, + stdio: ['pipe', 'pipe', 'pipe'], + }) + + runtimeProcess.stderr.setEncoding('utf8') + runtimeProcess.stderr.on('data', chunk => { + stderrTail = `${stderrTail}${chunk}`.slice(-10_000) + broadcast('runtime:stderr', { text: String(chunk), tail: stderrTail }) + }) + + runtimeProcess.on('exit', (code, signal) => { + runtimeProcess = undefined + acpClient = undefined + initializeResult = undefined + activeSessions.clear() + setRuntimeState(code === 0 ? 'stopped' : 'error', { exit: { code, signal } }) + }) + + const stream = ndJsonStream( + Writable.toWeb(runtimeProcess.stdin), + Readable.toWeb(runtimeProcess.stdout), + ) + + acpClient = new ClientSideConnection(() => ({ + sessionUpdate(params) { + broadcast('sessions:update', params) + return Promise.resolve() + }, + async requestPermission(params) { + const options = params.options.map(option => `${option.name ?? option.optionId} (${option.kind})`) + const result = await dialog.showMessageBox(mainWindow, { + type: 'question', + buttons: [...options, 'Cancel'], + cancelId: options.length, + defaultId: 0, + title: 'Harness permission request', + message: params.toolCall.title ?? 'Tool permission request', + detail: JSON.stringify(params.toolCall.rawInput ?? params.toolCall, null, 2), + }) + const option = params.options[result.response] + if (option === undefined) return { outcome: { outcome: 'cancelled' } } + return { outcome: { outcome: 'selected', optionId: option.optionId } } + }, + async unstable_createElicitation(params) { + const result = await dialog.showMessageBox(mainWindow, { + type: 'question', + buttons: ['Accept', 'Cancel'], + cancelId: 1, + defaultId: 0, + title: 'Harness needs input', + message: params.message, + detail: JSON.stringify(params, null, 2), + }) + return result.response === 0 ? { action: 'accept', content: {} } : { action: 'cancel' } + }, + }), stream) + + initializeResult = await acpClient.initialize({ + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: { + _meta: { terminal_output: true }, + }, + }) + setRuntimeState('running') + return runtimeStatus() +} + +async function stopRuntime() { + if (runtimeProcess === undefined) { + setRuntimeState('stopped') + return runtimeStatus() + } + setRuntimeState('stopping') + const proc = runtimeProcess + await new Promise(resolvePromise => { + proc.once('exit', resolvePromise) + proc.stdin.end() + setTimeout(() => { + if (!proc.killed) proc.kill('SIGTERM') + }, 1000).unref() + }) + return runtimeStatus() +} + +function walkJsonl(dir, out = []) { + if (!existsSync(dir)) return out + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name) + if (entry.isDirectory()) { + if (entry.name !== '.desktop-feedback') walkJsonl(full, out) + } else if (entry.isFile() && entry.name.endsWith('.jsonl')) { + out.push(full) + } + } + return out +} + +function readJsonl(file) { + const text = readFileSync(file, 'utf8') + const rows = [] + for (const [index, line] of text.split(/\r?\n/).entries()) { + if (!line.trim()) continue + try { + rows.push(JSON.parse(line)) + } catch (error) { + rows.push({ type: 'parse/error', seq: index, time: 0, data: { line, error: String(error) } }) + } + } + const first = rows[0] + const header = first?.type === 'session' + ? { ...first, path: file } + : { type: 'session', version: 0, id: file.split('/').at(-1)?.replace(/\.jsonl$/, ''), createdAt: 0, path: file } + const events = first?.type === 'session' ? rows.slice(1) : rows + return { header, events, rawText: text } +} + +function textOfContent(content) { + if (!Array.isArray(content)) return '' + return content.map(block => { + if (block?.type === 'text' || block?.type === 'reasoning') return block.text ?? '' + if (block?.type === 'tool-call') return `[tool-call ${block.name}] ${block.arguments ?? ''}` + return JSON.stringify(block) + }).filter(Boolean).join('\n') +} + +function latestHeader(events) { + return events.filter(event => event.type === 'request/header' && event.data?.header).at(-1)?.data?.header ?? {} +} + +function summarizeSession(file) { + const { header, events } = readJsonl(file) + const requestHeader = latestHeader(events) + const firstUser = events.find(event => event.type === 'user/message') + const last = events.at(-1) + return { + id: String(header.id), + cwd: header.cwd, + path: file, + relativePath: relative(repoRoot, file), + createdAt: header.createdAt || events[0]?.time || statSync(file).birthtimeMs, + lastActivity: last?.time || statSync(file).mtimeMs, + eventCount: events.length, + turnCount: events.filter(event => event.type === 'turn/start').length, + stepCount: events.filter(event => event.type === 'step/start').length, + toolCallCount: events.filter(event => event.type === 'tool/call').length, + model: requestHeader.config?.model, + title: textOfContent(firstUser?.data?.content).slice(0, 120) || String(header.id), + live: activeSessions.has(String(header.id)), + } +} + +function listSessions() { + const persisted = walkJsonl(sessionsRoot).map(summarizeSession) + const persistedIds = new Set(persisted.map(session => session.id)) + const liveOnly = [...activeSessions.values()] + .filter(session => !persistedIds.has(session.sessionId)) + .map(session => ({ + id: session.sessionId, + cwd: session.cwd, + path: undefined, + relativePath: undefined, + createdAt: Date.now(), + lastActivity: Date.now(), + eventCount: 0, + turnCount: 0, + stepCount: 0, + toolCallCount: 0, + model: undefined, + title: 'New live session', + live: true, + })) + return [...liveOnly, ...persisted].sort((a, b) => b.lastActivity - a.lastActivity) +} + +function findSessionFile(sessionId) { + return walkJsonl(sessionsRoot).find(file => { + if (file.endsWith(`${sessionId}.jsonl`)) return true + try { + return String(readJsonl(file).header.id) === sessionId + } catch { + return false + } + }) +} + +function readTrace(sessionId) { + const file = findSessionFile(sessionId) + if (file === undefined) { + return { found: false, sessionId, header: { id: sessionId, cwd: repoRoot }, events: [], rawText: '' } + } + const trace = readJsonl(file) + return { + found: true, + sessionId, + header: trace.header, + events: trace.events, + rawText: trace.rawText, + path: file, + relativePath: relative(repoRoot, file), + feedback: readFeedback(sessionId), + } +} + +function feedbackFile(sessionId) { + return join(feedbackRoot, `${encodeURIComponent(sessionId)}.feedback.jsonl`) +} + +function readFeedback(sessionId, targetId) { + const file = feedbackFile(sessionId) + if (!existsSync(file)) return [] + return readFileSync(file, 'utf8') + .split(/\r?\n/) + .filter(Boolean) + .map((line, index) => { + try { + return JSON.parse(line) + } catch (error) { + return { type: 'feedback/parse-error', seq: index, time: 0, data: { line, error: String(error) } } + } + }) + .filter(record => targetId === undefined || record.data?.targetId === targetId) +} + +function appendFeedback(entry) { + const sessionId = String(entry.sessionId ?? '') + if (sessionId.length === 0) throw new Error('sessionId is required') + const rows = readFeedback(sessionId) + const record = { + type: 'feedback/add', + seq: rows.length, + time: Date.now(), + data: { + sessionId, + targetId: String(entry.targetId ?? `session:${sessionId}`), + targetTitle: String(entry.targetTitle ?? sessionId), + targetKind: String(entry.targetKind ?? 'session'), + author: String(entry.author ?? 'shentuni').trim() || 'shentuni', + text: String(entry.text ?? '').trim(), + }, + } + if (record.data.text.length === 0) throw new Error('feedback text is required') + mkdirSync(feedbackRoot, { recursive: true }) + appendFileSync(feedbackFile(sessionId), `${JSON.stringify(record)}\n`) + return record +} + +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 }) +} + +function devStatus() { + let dirty = false + let branch = 'unknown' + let commit = 'unknown' + const configText = readTextSafe(acpConfigPath) + const recentEvidence = summarizeRecentArtifactEvidence() + try { + dirty = execFileSync('git', ['status', '--porcelain'], { cwd: repoRoot, encoding: 'utf8' }).trim().length > 0 + branch = execFileSync('git', ['branch', '--show-current'], { cwd: repoRoot, encoding: 'utf8' }).trim() + commit = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: repoRoot, encoding: 'utf8' }).trim() + } catch { + // Keep the UI usable if git is unavailable. + } + return { + runtime: runtimeStatus(), + git: { dirty, branch, commit }, + watchedPaths: ['packages/**', 'examples/**', 'plugins/**', 'cordis.yml', 'package.json', 'pnpm-lock.yaml'], + restartNeeded: dirty, + recentPromptUses: recentEvidence.promptUses, + recentToolCalls: recentEvidence.toolCalls, + appComposition: { + name: 'Deepseek Harness ACP agent', + entrypoint: 'packages/examples/acp-demo/src/bin.ts', + configPath: relative(repoRoot, acpConfigPath), + configText, + plugins: parseCordisPlugins(configText), + sourceFiles: [ + { + 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.', + }, + { + 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.', + }, + { + label: 'System prompt service', + path: 'packages/system-prompt/system-prompt/src/index.ts', + purpose: 'Owns persona, tool order, and assembled model-facing prompt sections.', + }, + { + label: 'Tool registry', + path: 'packages/tools/tools/src/index.ts', + purpose: 'Owns model-facing tool registration, schema validation, and tool presentation mode.', + }, + ], + }, + } +} + +function summarizeRecentArtifactEvidence() { + const promptUses = [] + const toolCalls = new Map() + for (const file of walkJsonl(sessionsRoot)) { + let trace + try { + trace = readJsonl(file) + } catch { + continue + } + const sessionId = String(trace.header.id) + for (const event of trace.events) { + if (event.type === 'request/header' && event.data?.header) { + const header = event.data.header + promptUses.push({ + sessionId, + relativePath: relative(repoRoot, file), + seq: event.seq, + time: event.time, + systemChars: String(header.system ?? '').length, + tools: Array.isArray(header.tools) ? header.tools.length : 0, + model: header.config?.model, + }) + } + if (event.type === 'tool/call') { + const name = String(event.data?.name ?? event.data?.toolName ?? 'tool') + const existing = toolCalls.get(name) ?? { + name, + count: 0, + lastSessionId: sessionId, + lastRelativePath: relative(repoRoot, file), + lastSeq: event.seq, + lastTime: event.time, + } + existing.count += 1 + if ((event.time ?? 0) >= (existing.lastTime ?? 0)) { + existing.lastSessionId = sessionId + existing.lastRelativePath = relative(repoRoot, file) + existing.lastSeq = event.seq + existing.lastTime = event.time + } + toolCalls.set(name, existing) + } + } + } + return { + promptUses: promptUses + .sort((a, b) => (b.time ?? 0) - (a.time ?? 0)) + .slice(0, 8), + toolCalls: [...toolCalls.values()] + .sort((a, b) => (b.lastTime ?? 0) - (a.lastTime ?? 0)), + } +} + +function readTextSafe(file) { + try { + return readFileSync(file, 'utf8') + } catch (error) { + return `# Unable to read ${file}\n${String(error)}` + } +} + +function parseCordisPlugins(text) { + const plugins = [] + let current + for (const line of text.split(/\r?\n/)) { + const id = line.match(/^\s*-\s+id:\s*(.+?)\s*$/) + if (id) { + if (current !== undefined) plugins.push(current) + current = { id: id[1], name: '', configPreview: '' } + continue + } + if (current === undefined) continue + const name = line.match(/^\s*name:\s*(.+?)\s*$/) + if (name) { + current.name = name[1].replace(/^['"]|['"]$/g, '') + continue + } + if (/^\s{2,}\S/.test(line) && current.configPreview.length < 1600) { + current.configPreview = `${current.configPreview}${line}\n` + } + } + if (current !== undefined) plugins.push(current) + return plugins +} + +function registerIpc() { + ipcMain.handle('runtime:start', async () => startRuntime()) + ipcMain.handle('runtime:stop', async () => stopRuntime()) + ipcMain.handle('runtime:restart', async () => { + await stopRuntime() + return startRuntime() + }) + ipcMain.handle('runtime:status', () => runtimeStatus()) + ipcMain.handle('sessions:list', () => ({ root: sessionsRoot, sessions: listSessions() })) + ipcMain.handle('sessions:create', async () => { + const client = await ensureRuntime() + const response = await client.newSession({ cwd: repoRoot, mcpServers: [] }) + activeSessions.set(response.sessionId, { sessionId: response.sessionId, loaded: true, cwd: repoRoot }) + return { ...response, trace: readTrace(response.sessionId) } + }) + ipcMain.handle('sessions:load', async (_event, { sessionId }) => { + await ensureSessionLoaded(String(sessionId)) + return { sessionId, trace: readTrace(String(sessionId)) } + }) + ipcMain.handle('sessions:prompt', async (_event, { sessionId, text }) => { + const id = String(sessionId) + await ensureSessionLoaded(id) + const client = await ensureRuntime() + const response = await client.prompt({ + sessionId: id, + prompt: [{ type: 'text', text: String(text) }], + }) + return { response, trace: readTrace(id) } + }) + ipcMain.handle('sessions:cancel', async (_event, { sessionId }) => { + const client = await ensureRuntime() + await client.cancel({ sessionId: String(sessionId) }) + return { ok: true } + }) + 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)) + ipcMain.handle('dev:status', () => devStatus()) +} + +async function createWindow() { + mainWindow = new BrowserWindow({ + width: 1440, + height: 940, + minWidth: 1080, + minHeight: 720, + title: 'DeepSeek Harness Desktop', + backgroundColor: '#f5f5f7', + webPreferences: { + preload: join(here, 'preload.cjs'), + contextIsolation: true, + nodeIntegration: false, + }, + }) + + if (process.env.VITE_DEV_SERVER_URL !== undefined) { + await mainWindow.loadURL(process.env.VITE_DEV_SERVER_URL) + mainWindow.webContents.openDevTools({ mode: 'detach' }) + } else { + const builtIndex = join(packageRoot, 'dist/index.html') + if (!existsSync(builtIndex)) { + await mainWindow.loadURL(`data:text/html,${encodeURIComponent('Run pnpm --dir packages/ui/desktop build:ui before pnpm --dir packages/ui/desktop start.')}`) + } else { + await mainWindow.loadURL(pathToFileURL(builtIndex).toString()) + } + } +} + +registerIpc() + +app.whenReady().then(async () => { + await createWindow() + try { + await startRuntime() + } catch (error) { + setRuntimeState('error', { error: String(error) }) + } +}) + +app.on('window-all-closed', () => { + void stopRuntime().finally(() => { + if (process.platform !== 'darwin') app.quit() + }) +}) + +app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) void createWindow() +}) diff --git a/packages/ui/desktop/src/preload.cjs b/packages/ui/desktop/src/preload.cjs new file mode 100644 index 0000000000..18ae117670 --- /dev/null +++ b/packages/ui/desktop/src/preload.cjs @@ -0,0 +1,44 @@ +const { contextBridge, ipcRenderer } = require('electron') + +const api = { + runtime: { + start: () => ipcRenderer.invoke('runtime:start'), + stop: () => ipcRenderer.invoke('runtime:stop'), + restart: () => ipcRenderer.invoke('runtime:restart'), + status: () => ipcRenderer.invoke('runtime:status'), + onStatus: (callback) => { + const listener = (_event, payload) => { callback(payload) } + ipcRenderer.on('runtime:status-update', listener) + return () => { ipcRenderer.removeListener('runtime:status-update', listener) } + }, + onStderr: (callback) => { + const listener = (_event, payload) => { callback(payload) } + ipcRenderer.on('runtime:stderr', listener) + return () => { ipcRenderer.removeListener('runtime:stderr', listener) } + }, + }, + sessions: { + list: () => ipcRenderer.invoke('sessions:list'), + create: () => ipcRenderer.invoke('sessions:create'), + load: (sessionId) => ipcRenderer.invoke('sessions:load', { sessionId }), + prompt: (sessionId, text) => ipcRenderer.invoke('sessions:prompt', { sessionId, text }), + cancel: (sessionId) => ipcRenderer.invoke('sessions:cancel', { sessionId }), + onUpdate: (callback) => { + const listener = (_event, payload) => { callback(payload) } + ipcRenderer.on('sessions:update', listener) + return () => { ipcRenderer.removeListener('sessions:update', listener) } + }, + }, + trace: { + read: (sessionId) => ipcRenderer.invoke('trace:read', { sessionId }), + }, + feedback: { + list: (sessionId, targetId) => ipcRenderer.invoke('feedback:list', { sessionId, targetId }), + add: (entry) => ipcRenderer.invoke('feedback:add', entry), + }, + dev: { + status: () => ipcRenderer.invoke('dev:status'), + }, +} + +contextBridge.exposeInMainWorld('dshDesktop', api) diff --git a/packages/ui/desktop/src/styles.css b/packages/ui/desktop/src/styles.css new file mode 100644 index 0000000000..0475b64b85 --- /dev/null +++ b/packages/ui/desktop/src/styles.css @@ -0,0 +1,1654 @@ +:root { + color-scheme: light; + --bg: #f5f5f4; + --paper: #ffffff; + --paper-soft: #fafaf9; + --sidebar: rgba(238, 238, 236, 0.86); + --sidebar-strong: rgba(226, 226, 223, 0.92); + --ink: #20201f; + --ink-soft: #555552; + --muted: #7b7a76; + --faint: #a4a29d; + --line: #e3e2de; + --line-strong: #d3d2cd; + --accent: #1f6feb; + --green: #238454; + --amber: #9a6700; + --red: #c93c37; + --shadow: 0 18px 44px rgba(15, 15, 15, 0.08); + --ease-out: cubic-bezier(0.23, 1, 0.32, 1); + font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", sans-serif; + font-size: 13px; + line-height: 1.45; + color: var(--ink); + background: var(--bg); +} + +* { + box-sizing: border-box; +} + +html, +body, +#app { + height: 100%; + margin: 0; +} + +body { + overflow: hidden; + -webkit-font-smoothing: antialiased; +} + +button, +input, +textarea { + font: inherit; +} + +button { + border: 0; + background: transparent; + color: inherit; + cursor: pointer; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +button:focus-visible, +input:focus-visible, +textarea:focus-visible, +summary:focus-visible { + outline: 2px solid rgba(31, 111, 235, 0.38); + outline-offset: 2px; +} + +.harness-shell { + display: grid; + grid-template-columns: 268px minmax(0, 1fr); + height: 100%; + overflow: hidden; + background: var(--bg); +} + +.harness-shell.inspector-open { + grid-template-columns: 268px minmax(0, 1fr) 382px; +} + +.source-list { + display: flex; + min-width: 0; + min-height: 0; + flex-direction: column; + overflow: hidden; + padding: 12px 10px; + border-right: 1px solid rgba(0, 0, 0, 0.08); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.42), rgba(255, 255, 255, 0)), + var(--sidebar); + backdrop-filter: blur(28px) saturate(160%); +} + +.traffic-lights { + display: flex; + gap: 8px; + height: 20px; + margin: 2px 0 12px 8px; +} + +.traffic-lights span { + width: 12px; + height: 12px; + border-radius: 50%; +} + +.traffic-lights .red { + background: #ff5f57; +} + +.traffic-lights .yellow { + background: #ffbd2e; +} + +.traffic-lights .green { + background: #28c840; +} + +.app-brand { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 8px 12px; +} + +.brand-title { + overflow: hidden; + font-size: 18px; + font-weight: 720; + letter-spacing: -0.035em; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; +} + +.runtime-dot { + width: 9px; + height: 9px; + border-radius: 999px; + background: var(--amber); + box-shadow: 0 0 0 4px rgba(154, 103, 0, 0.1); +} + +.runtime-dot.running { + background: var(--green); + box-shadow: 0 0 0 4px rgba(35, 132, 84, 0.12); +} + +.runtime-dot.error { + background: var(--red); + box-shadow: 0 0 0 4px rgba(201, 60, 55, 0.12); +} + +.primary-actions { + padding: 0 4px 12px; +} + +.primary-action { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + height: 34px; + padding: 0 10px; + border-radius: 9px; + background: #20201f; + color: #fff; + font-weight: 650; +} + +.language-toggle { + height: 28px; + padding: 0 10px; + border: 1px solid var(--line); + border-radius: 8px; + background: rgba(255, 255, 255, 0.72); + color: var(--ink-soft); + font-size: 12px; + font-weight: 650; +} + +.language-toggle:hover { + background: #fff; +} + +kbd { + font: inherit; + color: rgba(255, 255, 255, 0.62); +} + +.product-nav, +.session-list { + display: grid; + gap: 2px; +} + +.product-nav { + padding-bottom: 14px; +} + +.module-button, +.session-item { + width: 100%; + border-radius: 10px; + text-align: left; + transition: background 120ms var(--ease-out); +} + +.module-button { + min-height: 46px; + padding: 7px 10px; +} + +.module-button:not(.selected) { + opacity: 0.82; +} + +.module-button:hover, +.session-item:hover, +.toolbar-actions button:hover, +.surface-switcher button:hover { + background: rgba(255, 255, 255, 0.58); +} + +.module-button.selected, +.session-item.selected { + background: var(--sidebar-strong); +} + +.module-button span, +.session-copy { + display: grid; + min-width: 0; +} + +.module-button strong, +.session-copy strong { + overflow: hidden; + color: #262624; + font-weight: 650; + text-overflow: ellipsis; + white-space: nowrap; +} + +.module-button small, +.session-copy small { + overflow: hidden; + color: var(--muted); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.session-search { + display: grid; + gap: 6px; + padding: 0 8px 10px; + color: var(--muted); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.session-search input { + width: 100%; + height: 30px; + padding: 0 9px; + border: 1px solid rgba(0, 0, 0, 0.08); + border-radius: 8px; + background: rgba(255, 255, 255, 0.56); + outline: none; +} + +.session-group { + min-height: 0; + margin-bottom: 14px; +} + +.session-group:last-of-type { + flex: 1; + overflow: auto; +} + +.group-title { + padding: 0 8px 5px; + color: var(--faint); + font-size: 11px; + font-weight: 720; + text-transform: uppercase; +} + +.session-item { + display: grid; + grid-template-columns: 18px minmax(0, 1fr); + align-items: center; + min-height: 42px; + gap: 7px; + padding: 6px 8px; +} + +.session-glyph { + color: var(--green); + font-size: 10px; + text-align: center; +} + +.empty-list { + padding: 8px; + color: var(--muted); + font-size: 12px; +} + +.source-footer { + display: grid; + gap: 2px; + margin-top: auto; + padding: 10px 8px 0; + border-top: 1px solid rgba(0, 0, 0, 0.08); + color: var(--muted); + font-size: 11px; +} + +.source-footer strong { + color: var(--ink-soft); +} + +.harness-main { + display: grid; + min-width: 0; + min-height: 0; + grid-template-rows: 58px minmax(0, 1fr) auto; + background: var(--paper); +} + +.topbar { + display: grid; + grid-template-columns: minmax(220px, 1fr) auto auto; + align-items: center; + gap: 14px; + padding: 8px 16px 8px 20px; + border-bottom: 1px solid var(--line); + background: rgba(255, 255, 255, 0.84); + backdrop-filter: blur(24px); +} + +.chat-topbar { + background: rgba(255, 255, 255, 0.74); +} + +.chat-topbar .title-area h1 { + margin-top: 0; +} + +.title-area { + min-width: 0; +} + +.crumb { + overflow: hidden; + color: var(--muted); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.title-area h1 { + overflow: hidden; + margin: 1px 0 0; + font-size: 16px; + font-weight: 720; + letter-spacing: -0.025em; + text-overflow: ellipsis; + white-space: nowrap; +} + +.surface-switcher { + display: flex; + gap: 3px; + padding: 3px; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--paper-soft); +} + +.surface-switcher button { + height: 27px; + padding: 0 10px; + border-radius: 7px; + color: var(--muted); + font-size: 12px; + font-weight: 650; +} + +.surface-switcher button.active { + background: #fff; + color: var(--ink); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08); +} + +.toolbar-actions { + min-width: 1px; +} + +.toolbar-actions button, +.module-card button { + height: 29px; + padding: 0 10px; + border: 1px solid var(--line); + border-radius: 8px; + background: #fff; + color: var(--ink-soft); + font-size: 12px; + font-weight: 650; +} + +.session-canvas, +.module-canvas { + min-width: 0; + min-height: 0; + overflow: auto; + padding: 22px 34px 34px; + background: + linear-gradient(180deg, rgba(250, 250, 249, 0.86), rgba(255, 255, 255, 0) 160px), + var(--paper); +} + +.notice { + display: grid; + gap: 4px; + max-width: 920px; + margin: 0 auto 16px; + padding: 11px 13px; + border: 1px solid #f1b8b4; + border-radius: 12px; + background: #fff8f7; + color: var(--red); +} + +.run-strip { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + max-width: 1020px; + gap: 14px; + margin: 0 auto 18px; + padding: 10px; + border: 1px solid var(--line); + border-radius: 15px; + background: rgba(255, 255, 255, 0.82); +} + +.run-identity { + display: grid; + min-width: 0; + gap: 2px; + text-align: left; +} + +.run-identity.is-selected, +.chat-row.is-selected .message-bubble, +.chat-row.is-selected .assistant-message, +.fold-row.is-selected summary, +.tree-row.is-selected, +.span-row.is-selected, +.context-card.is-selected { + outline: 2px solid rgba(31, 111, 235, 0.22); + outline-offset: 2px; + background: rgba(31, 111, 235, 0.06); +} + +.run-identity strong { + font-weight: 720; +} + +.run-identity span { + overflow: hidden; + color: var(--muted); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.run-metrics { + display: flex; + gap: 6px; +} + +.run-metrics span { + display: grid; + min-width: 52px; + padding: 6px 8px; + border-radius: 10px; + background: var(--paper-soft); + text-align: center; +} + +.run-metrics strong { + font-size: 14px; +} + +.run-metrics small { + color: var(--muted); + font-size: 10px; +} + +.surface { + max-width: 980px; + margin: 0 auto; +} + +.chat-surface { + display: grid; + gap: 12px; + max-width: 900px; + padding: 26px 0 22px; +} + +.chat-row { + width: 100%; + text-align: left; +} + +.user-row { + display: flex; + position: relative; + justify-content: flex-end; + gap: 8px; +} + +.message-bubble { + display: block; + max-width: min(680px, 76%); + padding: 10px 13px; + border: 1px solid var(--line); + border-radius: 17px; + background: #f2f2f0; + color: var(--ink); +} + +.assistant-row { + position: relative; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + gap: 10px; + padding: 2px 4px; +} + +.assistant-message { + display: block; + max-width: 760px; + color: var(--ink); + font-size: 14px; +} + +.message-details { + align-self: start; + height: 26px; + padding: 0 8px; + border: 1px solid var(--line); + border-radius: 7px; + background: rgba(255, 255, 255, 0.78); + color: var(--muted); + font-size: 11px; + font-weight: 650; + opacity: 0; + transition: opacity 120ms var(--ease-out), background 120ms var(--ease-out); +} + +.chat-row:hover .message-details, +.message-details:focus-visible, +.chat-row.is-selected .message-details { + opacity: 1; +} + +.message-details:hover, +.fold-inspect:hover { + background: #fff; +} + +.message-bubble p, +.assistant-message p { + margin: 0 0 9px; +} + +.message-bubble p:last-child, +.assistant-message p:last-child { + margin-bottom: 0; +} + +.fold-row { + margin: 0 0 2px; + border-radius: 11px; +} + +.fold-row summary { + display: grid; + grid-template-columns: 92px minmax(0, 1fr) 18px; + align-items: center; + gap: 9px; + min-height: 30px; + padding: 5px 8px; + border: 1px solid transparent; + border-radius: 11px; + color: var(--muted); + list-style: none; + cursor: pointer; +} + +.fold-row summary::after { + content: "⌄"; + color: var(--faint); + text-align: right; +} + +.fold-row[open] summary::after { + content: "⌃"; +} + +.fold-row summary::-webkit-details-marker { + display: none; +} + +.fold-row summary:hover, +.fold-body:hover { + background: var(--paper-soft); +} + +.fold-kind { + color: var(--ink-soft); + font-size: 12px; + font-weight: 680; +} + +.fold-preview { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; +} + +.fold-body { + display: grid; + width: calc(100% - 12px); + gap: 8px; + margin: 0 0 6px 8px; + padding: 10px; + border-left: 2px solid var(--line); + text-align: left; +} + +.fold-inspect { + justify-self: start; + height: 26px; + padding: 0 8px; + border: 1px solid var(--line); + border-radius: 7px; + background: #fff; + color: var(--muted); + font-size: 11px; + font-weight: 650; +} + +.activity-section { + display: grid; + gap: 5px; +} + +.activity-section strong { + color: var(--muted); + font-size: 11px; + font-weight: 720; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +pre { + margin: 0; + overflow: auto; + font-family: "SF Mono", ui-monospace, Menlo, Monaco, Consolas, monospace; + font-size: 11px; + line-height: 1.55; + white-space: pre-wrap; +} + +.surface-intro { + display: grid; + gap: 3px; + margin: 8px 0 14px; + color: var(--muted); +} + +.surface-intro strong { + color: var(--ink); + font-size: 15px; + letter-spacing: -0.02em; +} + +.tree-view, +.waterfall, +.context-grid { + display: grid; + gap: 4px; +} + +.tree-item { + border-radius: 10px; +} + +.tree-item summary { + cursor: pointer; + list-style: none; +} + +.tree-item summary::-webkit-details-marker { + display: none; +} + +.tree-row { + display: grid; + grid-template-columns: 12px 76px minmax(0, 1fr) 64px; + align-items: center; + gap: 8px; + width: 100%; + min-height: 38px; + padding: 6px 8px; + border-radius: 10px; + text-align: left; +} + +.tree-item .tree-row { + grid-template-columns: 12px 76px minmax(0, 1fr) 64px 14px; +} + +.tree-item .tree-row::after { + content: "⌄"; + color: var(--faint); +} + +.tree-item[open] .tree-row::after { + content: "⌃"; +} + +.tree-row:hover, +.span-row:hover, +.context-card:hover { + background: var(--paper-soft); +} + +.tree-row.depth-1 { + padding-left: 28px; +} + +.tree-row.depth-2 { + padding-left: 50px; +} + +.tree-row.depth-3 { + padding-left: 72px; +} + +.tree-rail { + width: 7px; + height: 7px; + border-radius: 999px; + background: var(--line-strong); +} + +.tree-row.blue .tree-rail, +.span-row.blue .span-track span { + background: var(--accent); +} + +.tree-row.green .tree-rail, +.span-row.green .span-track span { + background: var(--green); +} + +.tree-row.amber .tree-rail, +.span-row.amber .span-track span { + background: var(--amber); +} + +.tree-row.red .tree-rail { + background: var(--red); +} + +.tree-kind { + color: var(--muted); + font-size: 11px; + font-weight: 720; + text-transform: uppercase; +} + +.tree-copy { + display: grid; + min-width: 0; +} + +.tree-copy strong, +.tree-copy small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tree-copy strong { + font-weight: 650; +} + +.tree-copy small, +.tree-row em { + color: var(--muted); + font-size: 12px; + font-style: normal; +} + +.traj-body { + display: grid; + gap: 8px; + margin: 2px 0 8px 72px; + padding: 8px 0 8px 12px; + border-left: 2px solid var(--line); +} + +.traj-card { + overflow: hidden; + border: 1px solid var(--line); + border-radius: 12px; + background: #fff; +} + +.traj-card header { + display: flex; + justify-content: space-between; + gap: 10px; + padding: 8px 10px; + background: #f4f4f2; +} + +.traj-card header strong { + font-size: 12px; +} + +.traj-card header span { + overflow: hidden; + color: var(--muted); + font-family: "SF Mono", ui-monospace, Menlo, Monaco, Consolas, monospace; + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.traj-card pre, +.metadata-line pre { + max-height: 320px; + padding: 10px 12px; + background: #fbfbfa; +} + +.metadata-line summary { + color: var(--muted); + font-size: 12px; + cursor: pointer; +} + +.span-row { + display: grid; + grid-template-columns: 172px minmax(0, 1fr) 72px; + align-items: center; + gap: 12px; + width: 100%; + min-height: 42px; + padding: 7px 9px; + border-radius: 10px; + text-align: left; +} + +.wf-summary { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 8px; + margin-bottom: 12px; +} + +.wf-stat { + display: grid; + gap: 3px; + min-width: 0; + padding: 9px 10px; + border: 1px solid var(--line); + border-radius: 12px; + background: rgba(255, 255, 255, 0.78); +} + +.wf-stat span { + color: var(--muted); + font-size: 11px; +} + +.wf-stat strong { + overflow: hidden; + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.span-title { + display: grid; + min-width: 0; +} + +.span-title strong, +.span-title small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.span-title small, +.span-row em { + color: var(--muted); + font-size: 11px; + font-style: normal; +} + +.span-track { + position: relative; + height: 18px; + overflow: hidden; + border-radius: 999px; + background: #ececea; +} + +.span-track span { + position: absolute; + top: 3px; + height: 12px; + border-radius: 999px; + background: #51514d; +} + +.context-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.context-grid.single { + grid-template-columns: 1fr; +} + +.context-card, +.module-card, +.compare-column { + border: 1px solid var(--line); + border-radius: 14px; + background: rgba(255, 255, 255, 0.78); +} + +.context-card { + display: grid; + min-height: 142px; + gap: 10px; + padding: 14px; + text-align: left; +} + +.context-card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.context-card-head strong { + font-size: 14px; +} + +.context-card-head em { + color: var(--muted); + font-size: 11px; + font-style: normal; +} + +.context-preview { + display: -webkit-box; + overflow: hidden; + color: var(--ink-soft); + font-family: "SF Mono", ui-monospace, Menlo, Monaco, Consolas, monospace; + font-size: 11px; + line-height: 1.5; + -webkit-box-orient: vertical; + -webkit-line-clamp: 5; +} + +.composer { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: end; + gap: 10px; + width: min(920px, calc(100% - 68px)); + margin: 0 auto 24px; + padding: 12px; + border: 1px solid var(--line); + border-radius: 18px; + background: rgba(255, 255, 255, 0.94); + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.08); +} + +.composer textarea { + min-height: 42px; + max-height: 128px; + resize: none; + overflow: auto; + border: 0; + background: transparent; + outline: none; +} + +.composer-meta { + display: grid; + align-items: center; + justify-items: end; +} + +.composer-meta button { + display: grid; + width: 36px; + height: 36px; + place-items: center; + padding: 0; + border-radius: 999px; + background: var(--ink); + color: #fff; + font-size: 18px; + font-weight: 690; + transition: transform 120ms var(--ease-out), background 120ms var(--ease-out); +} + +.primary-action:active, +.composer-meta button:active, +.fold-inspect:active, +.module-card button:active, +.inspector-head button:active, +.feedback-form button:active { + transform: scale(0.97); +} + +.status-bar { + display: flex; + justify-content: space-between; + padding: 8px 14px; + border-top: 1px solid var(--line); + color: var(--muted); + font-size: 11px; +} + +.inspector { + display: grid; + min-width: 0; + min-height: 0; + grid-template-rows: auto auto minmax(0, 1fr); + border-left: 1px solid var(--line); + background: #fbfbfa; +} + +.inspector-head { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 16px 16px 12px; + border-bottom: 1px solid var(--line); +} + +.inspector-head div { + display: grid; + min-width: 0; + gap: 2px; +} + +.inspector-head span { + color: var(--muted); + font-size: 11px; + font-weight: 720; + text-transform: uppercase; +} + +.inspector-head strong { + overflow: hidden; + font-size: 14px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.inspector-head small { + overflow: hidden; + color: var(--muted); + text-overflow: ellipsis; + white-space: nowrap; +} + +.inspector-head button { + align-self: start; + height: 28px; + padding: 0 9px; + border: 1px solid var(--line); + border-radius: 8px; + background: #fff; + color: var(--ink-soft); + font-size: 12px; + font-weight: 650; +} + +.inspector-tabs { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 3px; + padding: 8px; + border-bottom: 1px solid var(--line); +} + +.inspector-tabs button { + min-width: 0; + height: 28px; + border-radius: 8px; + color: var(--muted); + font-size: 11px; + font-weight: 680; +} + +.inspector-tabs button.active { + background: #fff; + color: var(--ink); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08); +} + +.inspector-body { + min-height: 0; + overflow: auto; + padding: 12px; +} + +.inspector-body pre { + padding: 12px; + border: 1px solid var(--line); + border-radius: 12px; + background: #fff; +} + +.feedback-list { + display: grid; + gap: 8px; +} + +.feedback-entry { + padding: 10px; + border: 1px solid var(--line); + border-radius: 12px; + background: #fff; +} + +.feedback-entry header { + display: flex; + justify-content: space-between; + color: var(--muted); + font-size: 11px; +} + +.feedback-entry header strong { + color: var(--ink); +} + +.feedback-entry p { + margin: 8px 0 0; +} + +.feedback-form { + display: grid; + gap: 8px; + margin-top: 12px; +} + +.feedback-form input, +.feedback-form textarea { + width: 100%; + padding: 9px; + border: 1px solid var(--line); + border-radius: 10px; + background: #fff; + outline: none; +} + +.feedback-form button { + justify-self: end; + height: 30px; + padding: 0 12px; + border-radius: 9px; + background: var(--ink); + color: #fff; + font-weight: 680; +} + +.empty-state, +.empty-thread { + max-width: 560px; + margin: 120px auto; + color: var(--muted); + text-align: center; +} + +.empty-thread.compact { + margin: 60px auto; +} + +.empty-state h2, +.empty-thread h2 { + margin: 0 0 8px; + color: var(--ink); + font-size: 22px; + letter-spacing: -0.035em; +} + +.empty-state button { + margin-top: 12px; + height: 34px; + padding: 0 14px; + border-radius: 10px; + background: var(--ink); + color: #fff; + font-weight: 680; +} + +.module-hero { + max-width: 760px; + margin: 8px auto 18px; +} + +.module-hero span { + color: var(--muted); + font-size: 11px; + font-weight: 760; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.module-hero h2 { + margin: 6px 0 6px; + font-size: 28px; + letter-spacing: -0.055em; +} + +.module-hero p { + margin: 0; + color: var(--ink-soft); + font-size: 14px; +} + +.develop-shell { + display: grid; + max-width: 1120px; + min-height: calc(100vh - 150px); + margin: 0 auto; + padding: 0 0 28px; +} + +.develop-browser { + display: grid; + grid-template-columns: 312px minmax(0, 1fr); + min-height: calc(100vh - 178px); + overflow: hidden; + border: 1px solid var(--line); + border-radius: 14px; + background: rgba(255, 255, 255, 0.76); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.025); +} + +.develop-artifact-rail { + display: grid; + align-content: start; + gap: 14px; + overflow: auto; + padding: 12px; + border-right: 1px solid var(--line); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.42), rgba(255, 255, 255, 0)), + var(--paper-soft); +} + +.dev-artifact-group { + display: grid; + gap: 6px; +} + +.dev-artifact-group h3 { + margin: 0; + padding: 0 7px; + color: var(--muted); + font-size: 11px; + font-weight: 720; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.dev-artifact-group > div { + display: grid; + gap: 2px; +} + +.dev-artifact-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + min-height: 46px; + padding: 7px 8px; + border-radius: 10px; + text-align: left; + transition: + background 120ms var(--ease-out), + transform 120ms var(--ease-out); +} + +.dev-artifact-row:hover { + background: rgba(255, 255, 255, 0.62); +} + +.dev-artifact-row:active { + transform: scale(0.99); +} + +.dev-artifact-row.selected { + background: #fff; + box-shadow: + inset 0 0 0 1px rgba(0, 0, 0, 0.04), + 0 1px 2px rgba(0, 0, 0, 0.05); +} + +.dev-artifact-row div { + display: grid; + min-width: 0; + gap: 1px; +} + +.dev-artifact-row strong { + overflow: hidden; + font-size: 13px; + font-weight: 680; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dev-artifact-row span { + overflow: hidden; + color: var(--ink-soft); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dev-artifact-row em, +.dev-detail-head em { + align-self: center; + padding: 2px 7px; + border-radius: 999px; + background: var(--paper-soft); + color: var(--muted); + font-size: 11px; + font-style: normal; + font-weight: 650; +} + +.dev-artifact-row.tool em, +.dev-detail-card.tool .dev-detail-head em { + background: rgba(35, 132, 84, 0.1); + color: var(--green); +} + +.develop-artifact-detail { + min-width: 0; + overflow: auto; + padding: 18px; +} + +.dev-detail-card { + display: grid; + gap: 16px; + max-width: 820px; +} + +.dev-section, +.dev-loop-card { + border: 1px solid var(--line); + border-radius: 14px; + background: rgba(255, 255, 255, 0.82); + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.025); +} + +.dev-detail-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 2px 0 0; +} + +.dev-detail-head span { + color: var(--muted); + font-size: 11px; + font-weight: 760; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.dev-detail-head h3 { + margin: 3px 0 4px; + font-size: 22px; + letter-spacing: -0.045em; +} + +.dev-detail-head p { + margin: 0; + color: var(--ink-soft); + font-size: 13px; +} + +.dev-detail-grid { + display: grid; + grid-template-columns: 1fr; + gap: 0; + border-top: 1px solid var(--line); + border-bottom: 1px solid var(--line); +} + +.dev-fact { + display: grid; + grid-template-columns: 112px minmax(0, 1fr); + gap: 12px; + min-width: 0; + padding: 7px 0; +} + +.dev-fact + .dev-fact { + border-top: 1px solid rgba(227, 226, 222, 0.7); +} + +.dev-fact span { + color: var(--muted); + font-size: 11px; +} + +.dev-fact strong { + overflow: hidden; + font-size: 12px; + font-weight: 650; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dev-registry-snapshot { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.dev-registry-snapshot > div { + min-width: 0; +} + +.dev-registry-snapshot h4 { + margin: 0 0 8px; + font-size: 12px; + font-weight: 720; +} + +.dev-registry-snapshot ul { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 0; + padding: 0; + list-style: none; +} + +.dev-registry-snapshot li { + display: inline-flex; + align-items: center; + gap: 6px; + max-width: 100%; + padding: 4px 7px; + border: 1px solid var(--line); + border-radius: 999px; + background: rgba(255, 255, 255, 0.76); + font-size: 11px; +} + +.dev-registry-snapshot li strong { + overflow: hidden; + max-width: 180px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dev-registry-snapshot li span, +.dev-registry-snapshot p { + margin: 0; + color: var(--muted); + font-size: 11px; +} + +.dev-section-head p { + color: var(--muted); + font-size: 11px; +} + +.dev-section { + overflow: hidden; +} + +.dev-section-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; + padding: 13px 14px 11px; + border-bottom: 1px solid var(--line); +} + +.dev-section-head h3 { + margin: 0 0 3px; + font-size: 13px; + font-weight: 700; + letter-spacing: -0.01em; +} + +.dev-section-head p { + margin: 0; + line-height: 1.45; +} + +.dev-code { + max-height: 420px; + margin: 0; + overflow: auto; + padding: 13px 14px 14px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(255, 255, 255, 0.48)), + #fbfbfa; + color: #242422; + font-family: "SF Mono", ui-monospace, Menlo, Monaco, Consolas, monospace; + font-size: 11px; + line-height: 1.55; + white-space: pre-wrap; + word-break: break-word; +} + +.dev-loop-card { + padding: 14px 16px; +} + +.dev-loop-card strong { + display: block; + margin-bottom: 8px; +} + +.dev-loop-card ol { + margin: 0; + padding-left: 18px; + color: var(--ink-soft); +} + +.dev-loop-card li + li { + margin-top: 4px; +} + +.module-card { + padding: 14px; +} + +.module-card h3 { + margin: 0 0 8px; + font-size: 14px; +} + +.module-card p { + margin: 0 0 12px; + color: var(--muted); +} + +dl { + display: grid; + gap: 6px; + margin: 0; +} + +dl div { + display: flex; + justify-content: space-between; + gap: 10px; +} + +dt { + color: var(--muted); +} + +dd { + margin: 0; + color: var(--ink); + font-weight: 640; +} + +.dev-status { + margin: 0; +} + +.empty-panel { + color: var(--muted); +} + +@media (max-width: 1180px) { + .harness-shell, + .harness-shell.inspector-open { + grid-template-columns: 236px minmax(0, 1fr); + } + + .inspector { + position: absolute; + top: 58px; + right: 0; + bottom: 0; + width: 360px; + box-shadow: var(--shadow); + } + + .run-strip, + .develop-shell, + .develop-browser { + grid-template-columns: 1fr; + } + + .develop-artifact-rail { + max-height: 260px; + border-right: 0; + border-bottom: 1px solid var(--line); + } + + .dev-detail-grid { + grid-template-columns: 1fr; + } + + .context-grid { + grid-template-columns: 1fr; + } +} diff --git a/packages/ui/desktop/tests/acp-subprocess.spec.ts b/packages/ui/desktop/tests/acp-subprocess.spec.ts new file mode 100644 index 0000000000..76ee191bd4 --- /dev/null +++ b/packages/ui/desktop/tests/acp-subprocess.spec.ts @@ -0,0 +1,79 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Readable, Writable } from 'node:stream' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + ClientSideConnection, + PROTOCOL_VERSION, + ndJsonStream, + type Client, + type SessionNotification, + type Stream, +} from '@agentclientprotocol/sdk' + +describe('desktop ACP subprocess bridge', () => { + let storageDir: string + let child: ChildProcessWithoutNullStreams | undefined + + beforeEach(async () => { + storageDir = await mkdtemp(join(tmpdir(), 'dsh-desktop-acp-')) + }) + + afterEach(async () => { + if (child !== undefined) { + child.stdin.end() + child.kill('SIGTERM') + child = undefined + } + await rm(storageDir, { recursive: true, force: true }) + }) + + it('initializes the real ACP runtime and creates a session without a model call', async () => { + child = spawn('node', [ + '--import', + 'tsx', + 'packages/examples/acp-demo/src/bin.ts', + '--config', + 'examples/acp-agent/cordis.yml', + ], { + cwd: process.cwd(), + env: { + ...process.env, + DSH_SNAPSHOT_SESSIONS_ROOT: storageDir, + }, + stdio: ['pipe', 'pipe', 'pipe'], + }) + + let stderr = '' + child.stderr.setEncoding('utf8') + child.stderr.on('data', chunk => { stderr += String(chunk) }) + + const stream: Stream = ndJsonStream( + Writable.toWeb(child.stdin) as WritableStream, + Readable.toWeb(child.stdout) as ReadableStream, + ) + const updates: SessionNotification[] = [] + const client = new ClientSideConnection((): Client => ({ + sessionUpdate(params) { + updates.push(params) + return Promise.resolve() + }, + requestPermission() { + return Promise.resolve({ outcome: { outcome: 'cancelled' } }) + }, + unstable_createElicitation() { + return Promise.resolve({ action: 'cancel' }) + }, + }), stream) + + const init = await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + expect(init.agentInfo.name).toBe('deepseek-harness-acp') + + const session = await client.newSession({ cwd: process.cwd(), mcpServers: [] }) + expect(session.sessionId).toBeTruthy() + expect(updates).toHaveLength(0) + expect(stderr).not.toContain('Error:') + }, 20_000) +}) diff --git a/packages/ui/desktop/tests/index.spec.ts b/packages/ui/desktop/tests/index.spec.ts new file mode 100644 index 0000000000..eb336185b8 --- /dev/null +++ b/packages/ui/desktop/tests/index.spec.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest' +import { + closedInspectorState, + createInspectorTargetId, + DEFAULT_FEEDBACK_AUTHOR, + DESKTOP_SURFACES, + fullDetailBelongsInInspector, + INSPECTOR_TABS, + openInspectorState, + opensInspector, + ownsComposer, + SURFACE_DEFINITIONS, + SURFACE_POLICIES, + type DesktopSurface, + type InspectorTarget, +} from '../src/index.ts' + +const target: InspectorTarget = { + id: 'session:one:event:1', + kind: 'message', + title: 'user/message', +} + +describe('desktop surface policies', () => { + it('routes trace-analysis detail through the inspector, but not Develop', () => { + for (const surface of DESKTOP_SURFACES.filter(surface => surface !== 'dev')) { + expect(opensInspector(surface, target)).toBe(true) + expect(fullDetailBelongsInInspector(surface)).toBe(true) + } + expect(opensInspector('dev', target)).toBe(false) + expect(fullDetailBelongsInInspector('dev')).toBe(false) + }) + + it('keeps empty selections from opening the inspector', () => { + expect(opensInspector('trajectory', undefined)).toBe(false) + }) + + it('keeps trajectory and context as summary-first surfaces', () => { + expect(SURFACE_POLICIES.trajectory).toMatchObject({ + summaryFirst: true, + inlinePreview: true, + fullDetailInInspector: true, + }) + expect(SURFACE_POLICIES.context).toMatchObject({ + summaryFirst: true, + inlinePreview: true, + fullDetailInInspector: true, + }) + }) + + it('keeps the composer scoped to chat only', () => { + for (const surface of DESKTOP_SURFACES) { + expect(ownsComposer(surface)).toBe(surface === 'chat') + } + }) + + it('keeps surface definitions aligned with the exported surface list', () => { + expect(Object.keys(SURFACE_DEFINITIONS).sort()).toEqual([...DESKTOP_SURFACES].sort()) + expect(SURFACE_DEFINITIONS.trajectory.purpose).toBe('navigate') + expect(SURFACE_DEFINITIONS.context.purpose).toBe('request-anatomy') + expect(SURFACE_DEFINITIONS.compare.primaryQuestion).toContain('baseline') + expect(SURFACE_DEFINITIONS.dev.primaryQuestion).toContain('artifacts compose') + }) +}) + +describe('desktop inspector contracts', () => { + it('uses stable target ids across surfaces', () => { + expect(createInspectorTargetId({ + sessionId: 's1', + runId: 'r1', + kind: 'tool-call', + eventSeq: 42, + })).toBe('session:s1:run:r1:kind:tool-call:seq:42') + }) + + it('opens output by default for produced data and metadata for structural targets', () => { + expect(openInspectorState({ + id: 'session:s1:kind:tool-result:seq:9', + kind: 'tool-result', + title: 'tool/result', + }).activeTab).toBe('output') + + expect(openInspectorState({ + id: 'session:s1:kind:step:seq:3', + kind: 'step', + title: 'step 1', + }).activeTab).toBe('metadata') + }) + + it('keeps inspector tabs ordered with feedback last', () => { + expect(INSPECTOR_TABS).toEqual(['input', 'output', 'metadata', 'feedback']) + expect(openInspectorState(target).tabs.map((tab) => tab.tab)).toEqual(INSPECTOR_TABS) + expect(closedInspectorState()).toEqual({ + open: false, + activeTab: 'input', + tabs: [], + }) + }) + + it('uses shentuni as the default feedback author', () => { + expect(DEFAULT_FEEDBACK_AUTHOR).toBe('shentuni') + }) +}) diff --git a/packages/ui/desktop/tsconfig.json b/packages/ui/desktop/tsconfig.json new file mode 100644 index 0000000000..0b174e83f6 --- /dev/null +++ b/packages/ui/desktop/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ] +}