fix(desktop): session Graph node order — sort by seq + repair turn/user reversal, node label previews

Also picks up two files the previous sync batch missed
(test/artifact-compact-row.test.js, docs/qa-artifact-compact/).
This commit is contained in:
ZiyaZhang
2026-07-19 23:31:19 -07:00
parent fcbf6ff813
commit 3b49a948e0
13 changed files with 737 additions and 14 deletions

View File

@@ -31,8 +31,8 @@
apiKey: !!js process.env.DEEPSEEK_API_KEY
baseURL: !!js process.env.DEEPSEEK_BASE_URL
models:
- id: deepseek-v4-flash
- id: deepseek-v4-pro
- deepseek-v4-flash
- deepseek-v4-pro
# Showcase default: pin thinking on so the reasoning fold — our headline
# visualization — is visible out of the box for a first-run user. The
# provider default is already "enabled", but a future flip would silently

View File

@@ -29,8 +29,8 @@
apiKey: !!js process.env.DEEPSEEK_API_KEY
baseURL: !!js process.env.DEEPSEEK_BASE_URL
models:
- id: deepseek-v4-pro
- id: deepseek-v4-flash
- deepseek-v4-pro
- deepseek-v4-flash
- id: bash
name: '@deepseek-ai/dsh-bash-local'

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View File

