mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
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:
Binary file not shown.
|
After Width: | Height: | Size: 302 KiB |
BIN
examples/desktop/docs/qa-chat-triple/02-drawer-open.png
Normal file
BIN
examples/desktop/docs/qa-chat-triple/02-drawer-open.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 359 KiB |
BIN
examples/desktop/docs/qa-chat-triple/03-graph-view.png
Normal file
BIN
examples/desktop/docs/qa-chat-triple/03-graph-view.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 298 KiB |
204
examples/desktop/scripts/qa-cdp-shoot-chat-triple.mjs
Normal file
204
examples/desktop/scripts/qa-cdp-shoot-chat-triple.mjs
Normal 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) })
|
||||
198
examples/desktop/src/renderer/chat-session-graph.js
Normal file
198
examples/desktop/src/renderer/chat-session-graph.js
Normal 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,
|
||||
}
|
||||
}
|
||||
|
||||
})()
|
||||
263
examples/desktop/src/renderer/chat-side-drawer.js
Normal file
263
examples/desktop/src/renderer/chat-side-drawer.js
Normal 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,
|
||||
}
|
||||
}
|
||||
|
||||
})()
|
||||
@@ -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">×</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>
|
||||
|
||||
@@ -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 /
|
||||
|
||||
@@ -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; }
|
||||
|
||||
222
examples/desktop/test/chat-triple-view.test.js
Normal file
222
examples/desktop/test/chat-triple-view.test.js
Normal 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')
|
||||
})
|
||||
Reference in New Issue
Block a user