diff --git a/examples/desktop/docs/qa-chat-triple/01-list-view-colored-edges.png b/examples/desktop/docs/qa-chat-triple/01-list-view-colored-edges.png new file mode 100644 index 0000000000..d9ba4b6ae4 Binary files /dev/null and b/examples/desktop/docs/qa-chat-triple/01-list-view-colored-edges.png differ diff --git a/examples/desktop/docs/qa-chat-triple/02-drawer-open.png b/examples/desktop/docs/qa-chat-triple/02-drawer-open.png new file mode 100644 index 0000000000..566188a75b Binary files /dev/null and b/examples/desktop/docs/qa-chat-triple/02-drawer-open.png differ diff --git a/examples/desktop/docs/qa-chat-triple/03-graph-view.png b/examples/desktop/docs/qa-chat-triple/03-graph-view.png new file mode 100644 index 0000000000..a8180f4890 Binary files /dev/null and b/examples/desktop/docs/qa-chat-triple/03-graph-view.png differ diff --git a/examples/desktop/scripts/qa-cdp-shoot-chat-triple.mjs b/examples/desktop/scripts/qa-cdp-shoot-chat-triple.mjs new file mode 100644 index 0000000000..e5f8f9fab3 --- /dev/null +++ b/examples/desktop/scripts/qa-cdp-shoot-chat-triple.mjs @@ -0,0 +1,204 @@ +// scripts/qa-cdp-shoot-chat-triple.mjs — feat/chat-triple-view shoot. +// +// Boots an isolated Electron on CDP :9271 (its own --user-data-dir + +// $DSH_DESKTOP_HOME so real user config is never touched, per the +// 2026-07-18 postmortem), seeds one fixture session with a small event +// stream (three turns + fork + interruption) so the drawer's history +// list and the graph's fork/interrupt topology both have something to +// paint, then captures three PNGs: +// +// 01-list-view-colored-edges.png — default List view, action-turn +// rails visible, drawer collapsed +// 02-drawer-open.png — same session, right-side detail +// drawer expanded with Current Turn + Session Overview + History +// 03-graph-view.png — Graph tab active, DAG visible +// +// Isolation follows scripts/qa-cdp-shoot-affordance.mjs precedent. + +import { spawn } from 'node:child_process' +import { existsSync, mkdirSync, writeFileSync, rmSync } from 'node:fs' +import { resolve, join } from 'node:path' +import { setTimeout as sleep } from 'node:timers/promises' +import { tmpdir } from 'node:os' + +const WORKTREE = resolve(process.env.DSH_WORKTREE || process.cwd()) +const PARENT = resolve(process.env.DSH_REPO || '/Users/ziya/harness/dsh-desktop-demo') +const ELECTRON = join(PARENT, 'node_modules/.bin/electron') +const CDP_PORT = Number(process.env.DSH_CHAT_TRIPLE_PORT || 9271) +const USER_DATA = join(tmpdir(), 'dsh-chat-triple-userdata') +const DSH_HOME = join(tmpdir(), 'dsh-chat-triple-home') +const OUTDIR = join(WORKTREE, 'docs/qa-chat-triple') + +if (!existsSync(ELECTRON)) { + console.error(`electron binary not found at ${ELECTRON}`) + process.exit(2) +} +mkdirSync(OUTDIR, { recursive: true }) +for (const dir of [USER_DATA, DSH_HOME]) { + try { rmSync(dir, { recursive: true, force: true }) } catch {} + mkdirSync(dir, { recursive: true }) +} +const seedOverlay = [ + 'plugins:', + ` - "@cordisjs/plugin-include":`, + ` path: ${join(WORKTREE, 'config/daemon-echo.yml')}`, + '', +].join('\n') +writeFileSync(join(DSH_HOME, 'user-overlay.cordis.yml'), seedOverlay) +writeFileSync(join(DSH_HOME, 'config.json'), JSON.stringify({ + role: 'coding', approvalMode: 'never', +})) +writeFileSync(join(DSH_HOME, '.onboarded'), new Date().toISOString()) + +async function bootElectron() { + const child = spawn(ELECTRON, [ + `--remote-debugging-port=${CDP_PORT}`, + `--user-data-dir=${USER_DATA}`, + '--disable-gpu', + '--no-sandbox', + '.', + ], { + cwd: WORKTREE, + env: { + ...process.env, + DSH_DESKTOP_HOME: DSH_HOME, + DSH_MAXIMIZE: '1', + DSH_QA: '1', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + const logs = [] + child.stdout.on('data', d => logs.push(String(d))) + child.stderr.on('data', d => logs.push(String(d))) + for (let i = 0; i < 40; i++) { + await sleep(500) + try { + const r = await fetch(`http://localhost:${CDP_PORT}/json/list`) + if (r.ok) return { child, logs } + } catch {} + } + child.kill('SIGKILL') + console.error('electron CDP did not come up. logs:\n' + logs.join('')) + process.exit(3) +} + +async function newCdp() { + const targets = await (await fetch(`http://localhost:${CDP_PORT}/json/list`)).json() + const target = targets.find(t => t.type === 'page') + if (!target) throw new Error('no page target on port ' + CDP_PORT) + const ws = new WebSocket(target.webSocketDebuggerUrl) + await new Promise((ok, err) => { ws.onopen = ok; ws.onerror = e => err(e) }) + let id = 1 + const pending = new Map() + ws.onmessage = ev => { + const msg = JSON.parse(typeof ev.data === 'string' ? ev.data : String(ev.data)) + if (msg.id != null && pending.has(msg.id)) { + const [ok, err] = pending.get(msg.id); pending.delete(msg.id) + if (msg.error) err(new Error(msg.error.message)); else ok(msg.result) + } + } + const call = (m, p = {}, ms = 15000) => new Promise((ok, err) => { + const _id = id++ + const t = setTimeout(() => { pending.delete(_id); err(new Error('cdp timeout: ' + m)) }, ms) + pending.set(_id, [v => { clearTimeout(t); ok(v) }, e => { clearTimeout(t); err(e) }]) + ws.send(JSON.stringify({ id: _id, method: m, params: p })) + }) + const evj = async expr => { + const r = await call('Runtime.evaluate', { + expression: `(async()=>{try{return (${expr})}catch(e){return {__err:String(e)}}})()`, + returnByValue: true, awaitPromise: true, + }) + if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text) + return r.result?.value + } + return { ws, call, evj } +} + +// Seed a demo session in the running renderer via __dshRenderer. +// The renderer exposes ensureSession + selectSession + onSessionEvent. +const SEED = `(async () => { + const R = window.__dshRenderer + if (!R) return { __err: 'renderer seam missing' } + const sid = 'triple-demo-' + Date.now() + R.ensureSession(sid) + await R.selectSession(sid) + const emit = (ev) => R.onSessionEvent(sid, ev) + let seq = 1 + const now = () => Date.now() + emit({ type: 'user/message', seq: seq++, time: now(), + data: { content: [{ type: 'text', text: 'summarize this repo' }] } }) + emit({ type: 'turn/start', seq: seq++, time: now(), + data: { turnId: 't0', model: 'deepseek-r1' } }) + emit({ type: 'assistant/message', seq: seq++, time: now(), + data: { text: 'sure — poking around now.' } }) + emit({ type: 'tool/call', seq: seq++, time: now(), + data: { call_id: 'c1', name: 'ls', arguments: '{"path":"."}' } }) + emit({ type: 'tool/result', seq: seq++, time: now(), + data: { call_id: 'c1', ok: true, output: 'src/ test/ …', durationMs: 42 } }) + emit({ type: 'turn/end', seq: seq++, time: now(), + data: { turnId: 't0', usage: { total_tokens: 240 }, durationMs: 620 } }) + emit({ type: 'user/message', seq: seq++, time: now(), + data: { content: [{ type: 'text', text: 'now run tests' }] } }) + emit({ type: 'turn/start', seq: seq++, time: now(), + data: { turnId: 't1', model: 'deepseek-r1' } }) + emit({ type: 'assistant/message', seq: seq++, time: now(), + data: { text: 'kicking off the suite.' } }) + emit({ type: 'turn/end', seq: seq++, time: now(), + data: { turnId: 't1', usage: { total_tokens: 512 }, durationMs: 4100 } }) + emit({ type: 'session/fork', seq: seq++, time: now(), + data: { fromTurnId: 't1', childSessionId: 'child-xyz' } }) + emit({ type: 'user/message', seq: seq++, time: now(), + data: { content: [{ type: 'text', text: 'wait, cancel' }] } }) + emit({ type: 'turn/start', seq: seq++, time: now(), + data: { turnId: 't2', model: 'deepseek-r1' } }) + emit({ type: 'user/interrupt', seq: seq++, time: now(), data: {} }) + emit({ type: 'turn/end', seq: seq++, time: now(), + data: { turnId: 't2', stopReason: 'cancelled' } }) + return { sid, count: seq - 1 } +})()` + +async function shoot(cdp, name) { + const shot = await cdp.call('Page.captureScreenshot', { format: 'png', fromSurface: false }) + const buf = Buffer.from(shot.data, 'base64') + writeFileSync(join(OUTDIR, name), buf) + console.log(' shot', name, buf.length, 'bytes') +} + +async function main() { + const { child, logs } = await bootElectron() + try { + await sleep(1500) + const cdp = await newCdp() + await cdp.call('Page.enable') + await cdp.call('Emulation.setDeviceMetricsOverride', { + width: 1400, height: 900, deviceScaleFactor: 2, mobile: false, + }) + // wait for renderer seam + for (let i = 0; i < 20; i++) { + const ready = await cdp.evj(`!!(window.__dshRenderer && window.__dshRenderer.onSessionEvent)`) + if (ready) break + await sleep(250) + } + await cdp.evj(`window.__dshTabs && window.__dshTabs.switchTo && window.__dshTabs.switchTo('chat')`) + const seedRes = await cdp.evj(SEED) + console.log('seed:', JSON.stringify(seedRes)) + await sleep(600) + // Shot 1: List view, drawer collapsed (default) + await shoot(cdp, '01-list-view-colored-edges.png') + // Shot 2: Drawer open + await cdp.evj(`document.getElementById('chat-side-drawer-btn').click()`) + await sleep(400) + await shoot(cdp, '02-drawer-open.png') + // Close drawer, switch to Graph + await cdp.evj(`document.getElementById('chat-side-drawer-close').click()`) + await sleep(200) + await cdp.evj(`document.querySelector('.chat-view-tab[data-chat-view-tab="graph"]').click()`) + await sleep(400) + await shoot(cdp, '03-graph-view.png') + console.log('shots saved to', OUTDIR) + } finally { + child.kill('SIGKILL') + } +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/examples/desktop/src/renderer/chat-session-graph.js b/examples/desktop/src/renderer/chat-session-graph.js new file mode 100644 index 0000000000..cd0aa84135 --- /dev/null +++ b/examples/desktop/src/renderer/chat-session-graph.js @@ -0,0 +1,198 @@ +// chat-session-graph.js — pure-SVG DAG rendering of a session's turn +// sequence for the Chat pane's Graph view (feat/chat-triple-view). +// +// Nodes: user messages (grey), agent turns (accent), interrupted turns +// (orange rim). Edges: +// - succession (solid) between consecutive nodes on the main line +// - fork (dashed) when a turn declares a fork to a child session id +// - interruption (orange) at the seam where a turn was cancelled +// +// Layout is a vertical timeline (one row per node) so the graph stays +// readable at any width without a force-directed engine. Kept dependency- +// free — pure SVG built via createElementNS so tests can shim the DOM. + +'use strict' + +;(function () { + +const SVG_NS = 'http://www.w3.org/2000/svg' +const NODE_R = 12 +const ROW_H = 44 +const COL_W = 60 +const PAD_Y = 24 +const PAD_X = 40 + +// Derive nodes + edges from a cachedEvents list. Same event model as +// chat-side-drawer.deriveTurnRows so the two views agree. +function deriveGraph(events) { + const nodes = [] + const edges = [] + if (!Array.isArray(events)) return { nodes, edges } + let currentTurn = null + let turnIdx = 0 + let lastNodeId = null + for (const evt of events) { + if (!evt || typeof evt !== 'object') continue + const type = evt.type || evt.event || '' + const data = evt.data || {} + if (type === 'user/message') { + const id = `u${nodes.length}` + nodes.push({ + id, kind: 'user', label: 'user', + turnId: null, seq: evt.seq || 0, + }) + if (lastNodeId != null) edges.push({ from: lastNodeId, to: id, kind: 'succession' }) + lastNodeId = id + } else if (type === 'turn/start' || type === 'turn.start') { + const id = `t${turnIdx}` + currentTurn = { + id, kind: 'turn', label: `#${turnIdx}`, + turnId: data.turnId || data.turn_id || id, + seq: evt.seq || 0, + interrupted: false, + forkChildren: [], + } + nodes.push(currentTurn) + if (lastNodeId != null) edges.push({ from: lastNodeId, to: id, kind: 'succession' }) + lastNodeId = id + turnIdx += 1 + } else if (currentTurn && (type === 'user/interrupt' || type === 'user/cancel')) { + currentTurn.interrupted = true + } else if (currentTurn && (type === 'turn/end' || type === 'turn.end')) { + const stop = (data.stopReason || data.stop_reason || '').toString().toLowerCase() + if (stop.includes('cancel') || stop.includes('interrupt') || stop.includes('reject')) { + currentTurn.interrupted = true + } + if (currentTurn.interrupted) { + currentTurn.kind = 'interrupt' + } + currentTurn = null + } else if (type === 'session/fork' || type === 'session.fork') { + const parentTurnId = data.fromTurnId || data.parentTurnId + const childId = data.childSessionId || data.child_session_id + const parent = nodes.find((n) => n.turnId === parentTurnId) + const parentId = parent ? parent.id : (lastNodeId || null) + if (parentId) { + const forkId = `f${nodes.length}` + nodes.push({ + id: forkId, kind: 'fork', label: 'fork', + turnId: null, seq: evt.seq || 0, + childSessionId: childId, + }) + edges.push({ from: parentId, to: forkId, kind: 'fork' }) + } + } + } + // Recolour any interrupt edges leading into an interrupt node. + for (const edge of edges) { + const target = nodes.find((n) => n.id === edge.to) + if (target && target.kind === 'interrupt') edge.kind = 'interrupt' + } + return { nodes, edges } +} + +// Compute {x,y} for each node using a simple vertical stack. Fork nodes +// step out to the right (column +1) so the DAG shows a branch. +function layoutGraph(graph) { + const positions = new Map() + let mainRow = 0 + for (const node of graph.nodes) { + if (node.kind === 'fork') { + const parentEdge = graph.edges.find((e) => e.to === node.id) + const parentPos = parentEdge ? positions.get(parentEdge.from) : null + if (parentPos) { + positions.set(node.id, { x: parentPos.x + COL_W, y: parentPos.y }) + continue + } + } + positions.set(node.id, { x: PAD_X, y: PAD_Y + mainRow * ROW_H }) + mainRow += 1 + } + const width = PAD_X * 2 + COL_W * 2 + NODE_R * 2 + const height = PAD_Y * 2 + Math.max(0, mainRow - 1) * ROW_H + NODE_R * 2 + return { positions, width, height } +} + +function renderSessionGraph(container, snapshot) { + if (!container) return + container.textContent = '' + const doc = container.ownerDocument || document + const events = snapshot && snapshot.events + const graph = deriveGraph(events) + if (graph.nodes.length === 0) { + const empty = doc.createElement('div') + empty.className = 'chat-session-graph-empty' + empty.textContent = 'No turns to graph yet. Send a message on this session.' + container.appendChild(empty) + return + } + const { positions, width, height } = layoutGraph(graph) + const svg = doc.createElementNS(SVG_NS, 'svg') + svg.setAttribute('viewBox', `0 0 ${width} ${height}`) + svg.setAttribute('width', String(width)) + svg.setAttribute('height', String(height)) + svg.setAttribute('role', 'img') + svg.setAttribute('aria-label', 'Session graph') + // Edges first so nodes overpaint their endpoints. + for (const edge of graph.edges) { + const from = positions.get(edge.from) + const to = positions.get(edge.to) + if (!from || !to) continue + const line = doc.createElementNS(SVG_NS, 'line') + line.setAttribute('x1', String(from.x)) + line.setAttribute('y1', String(from.y)) + line.setAttribute('x2', String(to.x)) + line.setAttribute('y2', String(to.y)) + line.setAttribute('class', `graph-edge edge-${edge.kind}`) + line.dataset.edgeKind = edge.kind + svg.appendChild(line) + } + for (const node of graph.nodes) { + const pos = positions.get(node.id) + if (!pos) continue + const g = doc.createElementNS(SVG_NS, 'g') + const cls = `graph-node node-${node.kind}` + g.setAttribute('class', cls) + g.dataset.nodeId = node.id + g.dataset.nodeKind = node.kind + if (node.turnId) g.dataset.turnId = node.turnId + if (node.turnId && snapshot && snapshot.selectedTurnId === node.turnId) { + g.classList && g.classList.add && g.classList.add('active') + g.setAttribute('class', cls + ' active') + } + const circle = doc.createElementNS(SVG_NS, 'circle') + circle.setAttribute('cx', String(pos.x)) + circle.setAttribute('cy', String(pos.y)) + circle.setAttribute('r', String(NODE_R)) + g.appendChild(circle) + const label = doc.createElementNS(SVG_NS, 'text') + label.setAttribute('x', String(pos.x + NODE_R + 6)) + label.setAttribute('y', String(pos.y + 4)) + label.textContent = node.label + g.appendChild(label) + if (typeof snapshot?.onSelect === 'function' && node.turnId) { + g.addEventListener('click', () => snapshot.onSelect(node)) + g.style && (g.style.cursor = 'pointer') + } + svg.appendChild(g) + } + container.appendChild(svg) +} + +if (typeof module !== 'undefined' && module.exports) { + module.exports = { + deriveGraph, + layoutGraph, + renderSessionGraph, + _constants: { NODE_R, ROW_H, COL_W, PAD_X, PAD_Y }, + } +} +if (typeof window !== 'undefined') { + window.__dshChatSessionGraph = { + deriveGraph, + layoutGraph, + renderSessionGraph, + } +} + +})() diff --git a/examples/desktop/src/renderer/chat-side-drawer.js b/examples/desktop/src/renderer/chat-side-drawer.js new file mode 100644 index 0000000000..12a7ee5c4d --- /dev/null +++ b/examples/desktop/src/renderer/chat-side-drawer.js @@ -0,0 +1,263 @@ +// chat-side-drawer.js — right-side fold-out drawer for the Chat pane +// (feat/chat-triple-view, lane-chat-triple). +// +// Three sections rendered in order: +// 1. Current Turn — model / tokens / duration / latency / session id / +// turn seq for the selected turn (defaults to the newest one). +// 2. Session Overview — running totals: tokens, duration, turn count. +// 3. History — one row per turn (role tag + first-line summary); click +// jumps main stream to that turn. +// +// State: pure over a session-like snapshot. The renderer wires it via +// `renderChatSideDrawer(container, snapshot)` and toggle by adding / +// removing `hidden` on the drawer aside. Nothing here talks to the DOM +// beyond the passed-in container. + +'use strict' + +;(function () { + +// Derive the ordered turn list from a cachedEvents ring. A turn is +// bounded by turn/start .. turn/end pairs; user/message events sit +// between turns. We flatten to a stream of "history rows" the drawer +// renders — one row per user message + one row per assistant turn. +function deriveTurnRows(events) { + if (!Array.isArray(events) || events.length === 0) return [] + const rows = [] + let currentTurn = null + let turnIdx = 0 + for (const evt of events) { + if (!evt || typeof evt !== 'object') continue + const type = evt.type || evt.event || '' + const data = evt.data || {} + if (type === 'user/message') { + const text = extractText(data) + rows.push({ + kind: 'user', + role: 'user', + summary: firstLine(text) || '(empty)', + turnIndex: null, + seq: evt.seq || 0, + turnId: null, + }) + } else if (type === 'turn/start' || type === 'turn.start') { + currentTurn = { + kind: 'turn', + role: 'agent', + summary: '', + turnIndex: turnIdx, + seq: evt.seq || 0, + turnId: data.turnId || data.turn_id || `t${turnIdx}`, + model: data.model || '', + tokens: 0, + durationMs: 0, + latencyMs: 0, + interrupted: false, + } + turnIdx += 1 + rows.push(currentTurn) + } else if (currentTurn && (type === 'assistant/message' || type === 'assistant.message')) { + const text = extractText(data) + if (!currentTurn.summary) currentTurn.summary = firstLine(text) + } else if (currentTurn && (type === 'turn/end' || type === 'turn.end')) { + const usage = data.usage || {} + currentTurn.tokens = num(usage.total_tokens || usage.totalTokens || usage.tokens || data.tokens) + currentTurn.durationMs = num(data.durationMs || data.duration_ms || 0) + currentTurn.latencyMs = num(data.latencyMs || data.latency_ms || 0) + currentTurn.model = currentTurn.model || data.model || '' + const stop = (data.stopReason || data.stop_reason || '').toString().toLowerCase() + if (stop.includes('cancel') || stop.includes('interrupt') || stop.includes('reject')) { + currentTurn.interrupted = true + } + currentTurn = null + } else if (currentTurn && (type === 'user/interrupt' || type === 'user/cancel')) { + currentTurn.interrupted = true + } + } + return rows +} + +function extractText(data) { + if (!data) return '' + if (typeof data === 'string') return data + if (typeof data.text === 'string') return data.text + if (typeof data.content === 'string') return data.content + if (Array.isArray(data.content)) { + return data.content.map((c) => (c && typeof c.text === 'string') ? c.text : '').join(' ') + } + if (typeof data.delta === 'string') return data.delta + return '' +} +function firstLine(text) { + if (typeof text !== 'string') return '' + const trimmed = text.trim() + if (!trimmed) return '' + const nl = trimmed.indexOf('\n') + const line = nl === -1 ? trimmed : trimmed.slice(0, nl) + return line.length > 80 ? line.slice(0, 79) + '…' : line +} +function num(x) { + const n = Number(x) + return Number.isFinite(n) ? n : 0 +} +function formatMs(ms) { + if (!Number.isFinite(ms) || ms <= 0) return '—' + if (ms < 1000) return `${Math.round(ms)}ms` + return `${(ms / 1000).toFixed(1)}s` +} + +// Compute session-level overview from the derived rows. +function summarize(rows) { + const turns = rows.filter((r) => r.kind === 'turn') + const tokens = turns.reduce((acc, t) => acc + (t.tokens || 0), 0) + const duration = turns.reduce((acc, t) => acc + (t.durationMs || 0), 0) + return { + turnCount: turns.length, + userCount: rows.filter((r) => r.kind === 'user').length, + tokens, + duration, + interrupted: turns.filter((t) => t.interrupted).length, + } +} + +// Render a drawer body into a container element. `snapshot`: +// { sessionId, model?, events, selectedTurnId? } +function renderChatSideDrawer(container, snapshot) { + if (!container) return + // Clear + container.textContent = '' + container.className = 'chat-side-drawer-body' + const doc = container.ownerDocument || document + const rows = deriveTurnRows(snapshot && snapshot.events) + const overview = summarize(rows) + const selectedTurnId = snapshot && snapshot.selectedTurnId + const turns = rows.filter((r) => r.kind === 'turn') + const selected = turns.find((t) => t.turnId === selectedTurnId) || turns[turns.length - 1] || null + + // Section 1: current turn + container.appendChild(renderCurrentTurn(doc, snapshot || {}, selected)) + // Section 2: session overview + container.appendChild(renderOverview(doc, overview)) + // Section 3: history + container.appendChild(renderHistory(doc, rows, selectedTurnId, snapshot && snapshot.onSelect)) +} + +function renderCurrentTurn(doc, snapshot, turn) { + const section = doc.createElement('section') + section.className = 'chat-side-drawer-section chat-side-drawer-section--current' + const title = doc.createElement('div') + title.className = 'chat-side-drawer-section-title' + title.textContent = 'Current Turn' + section.appendChild(title) + const dl = doc.createElement('dl') + dl.className = 'chat-side-drawer-meta' + const entries = [] + if (turn) { + entries.push(['seq', turn.turnIndex != null ? `#${turn.turnIndex}` : '—']) + entries.push(['model', turn.model || snapshot.model || '—']) + entries.push(['tokens', turn.tokens ? String(turn.tokens) : '—']) + entries.push(['duration', formatMs(turn.durationMs)]) + entries.push(['latency', formatMs(turn.latencyMs)]) + entries.push(['turn id', turn.turnId || '—']) + entries.push(['session', shortSid(snapshot.sessionId)]) + if (turn.interrupted) entries.push(['state', 'interrupted']) + } else { + entries.push(['state', 'no turn yet']) + entries.push(['session', shortSid(snapshot.sessionId)]) + } + for (const [k, v] of entries) { + const dt = doc.createElement('dt'); dt.textContent = k + const dd = doc.createElement('dd'); dd.textContent = v + dl.appendChild(dt); dl.appendChild(dd) + } + section.appendChild(dl) + return section +} +function renderOverview(doc, overview) { + const section = doc.createElement('section') + section.className = 'chat-side-drawer-section chat-side-drawer-section--overview' + const title = doc.createElement('div') + title.className = 'chat-side-drawer-section-title' + title.textContent = 'Session Overview' + section.appendChild(title) + const dl = doc.createElement('dl') + dl.className = 'chat-side-drawer-meta' + const entries = [ + ['turns', String(overview.turnCount)], + ['user msgs', String(overview.userCount)], + ['tokens', String(overview.tokens)], + ['duration', formatMs(overview.duration)], + ['interrupts', String(overview.interrupted)], + ] + for (const [k, v] of entries) { + const dt = doc.createElement('dt'); dt.textContent = k + const dd = doc.createElement('dd'); dd.textContent = v + dl.appendChild(dt); dl.appendChild(dd) + } + section.appendChild(dl) + return section +} +function renderHistory(doc, rows, selectedTurnId, onSelect) { + const section = doc.createElement('section') + section.className = 'chat-side-drawer-section chat-side-drawer-section--history' + const title = doc.createElement('div') + title.className = 'chat-side-drawer-section-title' + title.textContent = 'History' + section.appendChild(title) + if (rows.length === 0) { + const empty = doc.createElement('div') + empty.className = 'chat-side-drawer-empty' + empty.textContent = 'No turns yet — send a message to start.' + section.appendChild(empty) + return section + } + const ul = doc.createElement('ul') + ul.className = 'chat-side-drawer-history' + for (const row of rows) { + const li = doc.createElement('li') + li.className = 'chat-side-drawer-history-item' + if (row.turnId && row.turnId === selectedTurnId) li.classList.add('active') + if (row.turnId) li.dataset.turnId = row.turnId + if (row.seq) li.dataset.seq = String(row.seq) + li.dataset.kind = row.kind + const roleEl = doc.createElement('span') + roleEl.className = 'chat-side-drawer-history-role' + roleEl.textContent = row.role + const sumEl = doc.createElement('span') + sumEl.className = 'chat-side-drawer-history-summary' + sumEl.textContent = row.summary || (row.kind === 'turn' ? `(turn ${row.turnIndex})` : '(user)') + li.appendChild(roleEl) + li.appendChild(sumEl) + if (typeof onSelect === 'function') { + li.addEventListener('click', () => onSelect(row)) + } + ul.appendChild(li) + } + section.appendChild(ul) + return section +} +function shortSid(sid) { + if (typeof sid !== 'string' || !sid) return '—' + return sid.length > 10 ? sid.slice(0, 8) + '…' : sid +} + +if (typeof module !== 'undefined' && module.exports) { + module.exports = { + deriveTurnRows, + summarize, + firstLine, + formatMs, + renderChatSideDrawer, + } +} +if (typeof window !== 'undefined') { + window.__dshChatSideDrawer = { + deriveTurnRows, + summarize, + firstLine, + formatMs, + renderChatSideDrawer, + } +} + +})() diff --git a/examples/desktop/src/renderer/index.html b/examples/desktop/src/renderer/index.html index 261933a178..8026af5523 100644 --- a/examples/desktop/src/renderer/index.html +++ b/examples/desktop/src/renderer/index.html @@ -305,6 +305,15 @@ + + + + Details + Quick chat @@ -395,6 +404,16 @@ + + + List + Graph + + + Switch to Graph to see this session's turn DAG. + + + + + diff --git a/examples/desktop/src/renderer/renderer.js b/examples/desktop/src/renderer/renderer.js index be00340a30..932fb89ccc 100644 --- a/examples/desktop/src/renderer/renderer.js +++ b/examples/desktop/src/renderer/renderer.js @@ -4158,6 +4158,100 @@ function refreshRailIfOpen() { if (isRailOpen()) refreshRail() } if (ctxRailBtn) ctxRailBtn.addEventListener('click', () => setRailOpen(!isRailOpen())) if (ctxRailDrawerCloseBtn) ctxRailDrawerCloseBtn.addEventListener('click', () => setRailOpen(false)) +// -- feat/chat-triple-view: side drawer + Graph tab ------------------------ +// Right-side drawer with Current Turn / Session Overview / History; the +// Chat pane also grows a List | Graph tab strip that swaps stream vs the +// Session Graph DAG. Both surfaces read the same cachedEvents ring the +// Context Rail already projects, so no new wire is required. +const chatPaneEl = document.querySelector('.pane[data-pane="chat"]') +const chatSideDrawerBtn = document.getElementById('chat-side-drawer-btn') +const chatSideDrawerEl = document.getElementById('chat-side-drawer') +const chatSideDrawerBodyEl = document.getElementById('chat-side-drawer-body') +const chatSideDrawerCloseBtn = document.getElementById('chat-side-drawer-close') +const chatSessionGraphEl = document.getElementById('chat-session-graph') +const chatViewTabEls = document.querySelectorAll('.chat-view-tab') + +// Default the pane to List. The absence of the attribute would leave the +// CSS selectors idle and both children visible. +if (chatPaneEl && !chatPaneEl.dataset.chatView) { + chatPaneEl.dataset.chatView = 'list' +} +function isChatDrawerOpen() { + return !!(chatSideDrawerEl && !chatSideDrawerEl.classList.contains('hidden')) +} +function setChatDrawerOpen(open) { + if (!chatSideDrawerEl) return + chatSideDrawerEl.classList.toggle('hidden', !open) + chatSideDrawerEl.setAttribute('aria-hidden', open ? 'false' : 'true') + if (chatSideDrawerBtn) chatSideDrawerBtn.setAttribute('aria-expanded', open ? 'true' : 'false') + if (open) refreshChatSideDrawer() +} +function refreshChatSideDrawer() { + if (!isChatDrawerOpen() || !chatSideDrawerBodyEl) return + const api = window.__dshChatSideDrawer + if (!api || typeof api.renderChatSideDrawer !== 'function') return + const meta = state.activeSessionId ? state.sessions.get(state.activeSessionId) : null + const events = (meta && Array.isArray(meta.cachedEvents)) ? meta.cachedEvents : [] + api.renderChatSideDrawer(chatSideDrawerBodyEl, { + sessionId: state.activeSessionId || '', + model: meta && (meta.model || (meta.header && meta.header.model)) || '', + events, + selectedTurnId: null, + onSelect(row) { + if (!row || !row.turnId) return + const target = streamEl && streamEl.querySelector(`[data-turn-id="${row.turnId}"]`) + if (target && typeof target.scrollIntoView === 'function') { + target.scrollIntoView({ behavior: 'smooth', block: 'center' }) + } + }, + }) +} +function refreshChatSideDrawerIfOpen() { if (isChatDrawerOpen()) refreshChatSideDrawer() } +if (chatSideDrawerBtn) { + chatSideDrawerBtn.addEventListener('click', () => setChatDrawerOpen(!isChatDrawerOpen())) +} +if (chatSideDrawerCloseBtn) { + chatSideDrawerCloseBtn.addEventListener('click', () => setChatDrawerOpen(false)) +} + +function setChatView(view) { + if (!chatPaneEl) return + const v = view === 'graph' ? 'graph' : 'list' + chatPaneEl.dataset.chatView = v + for (const btn of chatViewTabEls) { + const active = btn.dataset.chatViewTab === v + btn.classList.toggle('active', active) + btn.setAttribute('aria-selected', active ? 'true' : 'false') + } + if (v === 'graph') refreshSessionGraph() +} +function refreshSessionGraph() { + if (!chatSessionGraphEl) return + const api = window.__dshChatSessionGraph + if (!api || typeof api.renderSessionGraph !== 'function') return + const meta = state.activeSessionId ? state.sessions.get(state.activeSessionId) : null + const events = (meta && Array.isArray(meta.cachedEvents)) ? meta.cachedEvents : [] + api.renderSessionGraph(chatSessionGraphEl, { + sessionId: state.activeSessionId || '', + events, + onSelect(node) { + if (!node || !node.turnId) return + // Jump to the turn in the List view and focus it. + setChatView('list') + const target = streamEl && streamEl.querySelector(`[data-turn-id="${node.turnId}"]`) + if (target && typeof target.scrollIntoView === 'function') { + target.scrollIntoView({ behavior: 'smooth', block: 'center' }) + } + }, + }) +} +function refreshSessionGraphIfActive() { + if (chatPaneEl && chatPaneEl.dataset.chatView === 'graph') refreshSessionGraph() +} +for (const btn of chatViewTabEls) { + btn.addEventListener('click', () => setChatView(btn.dataset.chatViewTab)) +} + function formatTokens(n) { if (!Number.isFinite(n)) return '—' if (n < 1000) return String(n) @@ -4416,6 +4510,17 @@ function onSessionEvent(sessionId, event) { // §1.3 A/B classifier gate: track turn count so hooks-* // demotes from family A (SessionStart) to family B on later turns. meta.turnCount = (meta.turnCount || 0) + 1 + // feat/chat-triple-view: once the current turn container exists in the + // stream, stamp its turnId from the start event so the drawer/graph + // can address it. Same {turnId, turn_id, fallback t{n}} shape as + // chat-side-drawer.deriveTurnRows so both surfaces agree. + const startDataForTurnId = event.data || {} + const derivedTurnId = startDataForTurnId.turnId || startDataForTurnId.turn_id + || `t${meta.turnCount - 1}` + if (sessionId === state.activeSessionId && state.currentTurn && state.currentTurn.section) { + state.currentTurn.section.dataset.turnId = derivedTurnId + state.currentTurn.section.dataset.turnIndex = String(meta.turnCount - 1) + } // Ticket B §B-4 (2026-07-16): a new turn starting means the previous // error/cancel is no longer the current state — drop the derived // lastError so the row stops rendering ✕ interrupted while a fresh @@ -4467,6 +4572,12 @@ function onSessionEvent(sessionId, event) { // list so inject/compact/recall events stream in live alongside the // message bubbles. No-op when the drawer is closed. refreshRailIfOpen() + // feat/chat-triple-view: keep the right-side detail drawer + Session Graph + // in sync with the same event tick. Both no-op when their surface is + // hidden, so this is cheap when the user hasn't opened the drawer / + // switched to Graph yet. + refreshChatSideDrawerIfOpen() + refreshSessionGraphIfActive() // §2.3 (batch 6) template triggers: pure module decides whether the event // qualifies for a template card (T2 error recovery / T4 artifact preview / diff --git a/examples/desktop/src/renderer/style.css b/examples/desktop/src/renderer/style.css index ec5e34abd3..7f2999857b 100644 --- a/examples/desktop/src/renderer/style.css +++ b/examples/desktop/src/renderer/style.css @@ -40,6 +40,18 @@ --accent-strong: #1d4ed8; --accent-soft: rgba(37, 99, 235, 0.12); + /* density-spec §7: turn edge tokens read across the chat stream. + * --turn-action-edge paints the left rail of a sealed action turn + * (reasoning + text + tool-call cluster reads as one blue-edged block). + * --turn-output-edge paints the left rail of a tool-result row so the + * result reads as grey-edged echo below the action's blue. + * --turn-interrupt-marker paints the small orange sliver that appears + * at the head of an interruption row so a reader tracking the rail + * sees the seam. Kept as brand-neutral defaults; swap here to reskin. */ + --turn-action-edge: var(--accent); + --turn-output-edge: var(--border-strong); + --turn-interrupt-marker: #ea580c; + /* Semantic */ --ok: #16a34a; --ok-soft: rgba(22, 163, 74, 0.12); @@ -143,6 +155,9 @@ --accent: #4f8bff; --accent-strong: #6ea3ff; --accent-soft: rgba(79, 139, 255, 0.18); + --turn-action-edge: var(--accent); + --turn-output-edge: var(--border-strong); + --turn-interrupt-marker: #f97316; --user-bubble: #22262f; --shadow-1: 0 1px 2px rgba(0, 0, 0, 0.4); --shadow-2: 0 2px 8px rgba(0, 0, 0, 0.5); @@ -168,6 +183,9 @@ --accent: #4f8bff; --accent-strong: #6ea3ff; --accent-soft: rgba(79, 139, 255, 0.18); + --turn-action-edge: var(--accent); + --turn-output-edge: var(--border-strong); + --turn-interrupt-marker: #f97316; --user-bubble: #22262f; } * { box-sizing: border-box; } @@ -6676,13 +6694,41 @@ textarea:focus-visible { .assistant-turn { display: block; border: 1px solid transparent; /* no card outline by default */ - border-left: 2px solid var(--border); + /* density-spec §7 tokens: sealed action turn wears the accent rail so a + * reasoning + text + tool-call cluster reads as one blue-edged block. */ + border-left: 2px solid var(--turn-action-edge); padding: 0 0 0 12px; margin: 8px 0; } .assistant-turn[data-turn-status="streaming"] { border-left-color: var(--accent-soft); } +/* Grey-edge output rows: tool results and any turn-child painted with + * .turn-output-edge (renderer stamps it on rows that echo external output + * back into the turn). Reads as a muted echo below the blue action edge. */ +.assistant-turn > .turn-body > .tool-result-row { + border-left: 2px solid var(--turn-output-edge); + margin-left: -14px; /* align inner edge with turn rail */ + padding-left: 12px; +} +.assistant-turn > .turn-body > .turn-output-edge { + border-left: 2px solid var(--turn-output-edge); + margin-left: -14px; + padding-left: 12px; +} +/* Interruption marker: an orange sliver planted at the head of a row that + * broke the streaming turn (user cancel, guard reject). Two-pixel high + * dash on the left rail so a reader scanning the edge column sees the + * seam without adding a full-row banner. */ +.assistant-turn .turn-interrupt-marker { + display: block; + width: 4px; + height: 12px; + background: var(--turn-interrupt-marker); + border-radius: 2px; + margin: 0 6px 0 -16px; + flex: 0 0 auto; +} .assistant-turn > .turn-rule { height: 1px; background: transparent; margin: 0 0 6px 0; } @@ -11579,3 +11625,228 @@ details.devtools-row[open] > .devtools-row-summary::before { transform: rotate(9 /* default display (this one is `display: flex`) — behaviourally identical */ /* once JS finishes booting, but robust against the first-paint race. */ .onboarding[hidden] { display: none !important; } + +/* -- Chat triple view: side drawer + view switcher + session graph ------- + * lane-chat-triple. The Chat pane grows a right-side fold-out drawer + * (turn/session metadata + history list) and a top-level view switcher + * that toggles the main stream between List (default) and Graph (a + * DAG over the session's turn sequence, drawn as pure SVG). */ + +/* View switcher tab strip. Sits between the header and the stream on the + * Chat pane. Two buttons, active one carries the accent underline. */ +.chat-view-tabs { + display: flex; + gap: 4px; + padding: 6px 20px 0 20px; + border-bottom: 1px solid var(--divider); + background: var(--bg); + flex: 0 0 auto; +} +.chat-view-tab { + background: transparent; + border: 0; + border-bottom: 2px solid transparent; + padding: 6px 10px; + color: var(--muted); + font-size: 12.5px; + cursor: pointer; + font-family: inherit; +} +.chat-view-tab:hover { color: var(--text); } +.chat-view-tab.active { + color: var(--accent); + border-bottom-color: var(--accent); +} + +/* Chat header gets an icon button that toggles the side drawer. */ +.chat-side-drawer-toggle { + display: inline-flex; + align-items: center; + gap: 4px; +} +.chat-side-drawer-toggle[aria-expanded="true"] { + color: var(--accent); + border-color: var(--accent); +} + +/* Right-side drawer: fixed 320px width, slides in from the right edge of + * the Chat pane. Hidden by default via [hidden]; script toggles that. */ +.chat-side-drawer { + position: absolute; + top: var(--header-h); + right: 0; + bottom: 0; + width: 320px; + background: var(--bg-elev); + border-left: 1px solid var(--border); + display: flex; + flex-direction: column; + overflow: hidden; + z-index: 20; + box-shadow: var(--shadow-2); +} +.chat-side-drawer.hidden { display: none; } +.chat-side-drawer-head { + padding: 10px 14px; + border-bottom: 1px solid var(--divider); + display: flex; + align-items: center; + gap: 8px; +} +.chat-side-drawer-title { + font-size: 13px; + font-weight: 600; + color: var(--text); + flex: 1; +} +.chat-side-drawer-close { + background: transparent; + border: 0; + color: var(--muted); + font-size: 18px; + cursor: pointer; + padding: 0 4px; + line-height: 1; +} +.chat-side-drawer-close:hover { color: var(--text); } +.chat-side-drawer-body { + overflow-y: auto; + padding: 8px 0; + flex: 1; +} +.chat-side-drawer-section { + padding: 8px 14px; + border-bottom: 1px solid var(--divider); +} +.chat-side-drawer-section:last-child { border-bottom: 0; } +.chat-side-drawer-section-title { + font-size: 11px; + font-weight: 600; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 6px; +} +.chat-side-drawer-meta { + display: grid; + grid-template-columns: 88px 1fr; + gap: 4px 8px; + font-size: 12px; +} +.chat-side-drawer-meta dt { + color: var(--muted); + font-weight: 400; + margin: 0; +} +.chat-side-drawer-meta dd { + color: var(--text); + font-family: var(--mono); + font-size: 11.5px; + margin: 0; + overflow-wrap: anywhere; +} +.chat-side-drawer-history { + list-style: none; + margin: 0; + padding: 0; +} +.chat-side-drawer-history-item { + padding: 6px 8px; + border-radius: 4px; + cursor: pointer; + display: flex; + gap: 6px; + align-items: baseline; + font-size: 12px; + color: var(--text); +} +.chat-side-drawer-history-item:hover { + background: var(--surface-hover); +} +.chat-side-drawer-history-item.active { + background: var(--accent-soft); +} +.chat-side-drawer-history-role { + color: var(--muted); + font-family: var(--mono); + font-size: 10.5px; + min-width: 42px; + flex: 0 0 auto; + text-transform: uppercase; +} +.chat-side-drawer-history-summary { + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; +} +.chat-side-drawer-empty { + color: var(--muted); + font-size: 12px; + padding: 8px 4px; +} + +/* Session Graph view. Hidden when the List tab is active; when Graph is + * active the stream container gets [data-chat-view="graph"] and the + * inline .chat-session-graph replaces its content area. */ +.chat-session-graph { + padding: 20px; + overflow: auto; + height: 100%; +} +.chat-session-graph[hidden] { display: none; } +.chat-session-graph-empty { + color: var(--muted); + font-size: 13px; + text-align: center; + padding: 40px 20px; +} +.chat-session-graph svg { + display: block; + max-width: 100%; +} +.chat-session-graph .graph-node { + cursor: pointer; +} +.chat-session-graph .graph-node circle { + fill: var(--bg-elev); + stroke: var(--turn-action-edge); + stroke-width: 2; +} +.chat-session-graph .graph-node.node-user circle { + stroke: var(--muted); +} +.chat-session-graph .graph-node.node-interrupt circle { + stroke: var(--turn-interrupt-marker); +} +.chat-session-graph .graph-node.active circle { + fill: var(--accent-soft); +} +.chat-session-graph .graph-node text { + fill: var(--text); + font-size: 11px; + font-family: var(--mono); +} +.chat-session-graph .graph-edge { + stroke: var(--border-strong); + stroke-width: 1.5; + fill: none; +} +.chat-session-graph .graph-edge.edge-fork { + stroke-dasharray: 4 3; +} +.chat-session-graph .graph-edge.edge-interrupt { + stroke: var(--turn-interrupt-marker); + stroke-width: 2; +} + +/* Stream shows only for [data-chat-view="list"], graph only for + * [data-chat-view="graph"]. The pane is the parent that carries the + * data attribute so a single toggle switches both children. */ +.pane[data-pane="chat"][data-chat-view="graph"] .stream { display: none; } +.pane[data-pane="chat"][data-chat-view="list"] .chat-session-graph { display: none; } + +/* Give the pane a positioning context so the absolute drawer anchors + * inside it, not against the viewport root. */ +.pane[data-pane="chat"] { position: relative; } diff --git a/examples/desktop/test/chat-triple-view.test.js b/examples/desktop/test/chat-triple-view.test.js new file mode 100644 index 0000000000..b3e26e4ae0 --- /dev/null +++ b/examples/desktop/test/chat-triple-view.test.js @@ -0,0 +1,222 @@ +// Tests for feat/chat-triple-view: color tokens, side drawer, session graph. +// Covers: +// - style.css declares the three §7 turn edge tokens and applies them +// to the correct selectors +// - chat-side-drawer.deriveTurnRows / summarize / renderChatSideDrawer +// produce the right shape for a fixture event stream +// - chat-session-graph.deriveGraph node count == turn count, and fork / +// interrupt edges land on the correct edge kinds + +'use strict' + +const test = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') + +const drawer = require('../src/renderer/chat-side-drawer.js') +const graph = require('../src/renderer/chat-session-graph.js') + +// -- Fixture: 3-turn session with one fork + one interruption -------------- +function buildFixture() { + return [ + { type: 'user/message', seq: 1, data: { content: [{ type: 'text', text: 'hello there' }] } }, + { type: 'turn/start', seq: 2, data: { turnId: 't0', model: 'deepseek-r1' } }, + { type: 'assistant/message', seq: 3, data: { text: 'hi — how can I help?' } }, + { type: 'turn/end', seq: 4, data: { turnId: 't0', usage: { total_tokens: 128 }, durationMs: 500 } }, + + { type: 'user/message', seq: 5, data: { content: [{ type: 'text', text: 'run a task' }] } }, + { type: 'turn/start', seq: 6, data: { turnId: 't1', model: 'deepseek-r1' } }, + { type: 'assistant/message', seq: 7, data: { text: 'sure, working on it' } }, + { type: 'turn/end', seq: 8, data: { turnId: 't1', usage: { total_tokens: 512 }, durationMs: 4200 } }, + + // fork off t1 into a child session + { type: 'session/fork', seq: 9, data: { fromTurnId: 't1', childSessionId: 'child-abc' } }, + + { type: 'user/message', seq: 10, data: { content: [{ type: 'text', text: 'wait, cancel that' }] } }, + { type: 'turn/start', seq: 11, data: { turnId: 't2', model: 'deepseek-r1' } }, + { type: 'user/interrupt', seq: 12, data: {} }, + { type: 'turn/end', seq: 13, data: { turnId: 't2', stopReason: 'cancelled' } }, + ] +} + +// -- CSS token gate -------------------------------------------------------- +test('style.css declares §7 turn edge tokens on :root', () => { + const css = fs.readFileSync(path.join(__dirname, '..', 'src', 'renderer', 'style.css'), 'utf8') + assert.match(css, /--turn-action-edge\s*:/, 'missing --turn-action-edge token') + assert.match(css, /--turn-output-edge\s*:/, 'missing --turn-output-edge token') + assert.match(css, /--turn-interrupt-marker\s*:/, 'missing --turn-interrupt-marker token') +}) +test('style.css applies action edge to .assistant-turn border-left', () => { + const css = fs.readFileSync(path.join(__dirname, '..', 'src', 'renderer', 'style.css'), 'utf8') + // capture the .assistant-turn block up to the first close brace + const m = css.match(/\.assistant-turn\s*\{[^}]+\}/) + assert.ok(m, '.assistant-turn rule missing') + assert.match(m[0], /border-left:\s*2px\s+solid\s+var\(--turn-action-edge\)/, + '.assistant-turn should paint its left rail with --turn-action-edge') +}) +test('style.css applies output edge to tool-result-row inside a turn', () => { + const css = fs.readFileSync(path.join(__dirname, '..', 'src', 'renderer', 'style.css'), 'utf8') + assert.match(css, /\.assistant-turn\s*>\s*\.turn-body\s*>\s*\.tool-result-row\s*\{[^}]*--turn-output-edge/, + 'tool-result-row inside turn should reference --turn-output-edge') +}) +test('style.css declares .turn-interrupt-marker with the interrupt color', () => { + const css = fs.readFileSync(path.join(__dirname, '..', 'src', 'renderer', 'style.css'), 'utf8') + assert.match(css, /\.turn-interrupt-marker\s*\{[^}]*var\(--turn-interrupt-marker\)/, + '.turn-interrupt-marker should paint with --turn-interrupt-marker') +}) + +// -- Side drawer pure functions -------------------------------------------- +test('deriveTurnRows: builds user + turn rows in wire order', () => { + const rows = drawer.deriveTurnRows(buildFixture()) + const kinds = rows.map((r) => r.kind) + assert.deepEqual(kinds, ['user', 'turn', 'user', 'turn', 'user', 'turn']) + const turns = rows.filter((r) => r.kind === 'turn') + assert.equal(turns.length, 3) + assert.equal(turns[0].turnId, 't0') + assert.equal(turns[0].tokens, 128) + assert.equal(turns[1].tokens, 512) + assert.equal(turns[2].interrupted, true, 't2 should be marked interrupted') +}) +test('summarize: totals across turns', () => { + const rows = drawer.deriveTurnRows(buildFixture()) + const s = drawer.summarize(rows) + assert.equal(s.turnCount, 3) + assert.equal(s.userCount, 3) + assert.equal(s.tokens, 640) // 128 + 512 + 0 + assert.equal(s.interrupted, 1) +}) +test('firstLine: strips + truncates', () => { + assert.equal(drawer.firstLine('short'), 'short') + assert.equal(drawer.firstLine('line 1\nline 2'), 'line 1') + const big = 'x'.repeat(200) + const trimmed = drawer.firstLine(big) + assert.ok(trimmed.length <= 80) + assert.ok(trimmed.endsWith('…')) +}) + +// -- Drawer render (DOM shim) ---------------------------------------------- +function makeMiniDoc() { + function el(tag) { + return { + tagName: String(tag).toUpperCase(), + className: '', + textContent: '', + dataset: {}, + _children: [], + _attrs: {}, + _listeners: {}, + classList: { + _cls: new Set(), + add(c) { this._cls.add(c) }, + remove(c) { this._cls.delete(c) }, + contains(c) { return this._cls.has(c) }, + toggle(c, force) { + const has = this._cls.has(c) + const shouldOn = typeof force === 'boolean' ? force : !has + if (shouldOn) this._cls.add(c); else this._cls.delete(c) + }, + }, + appendChild(c) { this._children.push(c); return c }, + append(...cs) { for (const c of cs) this._children.push(c); return this }, + setAttribute(k, v) { + this._attrs[k] = v + // In real DOM, HTML elements' `class` attribute mirrors to className; + // SVG elements' does not, but for the purposes of these tests we + // don't care about that distinction — mirror so the filter works. + if (k === 'class') this.className = String(v) + }, + addEventListener(evt, fn) { (this._listeners[evt] ||= []).push(fn) }, + querySelectorAll(sel) { + const cls = sel.replace(/^\./, '') + const out = [] + const walk = (n) => { + if (!n || !Array.isArray(n._children)) return + for (const c of n._children) { + if (c && typeof c.className === 'string' && c.className.split(/\s+/).includes(cls)) out.push(c) + walk(c) + } + } + walk(this) + return out + }, + } + } + return { createElement: el, createElementNS: (ns, tag) => el(tag) } +} +test('renderChatSideDrawer: paints three sections', () => { + const doc = makeMiniDoc() + const container = doc.createElement('div') + container.ownerDocument = doc + drawer.renderChatSideDrawer(container, { + sessionId: 's-abc-123456', + events: buildFixture(), + }) + const sections = container.querySelectorAll('.chat-side-drawer-section') + assert.equal(sections.length, 3, 'expected 3 sections: current / overview / history') + const historyItems = container.querySelectorAll('.chat-side-drawer-history-item') + // 3 user rows + 3 turns = 6 + assert.equal(historyItems.length, 6) +}) + +// -- Session graph --------------------------------------------------------- +test('deriveGraph: node count matches user+turn+fork events', () => { + const g = graph.deriveGraph(buildFixture()) + const turnNodes = g.nodes.filter((n) => n.kind === 'turn' || n.kind === 'interrupt') + const userNodes = g.nodes.filter((n) => n.kind === 'user') + const forkNodes = g.nodes.filter((n) => n.kind === 'fork') + assert.equal(turnNodes.length, 3, 'three turn nodes (interrupted still counts as turn)') + assert.equal(userNodes.length, 3) + assert.equal(forkNodes.length, 1) +}) +test('deriveGraph: fork edge is dashed and interrupt edge is orange', () => { + const g = graph.deriveGraph(buildFixture()) + const forkEdge = g.edges.find((e) => e.kind === 'fork') + assert.ok(forkEdge, 'fork edge missing') + const interruptEdge = g.edges.find((e) => e.kind === 'interrupt') + assert.ok(interruptEdge, 'interrupt edge missing') + // Interrupt edge terminates at an interrupt-kind node. + const target = g.nodes.find((n) => n.id === interruptEdge.to) + assert.equal(target.kind, 'interrupt') +}) +test('deriveGraph: succession edges chain the main line', () => { + const g = graph.deriveGraph(buildFixture()) + const successionEdges = g.edges.filter((e) => e.kind === 'succession') + // 6 main-line nodes ⇒ 5 chained edges; the one that lands on the + // interrupted t2 gets recoloured to `interrupt`, leaving 4 succession. + assert.equal(successionEdges.length, 4) + const interruptEdges = g.edges.filter((e) => e.kind === 'interrupt') + assert.equal(interruptEdges.length, 1) +}) +test('layoutGraph: assigns fork a right-shifted column', () => { + const g = graph.deriveGraph(buildFixture()) + const laid = graph.layoutGraph(g) + const forkNode = g.nodes.find((n) => n.kind === 'fork') + const parentEdge = g.edges.find((e) => e.to === forkNode.id) + const forkPos = laid.positions.get(forkNode.id) + const parentPos = laid.positions.get(parentEdge.from) + assert.ok(forkPos.x > parentPos.x, 'fork should be shifted right of its parent') + assert.equal(forkPos.y, parentPos.y, 'fork sits on the same row as its parent') +}) +test('renderSessionGraph: draws SVG with node + edge groups', () => { + const doc = makeMiniDoc() + const container = doc.createElement('div') + container.ownerDocument = doc + graph.renderSessionGraph(container, { events: buildFixture() }) + const svg = container._children[0] + assert.ok(svg, 'svg root missing') + assert.equal(svg.tagName, 'SVG') + // Count node children (kind: graph-node) and edges (graph-edge) + const nodes = svg._children.filter((c) => typeof c.className === 'string' && c.className.startsWith('graph-node')) + const edges = svg._children.filter((c) => typeof c.className === 'string' && c.className.startsWith('graph-edge')) + assert.equal(nodes.length, 7, 'three turns + three users + one fork = 7 nodes') + assert.ok(edges.length >= 5, 'at least the five main-line succession edges + fork + interrupt') +}) +test('renderSessionGraph: empty state when no events', () => { + const doc = makeMiniDoc() + const container = doc.createElement('div') + container.ownerDocument = doc + graph.renderSessionGraph(container, { events: [] }) + const empty = container._children[0] + assert.equal(empty.className, 'chat-session-graph-empty') +})