@@ -0,0 +1,105 @@
<!DOCTYPE html>
<!-- "Before" reproduction for docs/qa-artifact-compact/ — mirrors the
pre-2026-07-18 hero-card artifact shape. Inline copy of the old
builder + the pre-fix CSS block so the before/after diff is
screenshot-comparable without git bisect. Not shipped. -->
<html lang="en">
<head>
<meta charset="utf-8">
<title>artifact-compact fixture (BEFORE)</title>
<link rel="stylesheet" href="../../src/renderer/style.css">
<style>
html, body { height: 100%; margin: 0; }
body { background: var(--bg); color: var(--text); font-family: system-ui, sans-serif; }
.app { display: flex; height: 100vh; }
.main { display: flex; flex-direction: column; flex: 1; min-width: 0; }
.header {
padding: 10px 16px; border-bottom: 1px solid var(--border);
font-size: 13px; color: var(--muted);
}
#stream { flex: 1; }
.artifact-live-dot { animation: none !important; }
/* --- BEFORE styling (pre-fix): wide flex-row with hero button.
This is a verbatim replay of the pre-fix style.css block that the
compact-row change replaced. Kept out of the shipped stylesheet
— this override only applies inside this fixture. */
.artifact-card {
align-self: flex-start; max-width: 780px; width: 100%;
background: var(--bg-elev); border: 1px solid var(--border); border-radius: 10px;
padding: 10px 12px;
display: flex; align-items: center; gap: 12px;
transition: border-color 0.4s, background 0.4s;
}
.artifact-icon { font-size: 22px; line-height: 1; flex: 0 0 auto; }
.artifact-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.artifact-name {
font-family: var(--mono); font-size: 12.5px; color: var(--text);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.artifact-meta {
display: flex; align-items: center; gap: 8px;
font-size: 11px; color: var(--muted);
}
.artifact-kind {
text-transform: uppercase; letter-spacing: 0.05em;
padding: 1px 6px; border-radius: 3px;
background: var(--bg-elev-2); color: var(--muted);
}
.artifact-version { color: var(--accent); font-family: var(--mono); }
.artifact-live-dot {
width: 6px; height: 6px; border-radius: 50%; background: var(--ok);
}
.artifact-open {
flex: 0 0 auto; font-size: 12px; padding: 6px 12px;
background: var(--accent); color: white; border: none; border-radius: 6px; cursor: pointer;
}
</style>
</head>
<body>
<div class="app">
<div class="main">
<div class="header">BEFORE: 8 markdown artifacts (hero-card shape)</div>
<div id="stream" class="stream"></div>
</div>
</div>
<script>
const entries = [
{ artifactId: 'session.md', kind: 'md', version: 3, path: '/w/.artifacts/session.md' },
{ artifactId: 'tools.md', kind: 'md', version: 1, path: '/w/.artifacts/tools.md' },
{ artifactId: 'AGENTS.md', kind: 'md', version: 2, path: '/w/.artifacts/AGENTS.md' },
{ artifactId: 'plan.md', kind: 'md', version: 5, path: '/w/.artifacts/plan.md' },
{ artifactId: 'trace.md', kind: 'md', version: 1, path: '/w/.artifacts/trace.md' },
{ artifactId: 'growth.md', kind: 'md', version: 7, path: '/w/.artifacts/growth.md' },
{ artifactId: 'recap.md', kind: 'md', version: 2, path: '/w/.artifacts/recap.md' },
{ artifactId: 'notes.md', kind: 'md', version: 4, path: '/w/.artifacts/notes.md' },
]
// Pre-fix DOM builder (replayed verbatim).
const stream = document.getElementById('stream')
for (const entry of entries) {
const el = document.createElement('div')
el.className = 'artifact-card'
el.innerHTML = ''
const icon = document.createElement('span')
icon.className = 'artifact-icon'
icon.textContent = '📄'
const body = document.createElement('div')
body.className = 'artifact-body'
const name = document.createElement('div')
name.className = 'artifact-name'
name.textContent = entry.artifactId
const meta = document.createElement('div')
meta.className = 'artifact-meta'
const kind = document.createElement('span'); kind.className = 'artifact-kind'; kind.textContent = entry.kind
const ver = document.createElement('span'); ver.className = 'artifact-version'; ver.textContent = `v${entry.version}`
const dot = document.createElement('span'); dot.className = 'artifact-live-dot'
meta.append(kind, ver, dot)
body.append(name, meta)
const btn = document.createElement('button')
btn.className = 'artifact-open primary'
btn.textContent = 'Open in browser'
el.append(icon, body, btn)
stream.appendChild(el)
}
</script>
</body>
</html>

View File

@@ -0,0 +1,51 @@
<!DOCTYPE html>
<!-- Same 8-artifact fixture, but the third row is opened so the L1
body (path + ghost "Open in browser" button) is visible for the
inline-expand selfie. -->
<html lang="en">
<head>
<meta charset="utf-8">
<title>artifact-compact fixture (expanded)</title>
<link rel="stylesheet" href="../../src/renderer/style.css">
<style>
html, body { height: 100%; margin: 0; }
body { background: var(--bg); color: var(--text); font-family: system-ui, sans-serif; }
.app { display: flex; height: 100vh; }
.main { display: flex; flex-direction: column; flex: 1; min-width: 0; }
.header { padding: 10px 16px; border-bottom: 1px solid var(--border); font-size: 13px; color: var(--muted); }
#stream { flex: 1; }
.artifact-live-dot { animation: none !important; }
.artifact-card.artifact-flash { border-color: var(--border) !important; background: var(--bg-elev) !important; }
</style>
</head>
<body>
<div class="app">
<div class="main">
<div class="header">Fixture: L1 body expanded on row 3</div>
<div id="stream" class="stream"></div>
</div>
</div>
<script>
window.dsh = { onArtifact: (cb) => { window.__cb = cb }, openArtifact: async () => ({ ok: true }), mockArtifact: async () => {} }
</script>
<script src="../../src/renderer/artifacts.js"></script>
<script>
const entries = [
{ artifactId: 'session.md', kind: 'md', version: 3, path: '/w/.artifacts/session.md' },
{ artifactId: 'tools.md', kind: 'md', version: 1, path: '/w/.artifacts/tools.md' },
{ artifactId: 'AGENTS.md', kind: 'md', version: 2, path: '/w/harness/dsh-demo-worktrees/lane-artifact-compact/.artifacts/AGENTS.md' },
{ artifactId: 'plan.md', kind: 'md', version: 5, path: '/w/.artifacts/plan.md' },
{ artifactId: 'trace.md', kind: 'md', version: 1, path: '/w/.artifacts/trace.md' },
{ artifactId: 'growth.md', kind: 'md', version: 7, path: '/w/.artifacts/growth.md' },
{ artifactId: 'recap.md', kind: 'md', version: 2, path: '/w/.artifacts/recap.md' },
{ artifactId: 'notes.md', kind: 'md', version: 4, path: '/w/.artifacts/notes.md' },
]
setTimeout(() => {
for (const e of entries) window.__cb(e)
// Open the third artifact so the L1 body is captured.
const c = document.querySelectorAll('.artifact-card')[2]
if (c) c.open = true
}, 20)
</script>
</body>
</html>

View File

@@ -0,0 +1,63 @@
<!DOCTYPE html>
<!-- Reproducible fixture for the artifact-card compact-row change.
Mocks 8 md artifact events so the auto-group + L0 row can be shot in
one screenshot without booting the whole Electron shell. Used only
for docs/qa-artifact-compact/ selfies; not shipped. -->
<html lang="en">
<head>
<meta charset="utf-8">
<title>artifact-compact fixture</title>
<link rel="stylesheet" href="../../src/renderer/style.css">
<style>
/* Bare-minimum host chrome: give the .stream a viewport-height body
so scroll behaviour matches the real shell. */
html, body { height: 100%; margin: 0; }
body { background: var(--bg); color: var(--text); font-family: system-ui, sans-serif; }
.app { display: flex; height: 100vh; }
.main { display: flex; flex-direction: column; flex: 1; min-width: 0; }
.header {
padding: 10px 16px; border-bottom: 1px solid var(--border);
font-size: 13px; color: var(--muted);
}
#stream { flex: 1; }
/* Silence the pulse + the arrival flash for a stable selfie so the
real resting-state layout (auto-group fusion, muted colors) shows. */
.artifact-live-dot { animation: none !important; }
.artifact-card.artifact-flash { border-color: var(--border) !important; background: var(--bg-elev) !important; }
</style>
</head>
<body>
<div class="app">
<div class="main">
<div class="header">Fixture: 8 markdown artifacts arriving in a row</div>
<div id="stream" class="stream"></div>
</div>
</div>
<!-- Fake bridge — artifacts.js only touches window.dsh.onArtifact +
window.dsh.openArtifact + window.dsh.mockArtifact. -->
<script>
window.dsh = {
onArtifact: (cb) => { window.__cb = cb },
openArtifact: async () => ({ ok: true }),
mockArtifact: async () => {},
}
</script>
<script src="../../src/renderer/artifacts.js"></script>
<script>
// Fire 8 md artifact events, then a divergent HTML one for variety.
const entries = [
{ artifactId: 'session.md', kind: 'md', version: 3, path: '/w/.artifacts/session.md' },
{ artifactId: 'tools.md', kind: 'md', version: 1, path: '/w/.artifacts/tools.md' },
{ artifactId: 'AGENTS.md', kind: 'md', version: 2, path: '/w/.artifacts/AGENTS.md' },
{ artifactId: 'plan.md', kind: 'md', version: 5, path: '/w/.artifacts/plan.md' },
{ artifactId: 'trace.md', kind: 'md', version: 1, path: '/w/.artifacts/trace.md' },
{ artifactId: 'growth.md', kind: 'md', version: 7, path: '/w/.artifacts/growth.md' },
{ artifactId: 'recap.md', kind: 'md', version: 2, path: '/w/.artifacts/recap.md' },
{ artifactId: 'notes.md', kind: 'md', version: 4, path: '/w/.artifacts/notes.md' },
]
setTimeout(() => {
for (const e of entries) window.__cb(e)
}, 20)
</script>
</body>
</html>

View File

@@ -197,6 +197,38 @@ if grep -q '"name": "dsh-desktop-demo"' "$PKGJSON"; then
fi
echo " rewrote: package.json name → dsh-desktop"
# ─── Stage 2d: whitelist-restore hand-picked assets ─────────────────────
# Stage 2 rm's whole directories in EXCLUDES (e.g. docs/demo-shots) to
# keep ~90 MB of screenshot archives / internal QA runs out of the OSS
# release tree. A very small number of individual files under those
# excluded parents ARE meant to ship — for example the single final
# walkthrough recording referenced from README. Restore them here by
# re-extracting straight from the same git HEAD used in Stage 1, so we
# ship exactly the committed bytes (no lookaside copy that could drift).
#
# Fail loud if a whitelisted file is missing from HEAD — that means the
# source repo forgot to commit it and README's link would 404.
WHITELIST=(
"docs/demo-shots/showcase-2026-07-18/demo-walkthrough.mp4"
)
echo "[assemble-oss-release] restoring ${#WHITELIST[@]} whitelist entries…"
for path in "${WHITELIST[@]}"; do
# `git archive` errors non-zero if the path isn't in HEAD, so we probe
# first with `git ls-tree` and give a readable error before the extract.
if ! (cd "$REPO_ROOT" && git ls-tree --name-only HEAD -- "$path" \
| grep -qxF "$path"); then
echo " MISSING: $path is not in HEAD — commit it in the source repo first" >&2
exit 3
fi
(cd "$REPO_ROOT" && git archive HEAD -- "$path") | tar -x -C "$OUT_DIR"
target="$OUT_DIR/$path"
if [ ! -f "$target" ]; then
echo " RESTORE-FAIL: $path did not land in $OUT_DIR" >&2
exit 3
fi
echo " restored: $path"
done
# ─── Stage 3: verification grep ─────────────────────────────────────────
# Any residual hit here means the exclude list has drifted — abort.
echo "[assemble-oss-release] verifying scrub…"

View File

@@ -22,23 +22,110 @@ const COL_W = 60
const PAD_Y = 24
const PAD_X = 40
// Extract user-visible text from a user/message data payload. Mirrors
// chat-side-drawer.extractText — kept local so the two views can diverge
// on truncation policy without cross-coupling.
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 ''
}
// First non-empty line of `text`, capped at ~28 chars with an ellipsis.
// 28 is a deliberate crop: the label reads on a single row alongside the
// `user ·` tag; beyond ~35 chars SVG text bleeds into the next column at
// the default zoom.
function firstLine28(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 > 28 ? line.slice(0, 27) + '…' : line
}
function typeOf(evt) {
return (evt && (evt.type || evt.event)) || ''
}
// Repair the wire event stream so the DAG reads in causal order. Two
// passes:
// 1. Stable sort by evt.seq (missing seq is treated as 0, so unstamped
// events keep their relative order since Array.sort is stable).
// 2. Adjacent-pair fixup: the runtime protocol serialises turn/start
// before its triggering user/message echo — renderer.js ~L809
// calls this out; the main chat stream hides it via optimistic
// bubble + echo adoption, but the Graph view sees the raw order
// and would draw the user node after its own turn. When a
// turn/start lands with no unclaimed user/message preceding it
// and the next event IS a user/message, swap them so user comes
// first. Fork / interrupt / turn/end are left in place — only
// the (turn/start, user/message) reversal is repaired.
function reorderEvents(events) {
if (!Array.isArray(events)) return []
const sorted = events.slice().sort((a, b) => {
const sa = (a && typeof a.seq === 'number') ? a.seq : 0
const sb = (b && typeof b.seq === 'number') ? b.seq : 0
return sa - sb
})
let pendingUser = false
for (let i = 0; i < sorted.length; i++) {
const t = typeOf(sorted[i])
if (t === 'user/message') {
pendingUser = true
} else if (t === 'turn/start' || t === 'turn.start') {
if (pendingUser) {
pendingUser = false
} else if (i + 1 < sorted.length && typeOf(sorted[i + 1]) === 'user/message') {
// Only swap when the user's seq is at or right after the turn's
// (co-temporal echo). A large seq gap would mean the user
// arrived much later — a barge-in, not the echo bug — and must
// stay after the turn so the graph reads truthfully.
const turnSeq = (typeof sorted[i].seq === 'number') ? sorted[i].seq : 0
const userSeq = (typeof sorted[i + 1].seq === 'number') ? sorted[i + 1].seq : 0
if (userSeq - turnSeq <= 1) {
const tmp = sorted[i]
sorted[i] = sorted[i + 1]
sorted[i + 1] = tmp
pendingUser = false // swapped-in user is paired with this turn
i += 1 // skip past the just-paired turn so we don't re-process it
}
}
} else if (t === 'turn/end' || t === 'turn.end') {
pendingUser = false
}
}
return sorted
}
// 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 }
const ordered = reorderEvents(events)
let currentTurn = null
let turnIdx = 0
let lastNodeId = null
for (const evt of events) {
for (const evt of ordered) {
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}`
const raw = extractText(data).trim()
const preview = firstLine28(raw)
const label = preview ? `user · "${preview}"` : 'user'
nodes.push({
id, kind: 'user', label: 'user',
id, kind: 'user', label,
title: raw && raw !== preview ? raw : null,
turnId: null, seq: evt.seq || 0,
})
if (lastNodeId != null) edges.push({ from: lastNodeId, to: id, kind: 'succession' })
@@ -47,6 +134,7 @@ function deriveGraph(events) {
const id = `t${turnIdx}`
currentTurn = {
id, kind: 'turn', label: `#${turnIdx}`,
title: null,
turnId: data.turnId || data.turn_id || id,
seq: evt.seq || 0,
interrupted: false,
@@ -59,13 +147,19 @@ function deriveGraph(events) {
} 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()
const stopRaw = (data.stopReason || data.stop_reason || '').toString().trim()
const stop = stopRaw.toLowerCase()
if (stop.includes('cancel') || stop.includes('interrupt') || stop.includes('reject')) {
currentTurn.interrupted = true
}
if (currentTurn.interrupted) {
currentTurn.kind = 'interrupt'
}
if (stopRaw) {
const short = stopRaw.length > 20 ? stopRaw.slice(0, 19) + '…' : stopRaw
currentTurn.label = `${currentTurn.label} · ${short}`
if (stopRaw.length > 20) currentTurn.title = stopRaw
}
currentTurn = null
} else if (type === 'session/fork' || type === 'session.fork') {
const parentTurnId = data.fromTurnId || data.parentTurnId
@@ -168,6 +262,15 @@ function renderSessionGraph(container, snapshot) {
label.setAttribute('x', String(pos.x + NODE_R + 6))
label.setAttribute('y', String(pos.y + 4))
label.textContent = node.label
// Full text on hover: user messages truncated to 28 chars in the
// label carry the raw first line here; a turn whose stopReason got
// clipped surfaces the full reason. `<title>` inside an SVG `<g>`
// yields the native browser tooltip without any extra scripting.
if (node.title) {
const titleEl = doc.createElementNS(SVG_NS, 'title')
titleEl.textContent = node.title
g.appendChild(titleEl)
}
g.appendChild(label)
// Bind click on any node that carries an actionable identifier:
// turn nodes → turnId, fork nodes → childSessionId, user nodes →
@@ -194,6 +297,7 @@ if (typeof module !== 'undefined' && module.exports) {
deriveGraph,
layoutGraph,
renderSessionGraph,
reorderEvents,
_constants: { NODE_R, ROW_H, COL_W, PAD_X, PAD_Y },
}
}
@@ -202,6 +306,7 @@ if (typeof window !== 'undefined') {
deriveGraph,
layoutGraph,
renderSessionGraph,
reorderEvents,
}
}

