feat(desktop): chat triple view — turn edge colors + side drawer + Session Graph

Adds a three-layer view to the Chat pane:
  - List view    — turn cards get colored left-edge by role/state
                   (user/assistant/tool/error), replacing the flat
                   monochrome stack; readable at a glance for long
                   sessions.
  - Side drawer  — a right-side collapsible panel opened via the
                   Details button on any turn; shows the raw payload,
                   annotations, and jump-links to the trace tri-view
                   without leaving the chat.
  - Session Graph— a top-level SVG view of the whole session's turn
                   graph (user → assistant → tool chain → subagent
                   branch); zooms out from the linear list to the
                   session's structure.

Renderer wiring is additive: the three new modules mount into two
new hook points in index.html; turn/start now emits data-turn-id
so the drawer can round-trip. finishTurnContainer keeps its Lane C
signal-chip pass and picks up drawer wiring at the tail — the two
tails are independent and compose cleanly.

  chat-session-graph.js         198 +
  chat-side-drawer.js           263 +
  qa-cdp-shoot-chat-triple.mjs  204 +
  chat-triple-view.test.js      222 +
  index.html                     43 +
  renderer.js                   111 +   (wiring + data-turn-id)
  style.css                    +273 -1  (three view sections)

Test suite: 1682/1682 pass (+14 over Lane C baseline). Isolation-
machine fixture screenshots (list/drawer/graph, docs/qa-chat-triple/
0{1,2,3}-*.png) reproduce from the merged HEAD.

Known non-blocking niceties, tracked as follow-ups (out of scope
for this commit):
  - Details button and existing Context Rail both target the
    right-side slot; a small drawer-mutex pass unifies them.
  - Session Graph fixture includes an extra subagent turn beyond
    what the list view shows.
This commit is contained in:
ZiyaZhang
2026-07-19 01:02:15 -07:00
parent 47949244c8
commit 5e46a54de7
10 changed files with 1313 additions and 1 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 359 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

View File

@@ -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) })

View File

@@ -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,
}
}
})()

View File

@@ -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,
}
}
})()

View File

@@ -305,6 +305,15 @@
<!-- Quick chat launcher. The global shortcut ⌘⇧Space also
toggles this overlay; keeping a visible button makes it
discoverable for users who never learn shortcuts. -->
<!-- feat/chat-triple-view: right-side drawer toggle. When
clicked, adds/removes `hidden` on #chat-side-drawer.
Aria-expanded flips so the button styles as "on" when
the drawer is open. -->
<button id="chat-side-drawer-btn" class="ghost small chat-side-drawer-toggle"
aria-expanded="false" title="Toggle chat detail drawer" aria-label="Toggle chat detail drawer">
<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="M3 3.5h14v13h-14zM13 3.5v13"/></svg>
<span>Details</span>
</button>
<button id="quickchat-open" class="ghost small" title="Quick chat (⌘⇧Space)" aria-label="Open quick chat">
<svg viewBox="0 0 20 20" width="14" height="14" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" d="M3 5.5A2.5 2.5 0 0 1 5.5 3h9A2.5 2.5 0 0 1 17 5.5v6A2.5 2.5 0 0 1 14.5 14H9l-4 3v-3H5.5A2.5 2.5 0 0 1 3 11.5z"/><path fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" d="M7.5 8.5h5M7.5 6.5h5"/></svg>
<span>Quick chat</span>
@@ -395,6 +404,16 @@
</div>
</div>
</header>
<!-- feat/chat-triple-view: view switcher tabs (List | Graph).
The `data-chat-view` attribute on the parent .pane[data-pane="chat"]
swaps which child (stream vs graph container) is visible. Default
is "list" so first-paint stays identical to previous versions. -->
<div class="chat-view-tabs" role="tablist" aria-label="Chat view">
<button class="chat-view-tab active" data-chat-view-tab="list"
role="tab" aria-selected="true" type="button">List</button>
<button class="chat-view-tab" data-chat-view-tab="graph"
role="tab" aria-selected="false" type="button">Graph</button>
</div>
<section id="stream" class="stream" aria-live="polite">
<!-- Fresh-eyes P0 (2026-07-18): the empty-welcome block used to
vanish forever the moment New session ran (renderer.js clears
@@ -589,6 +608,28 @@
<div class="context-rail-empty">Open a session to see its context timeline.</div>
</div>
</aside>
<!-- feat/chat-triple-view: Session Graph mount point. Painted by
chat-session-graph.js when the Graph tab is active. Kept in the
chat pane so it inherits the composer/statusbar shell — the
view switcher only swaps the .stream vs this container. -->
<div class="chat-session-graph" id="chat-session-graph" role="region"
aria-label="Session graph">
<div class="chat-session-graph-empty">Switch to Graph to see this session's turn DAG.</div>
</div>
<!-- feat/chat-triple-view: right-side detail drawer. Rendered by
chat-side-drawer.js on toggle. `.hidden` class collapses; the
#chat-side-drawer-btn button in the header flips it. -->
<aside class="chat-side-drawer hidden" id="chat-side-drawer"
aria-label="Chat detail drawer">
<header class="chat-side-drawer-head">
<span class="chat-side-drawer-title">Details</span>
<button type="button" class="chat-side-drawer-close" id="chat-side-drawer-close"
aria-label="Close detail drawer" title="Close">&times;</button>
</header>
<div class="chat-side-drawer-body" id="chat-side-drawer-body">
<div class="chat-side-drawer-empty">Open a session to see turn metadata and history.</div>
</div>
</aside>
</section>
<!-- Session Tree pane. The feature-first surface: DSH's event log makes
@@ -1335,6 +1376,8 @@
<script src="./turn-flow-glyph.js"></script><!-- task #201 / trace-viz §4d: inline turn-flow shape glyph -->
<script src="./details-aria.js"></script><!-- fix/expand-affordance 2026-07-18: <details> [open] → summary aria-expanded reflection helper -->
<script src="./assistant-turn.js"></script><!-- task #162 rec 22-bis: assistant-turn container (consumes the three above) -->
<script src="./chat-side-drawer.js"></script><!-- feat/chat-triple-view: right-side turn/session detail drawer -->
<script src="./chat-session-graph.js"></script><!-- feat/chat-triple-view: session DAG (turn nodes + fork/interrupt edges) -->
<script src="./event-filter.js"></script>
<script src="./capabilities.js"></script>

View File

@@ -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 /

View File

@@ -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; }

View File

@@ -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 <g> 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')
})