Files
deepseek-harness/examples/desktop/src/renderer/panels-c-controller.js
T
ZiyaZhang e8f5c0b51b feat(desktop): DSH Electron desktop shell — harness internals visualized
Minimal Electron shell over the DSH JSON-RPC runtime — a first-look at
what a ChatGPT.app-style host on top of the DeepSeek Harness looks
like, with the harness's normally-invisible internals (trace timeline,
context surface, subagent tree, compaction, plugin registry, rubrics)
brought forward as first-class UI surfaces so plugin authors and
researchers can see what the agent is actually doing.

Runs against three keyless-to-live profiles (stdio-echo works on
master out of the box; daemon-echo / daemon-vibe-echo activate once
the daemon-demo lands; stdio-deepseek and daemon-vibe hit the real
DeepSeek API when you supply a key). HARNESS_DEV auto-resolves to the
in-repo runtime when this shell ships under examples/desktop/, so a
fresh clone launches without config; env DSH_DEV_ROOT overrides for
custom layouts, and a sibling deepseek-harness-dev/ checkout is the
original dev workflow.

Cold-clone gate (P0 fixes for first-time-clone usability):
- HARNESS_DEV: 3-candidate resolver (env → walk-up in-repo marker →
  sibling), unit-tested via mock fs so ordering is locked without
  needing either real layout on disk.
- config yml leaves rewritten at assemble time so the sibling-clone
  paths (../../deepseek-harness-dev/examples/echo-agent/…) become
  the in-repo paths (../../echo-agent/…) in the released tree —
  source yml stays usable for local dev, released tree ships a
  working shape.
- pnpm-workspace.yaml allowBuilds.electron = true (was placeholder).
- missing-key card in stdio-deepseek offers a one-click switch to
  stdio-echo (the keyless profile that works on master) rather than
  daemon-echo (blocked on the not-yet-shipped daemon-demo).
- assemble-oss-release.sh rewrites the source-side breadcrumb name
  'dsh-desktop-demo' → 'dsh-desktop' for the released package.json.

FOUC guard on the onboarding gate (41fc5df carried) keeps the
first-launch splash from flashing before the runtime probe finishes.

Test suite (1634 tests in source, 3990 in the runtime repo) covers
resolver ordering, renderer classifiers, trace timeline shape,
compaction diff rendering, rubric parity, and the missing-key
onboarding paths.
2026-07-18 12:59:34 -07:00

467 lines
18 KiB
JavaScript