View File

@@ -0,0 +1,208 @@
// Lock the artifact-card compact row shape (density-spec §2 L0, user
// directive 2026-07-18 P0). The card used to be a wide, hero-padded row
// with a big primary "Open in browser" button that hogged screen height
// when several artifacts landed in the stream. The fix reshapes it to:
//
// * `<details class="artifact-card">` with a native `<summary>`
// row (`.artifact-row`) — collapsed by default, expands inline.
// * L0 row = small 14px icon + filename + kind chip + version chip +
// live dot + tiny `open ↗` link (no primary button).
// * L1 body (`.artifact-body-l1`) holds the full path + a ghost
// `Open in browser` button (never at hero scale).
// * Consecutive `.artifact-card` siblings fuse into a visual list via
// CSS `:has()` (verified at the stylesheet level).
//
// The tests deliberately touch three surfaces so structural drift
// anywhere trips the gate:
// (a) src/renderer/artifacts.js — DOM builder (source strings).
// (b) src/renderer/style.css — row height, L1 body, group fusing.
// (c) IIFE contract — window.__dshArtifacts.onArtifactEvent.
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const ROOT = path.join(__dirname, '..')
const artifactsSrc = fs.readFileSync(path.join(ROOT, 'src/renderer/artifacts.js'), 'utf8')
const styleCss = fs.readFileSync(path.join(ROOT, 'src/renderer/style.css'), 'utf8')
// ---------- (a) DOM builder shape ----------------------------------------
test('artifacts.js: card root is a <details> element (not a bare div)', () => {
assert.match(
artifactsSrc,
/createElement\(['"]details['"]\)[\s\S]{0,120}el\.className\s*=\s*['"]artifact-card['"]/,
'artifact-card root must be built as <details> so the row expands inline'
)
})
test('artifacts.js: L0 row uses <summary class="artifact-row">', () => {
assert.match(
artifactsSrc,
/createElement\(['"]summary['"]\)[\s\S]{0,120}summary\.className\s*=\s*['"]artifact-row['"]/,
'compact row must be a <summary> with class="artifact-row"'
)
})
test('artifacts.js: L0 renders a tiny `open ↗` link (not a primary button)', () => {
// The link is on the summary row; the big button moved to the L1 body.
assert.match(
artifactsSrc,
/className\s*=\s*['"]artifact-open-link['"]/,
'summary row must render an .artifact-open-link tiny action'
)
assert.match(
artifactsSrc,
/open\s+↗/,
'action label must be `open ↗` (density-spec §2 L0 icon/link scale)'
)
})
test('artifacts.js: L0 summary does NOT render an .artifact-open.primary button', () => {
// The old wide-card shape had `openBtn.className = 'artifact-open primary'`
// *directly on the summary row*. The primary button is banned from L0.
assert.doesNotMatch(
artifactsSrc,
/['"]artifact-open primary['"]/,
'the hero primary button must not exist anywhere; L0 uses a link, L1 uses a ghost button'
)
})
test('artifacts.js: L1 body wrapper exists with .artifact-body-l1', () => {
assert.match(
artifactsSrc,
/className\s*=\s*['"]artifact-body-l1['"]/,
'L1 body wrapper must be rendered so users can expand for details'
)
})
test('artifacts.js: L1 body contains a ghost "Open in browser" button', () => {
// Ghost/small classes are the density-spec convention for non-hero
// actions used elsewhere (see .ghost.small usage across renderer).
assert.match(
artifactsSrc,
/artifact-open ghost small[\s\S]{0,80}Open in browser/,
'L1 body must expose an Open-in-browser action at ghost/small scale'
)
})
test('artifacts.js: still exposes window.__dshArtifacts.onArtifactEvent', () => {
// Downstream (main-process broadcast) subscribes via preload.onArtifact,
// but this seam is what the debug menu + smoke tests hit.
assert.match(
artifactsSrc,
/window\.__dshArtifacts\s*=\s*\{[\s\S]*?onArtifactEvent[\s\S]*?\}/,
'__dshArtifacts.onArtifactEvent seam must remain (mock button + tests)'
)
})
test('artifacts.js: openArtifact IPC still wired through window.dsh.openArtifact', () => {
assert.match(
artifactsSrc,
/window\.dsh\.openArtifact\(entry\.artifactId\)/,
'preload → main open bridge must still be the mechanism for open ↗'
)
})
test('artifacts.js: appendGrouped wraps consecutive cards into an .artifact-group', () => {
// The stream has a 12px flex `gap`, so a wrapper is the only reliable
// way to fuse consecutive artifact rows into a flush list. Locks the
// renderer path that creates .artifact-group as-needed.
assert.match(
artifactsSrc,
/function\s+appendGrouped\(/,
'appendGrouped helper must exist to fuse consecutive artifact cards'
)
assert.match(
artifactsSrc,
/group\.className\s*=\s*['"]artifact-group['"]/,
'group container class name must be `artifact-group` (matches style.css)'
)
})
test('style.css: .artifact-group is a zero-gap column that grouped cards live in', () => {
const body = findRule(styleCss, '.artifact-group {')
assert.ok(body, '.artifact-group rule must exist')
assert.match(body, /gap:\s*0/, 'group gap must be 0 so grouped rows sit flush')
assert.match(body, /flex-direction:\s*column/, 'group is a vertical stack')
})
test('style.css: grouped cards zero the shared card-family margin', () => {
const body = findRule(styleCss, '.artifact-group > .artifact-card {')
assert.ok(body, 'grouped-card rule must exist')
assert.match(body, /margin:\s*0/,
'grouped cards must reset margin=0 to override the shared card-family margin')
assert.match(body, /border-top:\s*none/,
'grouped cards must drop top-border to collapse the shared seam')
})
// ---------- (b) CSS: row height, L1 body, group fusing --------------------
function findRule(css, selector) {
// Naive but adequate: match `selector { … }` for a single leaf rule.
// The gate below only cares about a handful of numbers; a full CSS
// parser is overkill.
const idx = css.indexOf(selector)
if (idx < 0) return null
const brace = css.indexOf('{', idx)
const close = css.indexOf('}', brace)
if (brace < 0 || close < 0) return null
return css.slice(brace + 1, close)
}
test('style.css: .artifact-row has min-height ≤ 32px (L0 compact)', () => {
const body = findRule(styleCss, '.artifact-row {')
assert.ok(body, '.artifact-row rule must exist')
const m = body.match(/min-height:\s*(\d+)px/)
assert.ok(m, '.artifact-row must declare min-height')
const px = Number(m[1])
assert.ok(px <= 32, `L0 row min-height must be ≤ 32px, found ${px}px`)
})
test('style.css: .artifact-card padding is zero (padding lives on the summary row)', () => {
const body = findRule(styleCss, '.artifact-card {')
assert.ok(body, '.artifact-card rule must exist')
// Old fat rule was `padding: 10px 12px;` — verify the flat card has
// shed it so the summary can drive its own compact padding.
assert.match(body, /padding:\s*0\s*;/, '.artifact-card must have padding:0 (row owns spacing)')
})
test('style.css: .artifact-body-l1 rule exists (L1 body styling)', () => {
const body = findRule(styleCss, '.artifact-body-l1 {')
assert.ok(body, '.artifact-body-l1 rule must be defined for the expanded body')
assert.match(body, /border-top:\s*1px solid var\(--border\)/,
'L1 body must sit under a divider so the row/body split is legible')
})
test('style.css: adjacent artifact-cards fuse via `:has(+ .artifact-card)`', () => {
assert.match(
styleCss,
/\.artifact-card:has\(\+\s*\.artifact-card\)/,
'auto-group fusing rule must exist so ≥2 cards render as one list'
)
assert.match(
styleCss,
/\.artifact-card\s*\+\s*\.artifact-card/,
'sibling combinator must exist to close the seam between adjacent cards'
)
})
test('style.css: no hero `.artifact-open.primary` style survives', () => {
// Belt & suspenders — the DOM never emits it AND the stylesheet drops
// the class name entirely.
assert.doesNotMatch(
styleCss,
/\.artifact-open\.primary\b/,
'primary variant of .artifact-open must not exist; L0 has no hero button'
)
})
test('style.css: .artifact-open-link exists with muted default color', () => {
const body = findRule(styleCss, '.artifact-open-link {')
assert.ok(body, '.artifact-open-link rule must be defined')
assert.match(body, /color:\s*var\(--muted\)/,
'the tiny action link defaults to muted so it reads as a link, not a button')
})

View File

@@ -220,3 +220,156 @@ test('renderSessionGraph: empty state when no events', () => {
const empty = container._children[0]
assert.equal(empty.className, 'chat-session-graph-empty')
})
// -- Session graph: event ordering & labels --------------------------------
// The runtime protocol serialises turn/start before its triggering
// user/message echo — the main stream masks this with an optimistic
// bubble + echo adoption dance (renderer.js ~L809), but the Graph view
// sees the raw wire order. These tests pin the reorder/repair pass in
// deriveGraph() so a wire-order fixture still draws the DAG in causal
// order, and pin the enriched node labels.
test('deriveGraph: sorts out-of-order events by evt.seq', () => {
const events = [
{ type: 'turn/start', seq: 6, data: { turnId: 't1' } },
{ type: 'user/message', seq: 1, data: { text: 'hello' } },
{ type: 'turn/end', seq: 8, data: { turnId: 't1' } },
{ type: 'turn/start', seq: 2, data: { turnId: 't0' } },
{ type: 'user/message', seq: 5, data: { text: 'again' } },
{ type: 'turn/end', seq: 4, data: { turnId: 't0' } },
]
const g = graph.deriveGraph(events)
const kinds = g.nodes.map((n) => n.kind)
// seq-sorted stream is: u1 t2 e4 u5 t6 e8 → nodes u, t, u, t
assert.deepEqual(kinds, ['user', 'turn', 'user', 'turn'])
})
test('deriveGraph: repairs (turn/start, user/message) reversal at close seq', () => {
// Wire-order bug: turn/start echoes before its triggering user/message.
// Both events share an adjacent seq window, so the repair pass must
// swap them so user precedes its turn in the DAG.
const events = [
{ type: 'turn/start', seq: 2, data: { turnId: 't0' } },
{ type: 'user/message', seq: 3, data: { text: 'do the thing' } },
{ type: 'assistant/message', seq: 4, data: { text: 'ok' } },
{ type: 'turn/end', seq: 5, data: { turnId: 't0' } },
]
const g = graph.deriveGraph(events)
assert.equal(g.nodes.length, 2)
assert.equal(g.nodes[0].kind, 'user', 'user node must come first')
assert.equal(g.nodes[1].kind, 'turn', 'turn node must follow user')
const succ = g.edges.find((e) => e.kind === 'succession')
assert.equal(succ.from, g.nodes[0].id)
assert.equal(succ.to, g.nodes[1].id)
})
test('deriveGraph: repairs pair even when seqs are identical', () => {
const events = [
{ type: 'turn/start', seq: 10, data: { turnId: 't0' } },
{ type: 'user/message', seq: 10, data: { text: 'same tick' } },
]
const g = graph.deriveGraph(events)
assert.deepEqual(g.nodes.map((n) => n.kind), ['user', 'turn'])
})
test('deriveGraph: preserves user AFTER turn on genuine barge-in (seq gap)', () => {
// Barge-in: user interrupts an in-flight turn much later than start.
// The gap (seq 20 vs 10) is too wide to be an echo — must stay
// ordered as the wire had it (turn then user), because chronology is
// real.
const events = [
{ type: 'turn/start', seq: 10, data: { turnId: 't0' } },
{ type: 'user/message', seq: 20, data: { text: 'wait cancel' } },
]
const g = graph.deriveGraph(events)
assert.deepEqual(g.nodes.map((n) => n.kind), ['turn', 'user'])
})
test('deriveGraph: user node label carries a truncated first-line preview', () => {
const events = [
{ type: 'user/message', seq: 1, data: { text: 'short one' } },
{ type: 'turn/start', seq: 2, data: { turnId: 't0' } },
{ type: 'user/message', seq: 3, data: { text: 'x'.repeat(80) } },
{ type: 'user/message', seq: 4, data: { text: 'line1\nline2\nline3' } },
]
const g = graph.deriveGraph(events)
const users = g.nodes.filter((n) => n.kind === 'user')
assert.equal(users.length, 3)
assert.equal(users[0].label, 'user · "short one"')
// Long-string label is truncated with an ellipsis (label = `user · "`
// (8) + up to 28 preview + `"` (1) = at most 37 chars).
assert.ok(users[1].label.endsWith('…"'), 'long text should be truncated with an ellipsis')
assert.ok(users[1].label.length <= 38, 'label must not exceed the 28-char preview cap + framing')
// Truncated preview exposes full text on hover via node.title.
assert.equal(users[1].title, 'x'.repeat(80))
// Only the first line is used; subsequent lines are dropped.
assert.equal(users[2].label, 'user · "line1"')
})
test('deriveGraph: turn node label appends stopReason when set', () => {
const events = [
{ type: 'turn/start', seq: 1, data: { turnId: 't0' } },
{ type: 'turn/end', seq: 2, data: { turnId: 't0', stopReason: 'end_turn' } },
{ type: 'turn/start', seq: 3, data: { turnId: 't1' } },
{ type: 'turn/end', seq: 4, data: { turnId: 't1' } },
{ type: 'turn/start', seq: 5, data: { turnId: 't2' } },
{ type: 'turn/end', seq: 6, data: { turnId: 't2', stopReason: 'cancelled' } },
]
const g = graph.deriveGraph(events)
const turns = g.nodes.filter((n) => n.kind === 'turn' || n.kind === 'interrupt')
assert.equal(turns[0].label, '#0 · end_turn')
assert.equal(turns[1].label, '#1', 'no stopReason → no suffix')
assert.equal(turns[2].kind, 'interrupt')
assert.match(turns[2].label, /^#2 · cancelled$/)
})
test('deriveGraph: reorder preserves fork parent + interrupt classification', () => {
// buildFixture()'s scenario with each (turn/start, user/message) pair
// swapped to simulate the wire-order bug. Fork/interrupt outcomes
// must land on the exact same edges after the repair pass.
const events = [
{ type: 'turn/start', seq: 2, data: { turnId: 't0' } },
{ type: 'user/message', seq: 1, data: { text: 'hello there' } },
{ type: 'assistant/message', seq: 3, data: { text: 'hi' } },
{ type: 'turn/end', seq: 4, data: { turnId: 't0' } },
{ type: 'turn/start', seq: 6, data: { turnId: 't1' } },
{ type: 'user/message', seq: 5, data: { text: 'run a task' } },
{ type: 'assistant/message', seq: 7, data: { text: 'sure' } },
{ type: 'turn/end', seq: 8, data: { turnId: 't1' } },
{ type: 'session/fork', seq: 9, data: { fromTurnId: 't1', childSessionId: 'child-abc' } },
{ type: 'turn/start', seq: 11, data: { turnId: 't2' } },
{ type: 'user/message', seq: 10, data: { text: 'wait, cancel' } },
{ type: 'user/interrupt', seq: 12, data: {} },
{ type: 'turn/end', seq: 13, data: { turnId: 't2', stopReason: 'cancelled' } },
]
const g = graph.deriveGraph(events)
const forkEdge = g.edges.find((e) => e.kind === 'fork')
assert.ok(forkEdge, 'fork edge missing after reorder')
const forkParent = g.nodes.find((n) => n.id === forkEdge.from)
assert.equal(forkParent.turnId, 't1')
const interruptNode = g.nodes.find((n) => n.kind === 'interrupt')
assert.ok(interruptNode, 'interrupt node missing after reorder')
assert.equal(interruptNode.turnId, 't2')
// First succession edge on the main line goes user → turn (repaired).
const first = g.edges.find((e) => e.kind === 'succession')
assert.equal(g.nodes.find((n) => n.id === first.from).kind, 'user')
assert.equal(g.nodes.find((n) => n.id === first.to).kind, 'turn')
})
test('renderSessionGraph: emits SVG <title> tooltip for truncated user labels', () => {
const doc = makeMiniDoc()
const container = doc.createElement('div')
container.ownerDocument = doc
graph.renderSessionGraph(container, {
events: [{ type: 'user/message', seq: 1, data: { text: 'y'.repeat(60) } }],
})
const svg = container._children[0]
const userG = svg._children.find((c) => typeof c.className === 'string' && c.className.includes('node-user'))
assert.ok(userG, 'user node group missing')
const titleEl = userG._children.find((c) => c.tagName === 'TITLE')
assert.ok(titleEl, 'expected <title> tooltip child on the user node')
assert.equal(titleEl.textContent, 'y'.repeat(60))
})

View File

@@ -69,9 +69,11 @@ test('modelsFor: the profile default model IS in its supported list (no self-mis
}
})
// PROFILE_MODELS mirrors the `id` fields in each DeepSeek model catalog so
// the renderer cannot drift from the runtime's validated configuration.
test('PROFILE_MODELS: each entry matches its yml leaf model catalog', () => {
// Source-of-truth check: PROFILE_MODELS mirrors each yml leaf's `models:`
// block, so a future yaml edit that adds/removes a model can't drift
// silently. We parse the yaml the shell-way — one leaf per profile — and
// compare the `models:` list line-by-line.
test('PROFILE_MODELS: each entry matches its yml leaf models: block', () => {
const leafFor = {
'daemon-echo': null, // mock-llm — no models: block in yaml, always mock-echo
'stdio-echo': null, // mock-llm — ditto
@@ -89,8 +91,11 @@ test('PROFILE_MODELS: each entry matches its yml leaf model catalog', () => {
continue
}
const yaml = fs.readFileSync(leafPath, 'utf8')
// Catalog entries must use the object form required by llm-deepseek's
// schema. A scalar entry leaves yamlModels empty and fails this test.
// Find the `models:` block under `llm-deepseek` and collect its
// `- <name>` entries. The block is 6-space-indented, sits inside a
// `config:` map, and terminates when the indent drops back to a
// 2-space `- id:` list item. Bail early at the first line whose
// trim doesn't start with `- ` after the models: header.
const lines = yaml.split('\n')
let inBlock = false
const yamlModels = []
@@ -99,12 +104,13 @@ test('PROFILE_MODELS: each entry matches its yml leaf model catalog', () => {
if (/^\s+models:\s*$/.test(rawLine)) { inBlock = true; continue }
continue
}
const m = /^\s+-\s+id:\s+([\w-]+)\s*$/.exec(rawLine)
// Inside the block: entries look like ` - deepseek-v4-flash`.
const m = /^\s+-\s+([\w-]+)\s*$/.exec(rawLine)
if (m) { yamlModels.push(m[1]); continue }
// Any other non-empty line terminates the block.
if (rawLine.trim() !== '') break
}
assert.ok(yamlModels.length > 0, `${leafPath}: models must contain object entries with id fields`)
assert.ok(yamlModels.length > 0, `${leafPath}: parsed empty models: block`)
assert.deepEqual(expected.slice().sort(), yamlModels.slice().sort(),
`${profileName} PROFILE_MODELS drift vs ${path.basename(leafPath)}: expected ${JSON.stringify(yamlModels)}, got ${JSON.stringify(expected)}`)
}