mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
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.
223 lines
10 KiB
JavaScript
223 lines
10 KiB
JavaScript
// 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')
|
|
})
|