// Renderer-side controller for P1 batch C: workflow/tasks/web/skill/resume.
//
// Self-contained IIFE that installs its own `window.dsh.onNotify` listener +
// mounts a bottom "Background tasks" drawer + adds a "History" group to the
// sidebar. Nothing in renderer.js is modified beyond three thin wire-lines
// (a resumeSession IPC bridge and a hook after refreshSessionList).
//
// Six event/tool surfaces get dedicated rendering:
// - tool `web_search` result → structured result-list card (URL links)
// - tool `skill` result → "skill loaded: <name>" badge + folded body
// - tool `workflow` call/result → orchestration skeleton card
// - tools `task_output/list/kill` → background-task drawer (aggregated view)
// - `session/list` history split → sidebar "History" section (resume-capable)
// - Debug mock buttons for the workflow surface (upstream workflow/* events
// don't hit the wire today; the mock proves the card path)
//
// Wire-up model: our onNotify listener runs AFTER renderer.js's own listener
// because our <script> tag sits after ./renderer.js in index.html; both
// listeners see every notification, and DOM writes we do here re-target the
// same `resBox` renderer.js populated. Any tool without an override falls back
// to renderer.js's plain text — no interference.
//
// See docs/capability-ui-coverage.md §2 (rows for web_search/skill/workflow/
// task_*), §3 for the resume gap, and panels-c.js for the pure module.
'use strict'
;(function () {
if (typeof globalThis === 'undefined' || !globalThis.__dshPanelsC) {
console.warn('[panels-c] pure module missing; controller inert')
return
}
if (!globalThis.window || !globalThis.window.dsh) {
// Loaded outside an Electron renderer (e.g. accidentally imported into a
// test). Nothing to do.
return
}
const P = globalThis.__dshPanelsC
// Track callId → toolName so tool/result can dispatch by tool name (renderer.js
// has this info on the same map, but reaching into its state module is
// brittle — keeping our own tracker matches the visibility-controller model).
/** @type {Map<string, {name: string, args: string}>} */
const callIndex = new Map()
// Background task state — accumulated across task_* tool calls on the active
// session. Cleared on session switch so history from a different session
// doesn't linger.
let taskState = { tasks: new Map() }
let activeSessionId = null
// History section state — retired. The unified Recent list in renderer.js
// owns the collapse/expand toggle now (state.sessionsExpanded there). Kept
// as a comment marker so the retirement of `renderHistorySection` below
// reads as intentional rather than accidental dead code.
// ---- helpers -------------------------------------------------------------
function toolBlockFor(callId) {
return document.querySelector(`.tool-block[data-call-id="${cssEscape(callId)}"]`)
}
function resBoxFor(callId) {
const block = toolBlockFor(callId)
return block ? block.querySelector('.result') : null
}
function cssEscape(s) {
if (typeof CSS !== 'undefined' && CSS.escape) return CSS.escape(s)
return String(s).replace(/[^a-zA-Z0-9_-]/g, (c) => '\\' + c.charCodeAt(0).toString(16) + ' ')
}
// Renderer.js exports its state module handles via window.__dshChat at the
// end of bootUi. Use whatever the current activeSession is via the DOM
// (the .msg blocks live inside #stream but session id is only on the
// sidebar's <li.active>). Fallback to the last-seen id from event traffic.
function currentActiveSessionId() {
const li = document.querySelector('.sessions li.active')
if (li && li.title) return li.title
return activeSessionId
}
function scrollTasksToBottom(el) {
if (el) el.scrollTop = el.scrollHeight
}
// ---- web_search override -------------------------------------------------
function renderWebSearchInto(resBox, view) {
resBox.textContent = ''
const card = document.createElement('div')
card.className = 'card-web-search'
const head = document.createElement('div')
head.className = 'card-web-search-head'
head.textContent = `web results (${view.results.length})`
card.appendChild(head)
if (view.results.length === 0) {
const empty = document.createElement('div')
empty.className = 'card-web-search-empty muted'
empty.textContent = 'No parseable results — see raw tool output below.'
card.appendChild(empty)
const pre = document.createElement('pre')
pre.className = 'card-web-search-raw'
pre.textContent = view.raw
card.appendChild(pre)
resBox.appendChild(card)
return
}
const list = document.createElement('ol')
list.className = 'card-web-search-list'
for (const r of view.results) {
const li = document.createElement('li')
li.className = 'card-web-search-item'
const a = document.createElement('a')
a.className = 'card-web-search-title'
// href is set to '#' — the actual open is routed through shell.openExternal
// in main via ipc:openExternal so URL scheme is centrally whitelisted.
a.href = '#'
a.textContent = r.title
a.title = r.url
a.addEventListener('click', (ev) => {
ev.preventDefault()
void window.dsh.openExternalUrl(r.url).catch((err) => {
console.warn('[panels-c] openExternal failed', err)
})
})
const url = document.createElement('div')
url.className = 'card-web-search-url muted'
url.textContent = r.url
const sn = document.createElement('div')
sn.className = 'card-web-search-snippet'
sn.textContent = r.snippet
li.append(a, url, sn)
list.appendChild(li)
}
card.appendChild(list)
resBox.appendChild(card)
}
// ---- skill override ------------------------------------------------------
function renderSkillInto(resBox, view) {
resBox.textContent = ''
const card = document.createElement('details')
card.className = 'card-skill'
card.open = false
const summary = document.createElement('summary')
summary.className = 'card-skill-summary'
const icon = document.createElement('span')
icon.className = 'card-skill-icon'
// Book glyph — inline SVG (1.6px stroke, currentColor). Replaces an
// earlier emoji glyph so all "loaded skill" badges read as flat line-art
// matching the rest of the shell's icon system.
icon.innerHTML =
'<svg viewBox="0 0 20 20" width="14" height="14" aria-hidden="true">'
+ '<path fill="none" stroke="currentColor" stroke-width="1.6" '
+ 'stroke-linecap="round" stroke-linejoin="round" '
+ 'd="M4 5.5A1.5 1.5 0 0 1 5.5 4h3A1.5 1.5 0 0 1 10 5.5v10A1.5 1.5 0 0 0 8.5 14h-3A1.5 1.5 0 0 1 4 15.5zM10 5.5A1.5 1.5 0 0 1 11.5 4h3A1.5 1.5 0 0 1 16 5.5v10A1.5 1.5 0 0 0 14.5 14h-3A1.5 1.5 0 0 1 10 15.5z"/>'
+ '</svg>'
const label = document.createElement('span')
label.className = 'card-skill-label'
label.textContent = view.name ? `skill loaded: ${view.name}` : 'skill loaded'
const size = document.createElement('span')
size.className = 'card-skill-size muted'
size.textContent = view.body ? `${view.body.length} chars` : ''
summary.append(icon, label, size)
card.appendChild(summary)
if (view.body) {
const body = document.createElement('pre')
body.className = 'card-skill-body'
body.textContent = view.body
card.appendChild(body)
}
resBox.appendChild(card)
}
// ---- workflow override ---------------------------------------------------
function renderWorkflowInto(resBox, view, { pending = false } = {}) {
resBox.textContent = ''
const card = document.createElement('div')
card.className = 'card-workflow'
const head = document.createElement('div')
head.className = 'card-workflow-head'
const title = document.createElement('span')
title.className = 'card-workflow-title'
title.textContent = view.name ? `workflow: ${view.name}` : 'workflow'
const chip = document.createElement('span')
chip.className = 'card-workflow-chip muted'
// The workflow tool's Cordis-side events don't cross the JSON-RPC wire
// today (see coverage doc §1 "workflow/* Cordis events"). We surface this
// limitation on the card rather than silently faking progress.
chip.textContent = pending ? 'running…' : 'events not wired'
chip.title = 'workflow/* Cordis events aren\'t on the JSON-RPC wire today'
head.append(title, chip)
card.appendChild(head)
if (view.phases.length > 0) {
const list = document.createElement('ol')
list.className = 'card-workflow-phases'
for (const p of view.phases) {
const li = document.createElement('li')
li.className = `card-workflow-phase status-${p.status}`
const dot = document.createElement('span')
dot.className = 'card-workflow-phase-dot'
dot.textContent = p.status === 'done' ? '✓'
: p.status === 'failed' ? '✗'
: p.status === 'running' ? '⋯' : '·'
const lbl = document.createElement('span')
lbl.className = 'card-workflow-phase-label'
lbl.textContent = p.label
li.append(dot, lbl)
list.appendChild(li)
}
card.appendChild(list)
} else {
const empty = document.createElement('div')
empty.className = 'card-workflow-empty muted'
empty.textContent = 'No declared phases.'
card.appendChild(empty)
}
resBox.appendChild(card)
}
// ---- background tasks drawer --------------------------------------------
function ensureTasksDrawer() {
let drawer = document.getElementById('panels-c-tasks-drawer')
if (drawer) return drawer
// Mount into the chat pane so its lifecycle follows the chat surface.
// Positioned via CSS as a small dock above the composer footer.
const chatPane = document.querySelector('.pane[data-pane="chat"]')
if (!chatPane) return null
drawer = document.createElement('div')
drawer.id = 'panels-c-tasks-drawer'
drawer.className = 'panels-c-tasks-drawer'
drawer.hidden = true
const head = document.createElement('div')
head.className = 'panels-c-tasks-head'
const title = document.createElement('span')
title.className = 'panels-c-tasks-title'
title.textContent = 'Background tasks'
const count = document.createElement('span')
count.className = 'panels-c-tasks-count muted'
count.textContent = '0'
const toggle = document.createElement('button')
toggle.type = 'button'
toggle.className = 'panels-c-tasks-toggle ghost'
toggle.textContent = 'Hide'
toggle.addEventListener('click', () => {
const body = drawer.querySelector('.panels-c-tasks-body')
const collapsed = body.classList.toggle('collapsed')
toggle.textContent = collapsed ? 'Show' : 'Hide'
})
head.append(title, count, toggle)
const body = document.createElement('div')
body.className = 'panels-c-tasks-body'
drawer.append(head, body)
// Insert before the composer footer.
const composer = chatPane.querySelector('.composer')
if (composer) chatPane.insertBefore(drawer, composer)
else chatPane.appendChild(drawer)
return drawer
}
function renderTasksDrawer() {
const drawer = ensureTasksDrawer()
if (!drawer) return
const body = drawer.querySelector('.panels-c-tasks-body')
const count = drawer.querySelector('.panels-c-tasks-count')
if (!body || !count) return
const entries = Array.from(taskState.tasks.values())
if (entries.length === 0) {
drawer.hidden = true
body.textContent = ''
count.textContent = '0'
return
}
drawer.hidden = false
// Show most-recent first — lastUpdate is an ISO string, string compare
// works for the ISO format the reducer emits.
entries.sort((a, b) => String(b.lastUpdate).localeCompare(String(a.lastUpdate)))
count.textContent = String(entries.length)
body.textContent = ''
for (const t of entries) {
const row = document.createElement('div')
row.className = `panels-c-task-row status-${t.status}`
const status = document.createElement('span')
status.className = 'panels-c-task-status'
status.textContent = statusGlyph(t.status)
status.title = t.status
const id = document.createElement('span')
id.className = 'panels-c-task-id'
id.textContent = t.id
const name = document.createElement('span')
name.className = 'panels-c-task-name'
name.textContent = t.name || ''
const summary = document.createElement('span')
summary.className = 'panels-c-task-summary muted'
summary.textContent = t.summary || ''
row.append(status, id, name, summary)
body.appendChild(row)
}
scrollTasksToBottom(body)
}
function statusGlyph(s) {
if (s === 'done') return '✓'
if (s === 'failed') return '✗'
if (s === 'killed') return '⊘'
if (s === 'running') return '⋯'
return '·'
}
// ---- sidebar history section (retired) ----------------------------------
//
// The sidebar used to render a separate "History" list here for persisted-
// but-not-live sessions, sitting below the "Sessions" list. That split
// leaked a runtime impl detail (live vs. persisted) as a group header.
// renderer.js's `renderSessionList` now emits a single unified "Recent"
// list that includes both kinds and marks resume-vs-live per row (see
// panels-c.js `mergeRecentSessions`). This function is kept as a no-op
// shim so the existing event-dispatch call sites don't need to grow
// conditional guards — deleting it would ripple across the notify path.
function renderHistorySection() {
// A previous section may still be in the DOM if the controller ran under
// an older renderer.js — remove it once so the two lists never coexist.
const stale = document.getElementById('panels-c-history')
if (stale) stale.remove()
}
// ---- event dispatch ------------------------------------------------------
function handleToolCall(sessionId, data) {
if (!data || typeof data.callId !== 'string') return
callIndex.set(data.callId, { name: String(data.name || ''), args: String(data.arguments || '') })
if (sessionId !== currentActiveSessionId()) return
// Workflow gets a pre-populated skeleton at call time — the result-time
// pass upgrades it if we can parse phases from the output.
if (data.name === 'workflow') {
const resBox = resBoxFor(data.callId)
if (resBox) {
const view = P.foldWorkflowCall({ args: data.arguments })
renderWorkflowInto(resBox, view, { pending: true })
}
}
// Task-family tools also inform the drawer state on call.
if (data.name === 'task_output' || data.name === 'task_list' || data.name === 'task_kill') {
taskState = P.updateBackgroundTasks(taskState, {
toolName: data.name, callId: data.callId,
args: data.arguments, phase: 'call',
})
renderTasksDrawer()
}
}
function handleToolResult(sessionId, data) {
if (!data || typeof data.callId !== 'string') return
const entry = callIndex.get(data.callId)
if (!entry) return
const { name, args } = entry
const isError = !!data.isError
const content = data.content
// Update background task state first so the drawer reflects even
// errored/isError=true results.
if (name === 'task_output' || name === 'task_list' || name === 'task_kill') {
taskState = P.updateBackgroundTasks(taskState, {
toolName: name, callId: data.callId,
args, content, isError, phase: 'result',
})
renderTasksDrawer()
}
if (sessionId !== currentActiveSessionId()) return
// Skip the resBox override when renderer.js already routed to a widget or
// known card (diff/terminal) — those already have first-class rendering
// and we should not stomp them. Detect by checking whether the resBox
// currently hosts a first-class card node.
const resBox = resBoxFor(data.callId)
if (!resBox) return
const firstChild = resBox.firstElementChild
if (firstChild) {
const cls = firstChild.className || ''
if (/^(?:widget|card-terminal|card-diff)\b/.test(cls)) return
}
if (name === 'web_search' && !isError) {
renderWebSearchInto(resBox, P.foldWebSearchResults(content))
return
}
if (name === 'skill' && !isError) {
renderSkillInto(resBox, P.foldSkillLoad({ args, content }))
return
}
if (name === 'workflow') {
// Parse result body for phase updates; if the tool didn't ship any
// structured phases we keep the call-time skeleton but flip the chip.
const view = P.foldWorkflowCall({ args })
renderWorkflowInto(resBox, view, { pending: false })
return
}
}
function handleSessionListUpdate() {
// Called after renderer.js refreshes state.entries. Also self-called on
// subagent.started/finished since those trigger a refresh downstream.
renderHistorySection()
}
function handleSessionSwitch() {
// Poll for the active session on every render — cheap; also clears task
// state so a different session's drawer doesn't leak in.
const nextId = currentActiveSessionId()
if (nextId && nextId !== activeSessionId) {
activeSessionId = nextId
taskState = { tasks: new Map() }
renderTasksDrawer()
}
}
// ---- listener install ----------------------------------------------------
window.dsh.onNotify(({ method, params }) => {
if (!params) return
if (method === 'session.event') {
const { sessionId, event } = params
if (!event) return
handleSessionSwitch()
if (event.type === 'tool/call') handleToolCall(sessionId, event.data || event)
else if (event.type === 'tool/result') handleToolResult(sessionId, event.data || event)
} else if (method === 'subagent.started' || method === 'subagent.finished' || method === 'session.finished') {
// These triggers a session/list refresh downstream; the history section
// will pick up the change on the next tick after renderer.js completes.
queueMicrotask(() => {
handleSessionSwitch()
renderHistorySection()
})
}
})
// Kick a first render after boot so the sidebar History section appears
// once the initial session/list poll arrives. renderer.js polls once at
// ~500ms boot delay, so wait a beat longer.
setTimeout(() => {
handleSessionSwitch()
renderHistorySection()
}, 800)
})()