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

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

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

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

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

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

View File

@@ -0,0 +1,198 @@
// chat-session-graph.js — pure-SVG DAG rendering of a session's turn
// sequence for the Chat pane's Graph view (feat/chat-triple-view).
//
// Nodes: user messages (grey), agent turns (accent), interrupted turns
// (orange rim). Edges:
// - succession (solid) between consecutive nodes on the main line
// - fork (dashed) when a turn declares a fork to a child session id
// - interruption (orange) at the seam where a turn was cancelled
//
// Layout is a vertical timeline (one row per node) so the graph stays
// readable at any width without a force-directed engine. Kept dependency-
// free — pure SVG built via createElementNS so tests can shim the DOM.
'use strict'
;(function () {
const SVG_NS = 'http://www.w3.org/2000/svg'
const NODE_R = 12
const ROW_H = 44
const COL_W = 60
const PAD_Y = 24
const PAD_X = 40
// Derive nodes + edges from a cachedEvents list. Same event model as
// chat-side-drawer.deriveTurnRows so the two views agree.
function deriveGraph(events) {
const nodes = []
const edges = []
if (!Array.isArray(events)) return { nodes, edges }
let currentTurn = null
let turnIdx = 0
let lastNodeId = null
for (const evt of events) {
if (!evt || typeof evt !== 'object') continue
const type = evt.type || evt.event || ''
const data = evt.data || {}
if (type === 'user/message') {
const id = `u${nodes.length}`
nodes.push({
id, kind: 'user', label: 'user',
turnId: null, seq: evt.seq || 0,
})
if (lastNodeId != null) edges.push({ from: lastNodeId, to: id, kind: 'succession' })
lastNodeId = id
} else if (type === 'turn/start' || type === 'turn.start') {
const id = `t${turnIdx}`
currentTurn = {
id, kind: 'turn', label: `#${turnIdx}`,
turnId: data.turnId || data.turn_id || id,
seq: evt.seq || 0,
interrupted: false,
forkChildren: [],
}
nodes.push(currentTurn)
if (lastNodeId != null) edges.push({ from: lastNodeId, to: id, kind: 'succession' })
lastNodeId = id
turnIdx += 1
} else if (currentTurn && (type === 'user/interrupt' || type === 'user/cancel')) {
currentTurn.interrupted = true
} else if (currentTurn && (type === 'turn/end' || type === 'turn.end')) {
const stop = (data.stopReason || data.stop_reason || '').toString().toLowerCase()
if (stop.includes('cancel') || stop.includes('interrupt') || stop.includes('reject')) {
currentTurn.interrupted = true
}
if (currentTurn.interrupted) {
currentTurn.kind = 'interrupt'
}
currentTurn = null
} else if (type === 'session/fork' || type === 'session.fork') {
const parentTurnId = data.fromTurnId || data.parentTurnId
const childId = data.childSessionId || data.child_session_id
const parent = nodes.find((n) => n.turnId === parentTurnId)
const parentId = parent ? parent.id : (lastNodeId || null)
if (parentId) {
const forkId = `f${nodes.length}`
nodes.push({
id: forkId, kind: 'fork', label: 'fork',
turnId: null, seq: evt.seq || 0,
childSessionId: childId,
})
edges.push({ from: parentId, to: forkId, kind: 'fork' })
}
}
}
// Recolour any interrupt edges leading into an interrupt node.
for (const edge of edges) {
const target = nodes.find((n) => n.id === edge.to)
if (target && target.kind === 'interrupt') edge.kind = 'interrupt'
}
return { nodes, edges }
}
// Compute {x,y} for each node using a simple vertical stack. Fork nodes
// step out to the right (column +1) so the DAG shows a branch.
function layoutGraph(graph) {
const positions = new Map()
let mainRow = 0
for (const node of graph.nodes) {
if (node.kind === 'fork') {
const parentEdge = graph.edges.find((e) => e.to === node.id)
const parentPos = parentEdge ? positions.get(parentEdge.from) : null
if (parentPos) {
positions.set(node.id, { x: parentPos.x + COL_W, y: parentPos.y })
continue
}
}
positions.set(node.id, { x: PAD_X, y: PAD_Y + mainRow * ROW_H })
mainRow += 1
}
const width = PAD_X * 2 + COL_W * 2 + NODE_R * 2
const height = PAD_Y * 2 + Math.max(0, mainRow - 1) * ROW_H + NODE_R * 2
return { positions, width, height }
}
function renderSessionGraph(container, snapshot) {
if (!container) return
container.textContent = ''
const doc = container.ownerDocument || document
const events = snapshot && snapshot.events
const graph = deriveGraph(events)
if (graph.nodes.length === 0) {
const empty = doc.createElement('div')
empty.className = 'chat-session-graph-empty'
empty.textContent = 'No turns to graph yet. Send a message on this session.'
container.appendChild(empty)
return
}
const { positions, width, height } = layoutGraph(graph)
const svg = doc.createElementNS(SVG_NS, 'svg')
svg.setAttribute('viewBox', `0 0 ${width} ${height}`)
svg.setAttribute('width', String(width))
svg.setAttribute('height', String(height))
svg.setAttribute('role', 'img')
svg.setAttribute('aria-label', 'Session graph')
// Edges first so nodes overpaint their endpoints.
for (const edge of graph.edges) {
const from = positions.get(edge.from)
const to = positions.get(edge.to)
if (!from || !to) continue
const line = doc.createElementNS(SVG_NS, 'line')
line.setAttribute('x1', String(from.x))
line.setAttribute('y1', String(from.y))
line.setAttribute('x2', String(to.x))
line.setAttribute('y2', String(to.y))
line.setAttribute('class', `graph-edge edge-${edge.kind}`)
line.dataset.edgeKind = edge.kind
svg.appendChild(line)
}
for (const node of graph.nodes) {
const pos = positions.get(node.id)
if (!pos) continue
const g = doc.createElementNS(SVG_NS, 'g')
const cls = `graph-node node-${node.kind}`
g.setAttribute('class', cls)
g.dataset.nodeId = node.id
g.dataset.nodeKind = node.kind
if (node.turnId) g.dataset.turnId = node.turnId
if (node.turnId && snapshot && snapshot.selectedTurnId === node.turnId) {
g.classList && g.classList.add && g.classList.add('active')
g.setAttribute('class', cls + ' active')
}
const circle = doc.createElementNS(SVG_NS, 'circle')
circle.setAttribute('cx', String(pos.x))
circle.setAttribute('cy', String(pos.y))
circle.setAttribute('r', String(NODE_R))
g.appendChild(circle)
const label = doc.createElementNS(SVG_NS, 'text')
label.setAttribute('x', String(pos.x + NODE_R + 6))
label.setAttribute('y', String(pos.y + 4))
label.textContent = node.label
g.appendChild(label)
if (typeof snapshot?.onSelect === 'function' && node.turnId) {
g.addEventListener('click', () => snapshot.onSelect(node))
g.style && (g.style.cursor = 'pointer')
}
svg.appendChild(g)
}
container.appendChild(svg)
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
deriveGraph,
layoutGraph,
renderSessionGraph,
_constants: { NODE_R, ROW_H, COL_W, PAD_X, PAD_Y },
}
}
if (typeof window !== 'undefined') {
window.__dshChatSessionGraph = {
deriveGraph,
layoutGraph,
renderSessionGraph,
}
}
})()

View File

@@ -0,0 +1,263 @@
// chat-side-drawer.js — right-side fold-out drawer for the Chat pane
// (feat/chat-triple-view, lane-chat-triple).
//
// Three sections rendered in order:
// 1. Current Turn — model / tokens / duration / latency / session id /
// turn seq for the selected turn (defaults to the newest one).
// 2. Session Overview — running totals: tokens, duration, turn count.
// 3. History — one row per turn (role tag + first-line summary); click
// jumps main stream to that turn.
//
// State: pure over a session-like snapshot. The renderer wires it via
// `renderChatSideDrawer(container, snapshot)` and toggle by adding /
// removing `hidden` on the drawer aside. Nothing here talks to the DOM
// beyond the passed-in container.
'use strict'
;(function () {
// Derive the ordered turn list from a cachedEvents ring. A turn is
// bounded by turn/start .. turn/end pairs; user/message events sit
// between turns. We flatten to a stream of "history rows" the drawer
// renders — one row per user message + one row per assistant turn.
function deriveTurnRows(events) {
if (!Array.isArray(events) || events.length === 0) return []
const rows = []
let currentTurn = null
let turnIdx = 0
for (const evt of events) {
if (!evt || typeof evt !== 'object') continue
const type = evt.type || evt.event || ''
const data = evt.data || {}
if (type === 'user/message') {
const text = extractText(data)
rows.push({
kind: 'user',
role: 'user',
summary: firstLine(text) || '(empty)',
turnIndex: null,
seq: evt.seq || 0,
turnId: null,
})
} else if (type === 'turn/start' || type === 'turn.start') {
currentTurn = {
kind: 'turn',
role: 'agent',
summary: '',
turnIndex: turnIdx,
seq: evt.seq || 0,
turnId: data.turnId || data.turn_id || `t${turnIdx}`,
model: data.model || '',
tokens: 0,
durationMs: 0,
latencyMs: 0,
interrupted: false,
}
turnIdx += 1
rows.push(currentTurn)
} else if (currentTurn && (type === 'assistant/message' || type === 'assistant.message')) {
const text = extractText(data)
if (!currentTurn.summary) currentTurn.summary = firstLine(text)
} else if (currentTurn && (type === 'turn/end' || type === 'turn.end')) {
const usage = data.usage || {}
currentTurn.tokens = num(usage.total_tokens || usage.totalTokens || usage.tokens || data.tokens)
currentTurn.durationMs = num(data.durationMs || data.duration_ms || 0)
currentTurn.latencyMs = num(data.latencyMs || data.latency_ms || 0)
currentTurn.model = currentTurn.model || data.model || ''
const stop = (data.stopReason || data.stop_reason || '').toString().toLowerCase()
if (stop.includes('cancel') || stop.includes('interrupt') || stop.includes('reject')) {
currentTurn.interrupted = true
}
currentTurn = null
} else if (currentTurn && (type === 'user/interrupt' || type === 'user/cancel')) {
currentTurn.interrupted = true
}
}
return rows
}
function extractText(data) {
if (!data) return ''
if (typeof data === 'string') return data
if (typeof data.text === 'string') return data.text
if (typeof data.content === 'string') return data.content
if (Array.isArray(data.content)) {
return data.content.map((c) => (c && typeof c.text === 'string') ? c.text : '').join(' ')
}
if (typeof data.delta === 'string') return data.delta
return ''
}
function firstLine(text) {
if (typeof text !== 'string') return ''
const trimmed = text.trim()
if (!trimmed) return ''
const nl = trimmed.indexOf('\n')
const line = nl === -1 ? trimmed : trimmed.slice(0, nl)
return line.length > 80 ? line.slice(0, 79) + '…' : line
}
function num(x) {
const n = Number(x)
return Number.isFinite(n) ? n : 0
}
function formatMs(ms) {
if (!Number.isFinite(ms) || ms <= 0) return '—'
if (ms < 1000) return `${Math.round(ms)}ms`
return `${(ms / 1000).toFixed(1)}s`
}
// Compute session-level overview from the derived rows.
function summarize(rows) {
const turns = rows.filter((r) => r.kind === 'turn')
const tokens = turns.reduce((acc, t) => acc + (t.tokens || 0), 0)
const duration = turns.reduce((acc, t) => acc + (t.durationMs || 0), 0)
return {
turnCount: turns.length,
userCount: rows.filter((r) => r.kind === 'user').length,
tokens,
duration,
interrupted: turns.filter((t) => t.interrupted).length,
}
}
// Render a drawer body into a container element. `snapshot`:
// { sessionId, model?, events, selectedTurnId? }
function renderChatSideDrawer(container, snapshot) {
if (!container) return
// Clear
container.textContent = ''
container.className = 'chat-side-drawer-body'
const doc = container.ownerDocument || document
const rows = deriveTurnRows(snapshot && snapshot.events)
const overview = summarize(rows)
const selectedTurnId = snapshot && snapshot.selectedTurnId
const turns = rows.filter((r) => r.kind === 'turn')
const selected = turns.find((t) => t.turnId === selectedTurnId) || turns[turns.length - 1] || null
// Section 1: current turn
container.appendChild(renderCurrentTurn(doc, snapshot || {}, selected))
// Section 2: session overview
container.appendChild(renderOverview(doc, overview))
// Section 3: history
container.appendChild(renderHistory(doc, rows, selectedTurnId, snapshot && snapshot.onSelect))
}
function renderCurrentTurn(doc, snapshot, turn) {
const section = doc.createElement('section')
section.className = 'chat-side-drawer-section chat-side-drawer-section--current'
const title = doc.createElement('div')
title.className = 'chat-side-drawer-section-title'
title.textContent = 'Current Turn'
section.appendChild(title)
const dl = doc.createElement('dl')
dl.className = 'chat-side-drawer-meta'
const entries = []
if (turn) {
entries.push(['seq', turn.turnIndex != null ? `#${turn.turnIndex}` : '—'])
entries.push(['model', turn.model || snapshot.model || '—'])
entries.push(['tokens', turn.tokens ? String(turn.tokens) : '—'])
entries.push(['duration', formatMs(turn.durationMs)])
entries.push(['latency', formatMs(turn.latencyMs)])
entries.push(['turn id', turn.turnId || '—'])
entries.push(['session', shortSid(snapshot.sessionId)])
if (turn.interrupted) entries.push(['state', 'interrupted'])
} else {
entries.push(['state', 'no turn yet'])
entries.push(['session', shortSid(snapshot.sessionId)])
}
for (const [k, v] of entries) {
const dt = doc.createElement('dt'); dt.textContent = k
const dd = doc.createElement('dd'); dd.textContent = v
dl.appendChild(dt); dl.appendChild(dd)
}
section.appendChild(dl)
return section
}
function renderOverview(doc, overview) {
const section = doc.createElement('section')
section.className = 'chat-side-drawer-section chat-side-drawer-section--overview'
const title = doc.createElement('div')
title.className = 'chat-side-drawer-section-title'
title.textContent = 'Session Overview'
section.appendChild(title)
const dl = doc.createElement('dl')
dl.className = 'chat-side-drawer-meta'
const entries = [
['turns', String(overview.turnCount)],
['user msgs', String(overview.userCount)],
['tokens', String(overview.tokens)],
['duration', formatMs(overview.duration)],
['interrupts', String(overview.interrupted)],
]
for (const [k, v] of entries) {
const dt = doc.createElement('dt'); dt.textContent = k
const dd = doc.createElement('dd'); dd.textContent = v
dl.appendChild(dt); dl.appendChild(dd)
}
section.appendChild(dl)
return section
}
function renderHistory(doc, rows, selectedTurnId, onSelect) {
const section = doc.createElement('section')
section.className = 'chat-side-drawer-section chat-side-drawer-section--history'
const title = doc.createElement('div')
title.className = 'chat-side-drawer-section-title'
title.textContent = 'History'
section.appendChild(title)
if (rows.length === 0) {
const empty = doc.createElement('div')
empty.className = 'chat-side-drawer-empty'
empty.textContent = 'No turns yet — send a message to start.'
section.appendChild(empty)
return section
}
const ul = doc.createElement('ul')
ul.className = 'chat-side-drawer-history'
for (const row of rows) {
const li = doc.createElement('li')
li.className = 'chat-side-drawer-history-item'
if (row.turnId && row.turnId === selectedTurnId) li.classList.add('active')
if (row.turnId) li.dataset.turnId = row.turnId
if (row.seq) li.dataset.seq = String(row.seq)
li.dataset.kind = row.kind
const roleEl = doc.createElement('span')
roleEl.className = 'chat-side-drawer-history-role'
roleEl.textContent = row.role
const sumEl = doc.createElement('span')
sumEl.className = 'chat-side-drawer-history-summary'
sumEl.textContent = row.summary || (row.kind === 'turn' ? `(turn ${row.turnIndex})` : '(user)')
li.appendChild(roleEl)
li.appendChild(sumEl)
if (typeof onSelect === 'function') {
li.addEventListener('click', () => onSelect(row))
}
ul.appendChild(li)
}
section.appendChild(ul)
return section
}
function shortSid(sid) {
if (typeof sid !== 'string' || !sid) return '—'
return sid.length > 10 ? sid.slice(0, 8) + '…' : sid
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
deriveTurnRows,
summarize,
firstLine,
formatMs,
renderChatSideDrawer,
}
}
if (typeof window !== 'undefined') {
window.__dshChatSideDrawer = {
deriveTurnRows,
summarize,
firstLine,
formatMs,
renderChatSideDrawer,
}
}
})()

View File

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

View File

@@ -4158,6 +4158,100 @@ function refreshRailIfOpen() { if (isRailOpen()) refreshRail() }
if (ctxRailBtn) ctxRailBtn.addEventListener('click', () => setRailOpen(!isRailOpen()))
if (ctxRailDrawerCloseBtn) ctxRailDrawerCloseBtn.addEventListener('click', () => setRailOpen(false))
// -- feat/chat-triple-view: side drawer + Graph tab ------------------------
// Right-side drawer with Current Turn / Session Overview / History; the
// Chat pane also grows a List | Graph tab strip that swaps stream vs the
// Session Graph DAG. Both surfaces read the same cachedEvents ring the
// Context Rail already projects, so no new wire is required.
const chatPaneEl = document.querySelector('.pane[data-pane="chat"]')
const chatSideDrawerBtn = document.getElementById('chat-side-drawer-btn')
const chatSideDrawerEl = document.getElementById('chat-side-drawer')
const chatSideDrawerBodyEl = document.getElementById('chat-side-drawer-body')
const chatSideDrawerCloseBtn = document.getElementById('chat-side-drawer-close')
const chatSessionGraphEl = document.getElementById('chat-session-graph')
const chatViewTabEls = document.querySelectorAll('.chat-view-tab')
// Default the pane to List. The absence of the attribute would leave the
// CSS selectors idle and both children visible.
if (chatPaneEl && !chatPaneEl.dataset.chatView) {
chatPaneEl.dataset.chatView = 'list'
}
function isChatDrawerOpen() {
return !!(chatSideDrawerEl && !chatSideDrawerEl.classList.contains('hidden'))
}
function setChatDrawerOpen(open) {
if (!chatSideDrawerEl) return
chatSideDrawerEl.classList.toggle('hidden', !open)
chatSideDrawerEl.setAttribute('aria-hidden', open ? 'false' : 'true')
if (chatSideDrawerBtn) chatSideDrawerBtn.setAttribute('aria-expanded', open ? 'true' : 'false')
if (open) refreshChatSideDrawer()
}
function refreshChatSideDrawer() {
if (!isChatDrawerOpen() || !chatSideDrawerBodyEl) return
const api = window.__dshChatSideDrawer
if (!api || typeof api.renderChatSideDrawer !== 'function') return
const meta = state.activeSessionId ? state.sessions.get(state.activeSessionId) : null
const events = (meta && Array.isArray(meta.cachedEvents)) ? meta.cachedEvents : []
api.renderChatSideDrawer(chatSideDrawerBodyEl, {
sessionId: state.activeSessionId || '',
model: meta && (meta.model || (meta.header && meta.header.model)) || '',
events,
selectedTurnId: null,
onSelect(row) {
if (!row || !row.turnId) return
const target = streamEl && streamEl.querySelector(`[data-turn-id="${row.turnId}"]`)
if (target && typeof target.scrollIntoView === 'function') {
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
},
})
}
function refreshChatSideDrawerIfOpen() { if (isChatDrawerOpen()) refreshChatSideDrawer() }
if (chatSideDrawerBtn) {
chatSideDrawerBtn.addEventListener('click', () => setChatDrawerOpen(!isChatDrawerOpen()))
}
if (chatSideDrawerCloseBtn) {
chatSideDrawerCloseBtn.addEventListener('click', () => setChatDrawerOpen(false))
}
function setChatView(view) {
if (!chatPaneEl) return
const v = view === 'graph' ? 'graph' : 'list'
chatPaneEl.dataset.chatView = v
for (const btn of chatViewTabEls) {
const active = btn.dataset.chatViewTab === v
btn.classList.toggle('active', active)
btn.setAttribute('aria-selected', active ? 'true' : 'false')
}
if (v === 'graph') refreshSessionGraph()
}
function refreshSessionGraph() {
if (!chatSessionGraphEl) return
const api = window.__dshChatSessionGraph
if (!api || typeof api.renderSessionGraph !== 'function') return
const meta = state.activeSessionId ? state.sessions.get(state.activeSessionId) : null
const events = (meta && Array.isArray(meta.cachedEvents)) ? meta.cachedEvents : []
api.renderSessionGraph(chatSessionGraphEl, {
sessionId: state.activeSessionId || '',
events,
onSelect(node) {
if (!node || !node.turnId) return
// Jump to the turn in the List view and focus it.
setChatView('list')
const target = streamEl && streamEl.querySelector(`[data-turn-id="${node.turnId}"]`)
if (target && typeof target.scrollIntoView === 'function') {
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
},
})
}
function refreshSessionGraphIfActive() {
if (chatPaneEl && chatPaneEl.dataset.chatView === 'graph') refreshSessionGraph()
}
for (const btn of chatViewTabEls) {
btn.addEventListener('click', () => setChatView(btn.dataset.chatViewTab))
}
function formatTokens(n) {
if (!Number.isFinite(n)) return '—'
if (n < 1000) return String(n)
@@ -4416,6 +4510,17 @@ function onSessionEvent(sessionId, event) {
// §1.3 A/B classifier gate: track turn count so hooks-*
// demotes from family A (SessionStart) to family B on later turns.
meta.turnCount = (meta.turnCount || 0) + 1
// feat/chat-triple-view: once the current turn container exists in the
// stream, stamp its turnId from the start event so the drawer/graph
// can address it. Same {turnId, turn_id, fallback t{n}} shape as
// chat-side-drawer.deriveTurnRows so both surfaces agree.
const startDataForTurnId = event.data || {}
const derivedTurnId = startDataForTurnId.turnId || startDataForTurnId.turn_id
|| `t${meta.turnCount - 1}`
if (sessionId === state.activeSessionId && state.currentTurn && state.currentTurn.section) {
state.currentTurn.section.dataset.turnId = derivedTurnId
state.currentTurn.section.dataset.turnIndex = String(meta.turnCount - 1)
}
// Ticket B §B-4 (2026-07-16): a new turn starting means the previous
// error/cancel is no longer the current state — drop the derived
// lastError so the row stops rendering ✕ interrupted while a fresh
@@ -4467,6 +4572,12 @@ function onSessionEvent(sessionId, event) {
// list so inject/compact/recall events stream in live alongside the
// message bubbles. No-op when the drawer is closed.
refreshRailIfOpen()
// feat/chat-triple-view: keep the right-side detail drawer + Session Graph
// in sync with the same event tick. Both no-op when their surface is
// hidden, so this is cheap when the user hasn't opened the drawer /
// switched to Graph yet.
refreshChatSideDrawerIfOpen()
refreshSessionGraphIfActive()
// §2.3 (batch 6) template triggers: pure module decides whether the event
// qualifies for a template card (T2 error recovery / T4 artifact preview /

View File

@@ -40,6 +40,18 @@
--accent-strong: #1d4ed8;
--accent-soft: rgba(37, 99, 235, 0.12);
/* density-spec §7: turn edge tokens read across the chat stream.
* --turn-action-edge paints the left rail of a sealed action turn
* (reasoning + text + tool-call cluster reads as one blue-edged block).
* --turn-output-edge paints the left rail of a tool-result row so the
* result reads as grey-edged echo below the action's blue.
* --turn-interrupt-marker paints the small orange sliver that appears
* at the head of an interruption row so a reader tracking the rail
* sees the seam. Kept as brand-neutral defaults; swap here to reskin. */
--turn-action-edge: var(--accent);
--turn-output-edge: var(--border-strong);
--turn-interrupt-marker: #ea580c;
/* Semantic */
--ok: #16a34a;
--ok-soft: rgba(22, 163, 74, 0.12);
@@ -143,6 +155,9 @@
--accent: #4f8bff;
--accent-strong: #6ea3ff;
--accent-soft: rgba(79, 139, 255, 0.18);
--turn-action-edge: var(--accent);
--turn-output-edge: var(--border-strong);
--turn-interrupt-marker: #f97316;
--user-bubble: #22262f;
--shadow-1: 0 1px 2px rgba(0, 0, 0, 0.4);
--shadow-2: 0 2px 8px rgba(0, 0, 0, 0.5);
@@ -168,6 +183,9 @@
--accent: #4f8bff;
--accent-strong: #6ea3ff;
--accent-soft: rgba(79, 139, 255, 0.18);
--turn-action-edge: var(--accent);
--turn-output-edge: var(--border-strong);
--turn-interrupt-marker: #f97316;
--user-bubble: #22262f;
}
* { box-sizing: border-box; }
@@ -6676,13 +6694,41 @@ textarea:focus-visible {
.assistant-turn {
display: block;
border: 1px solid transparent; /* no card outline by default */
border-left: 2px solid var(--border);
/* density-spec §7 tokens: sealed action turn wears the accent rail so a
* reasoning + text + tool-call cluster reads as one blue-edged block. */
border-left: 2px solid var(--turn-action-edge);
padding: 0 0 0 12px;
margin: 8px 0;
}
.assistant-turn[data-turn-status="streaming"] {
border-left-color: var(--accent-soft);
}
/* Grey-edge output rows: tool results and any turn-child painted with
* .turn-output-edge (renderer stamps it on rows that echo external output
* back into the turn). Reads as a muted echo below the blue action edge. */
.assistant-turn > .turn-body > .tool-result-row {
border-left: 2px solid var(--turn-output-edge);
margin-left: -14px; /* align inner edge with turn rail */
padding-left: 12px;
}
.assistant-turn > .turn-body > .turn-output-edge {
border-left: 2px solid var(--turn-output-edge);
margin-left: -14px;
padding-left: 12px;
}
/* Interruption marker: an orange sliver planted at the head of a row that
* broke the streaming turn (user cancel, guard reject). Two-pixel high
* dash on the left rail so a reader scanning the edge column sees the
* seam without adding a full-row banner. */
.assistant-turn .turn-interrupt-marker {
display: block;
width: 4px;
height: 12px;
background: var(--turn-interrupt-marker);
border-radius: 2px;
margin: 0 6px 0 -16px;
flex: 0 0 auto;
}
.assistant-turn > .turn-rule {
height: 1px; background: transparent; margin: 0 0 6px 0;
}
@@ -11579,3 +11625,228 @@ details.devtools-row[open] > .devtools-row-summary::before { transform: rotate(9
/* default display (this one is `display: flex`) — behaviourally identical */
/* once JS finishes booting, but robust against the first-paint race. */
.onboarding[hidden] { display: none !important; }
/* -- Chat triple view: side drawer + view switcher + session graph -------
* lane-chat-triple. The Chat pane grows a right-side fold-out drawer
* (turn/session metadata + history list) and a top-level view switcher
* that toggles the main stream between List (default) and Graph (a
* DAG over the session's turn sequence, drawn as pure SVG). */
/* View switcher tab strip. Sits between the header and the stream on the
* Chat pane. Two buttons, active one carries the accent underline. */
.chat-view-tabs {
display: flex;
gap: 4px;
padding: 6px 20px 0 20px;
border-bottom: 1px solid var(--divider);
background: var(--bg);
flex: 0 0 auto;
}
.chat-view-tab {
background: transparent;
border: 0;
border-bottom: 2px solid transparent;
padding: 6px 10px;
color: var(--muted);
font-size: 12.5px;
cursor: pointer;
font-family: inherit;
}
.chat-view-tab:hover { color: var(--text); }
.chat-view-tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
/* Chat header gets an icon button that toggles the side drawer. */
.chat-side-drawer-toggle {
display: inline-flex;
align-items: center;
gap: 4px;
}
.chat-side-drawer-toggle[aria-expanded="true"] {
color: var(--accent);
border-color: var(--accent);
}
/* Right-side drawer: fixed 320px width, slides in from the right edge of
* the Chat pane. Hidden by default via [hidden]; script toggles that. */
.chat-side-drawer {
position: absolute;
top: var(--header-h);
right: 0;
bottom: 0;
width: 320px;
background: var(--bg-elev);
border-left: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
z-index: 20;
box-shadow: var(--shadow-2);
}
.chat-side-drawer.hidden { display: none; }
.chat-side-drawer-head {
padding: 10px 14px;
border-bottom: 1px solid var(--divider);
display: flex;
align-items: center;
gap: 8px;
}
.chat-side-drawer-title {
font-size: 13px;
font-weight: 600;
color: var(--text);
flex: 1;
}
.chat-side-drawer-close {
background: transparent;
border: 0;
color: var(--muted);
font-size: 18px;
cursor: pointer;
padding: 0 4px;
line-height: 1;
}
.chat-side-drawer-close:hover { color: var(--text); }
.chat-side-drawer-body {
overflow-y: auto;
padding: 8px 0;
flex: 1;
}
.chat-side-drawer-section {
padding: 8px 14px;
border-bottom: 1px solid var(--divider);
}
.chat-side-drawer-section:last-child { border-bottom: 0; }
.chat-side-drawer-section-title {
font-size: 11px;
font-weight: 600;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 6px;
}
.chat-side-drawer-meta {
display: grid;
grid-template-columns: 88px 1fr;
gap: 4px 8px;
font-size: 12px;
}
.chat-side-drawer-meta dt {
color: var(--muted);
font-weight: 400;
margin: 0;
}
.chat-side-drawer-meta dd {
color: var(--text);
font-family: var(--mono);
font-size: 11.5px;
margin: 0;
overflow-wrap: anywhere;
}
.chat-side-drawer-history {
list-style: none;
margin: 0;
padding: 0;
}
.chat-side-drawer-history-item {
padding: 6px 8px;
border-radius: 4px;
cursor: pointer;
display: flex;
gap: 6px;
align-items: baseline;
font-size: 12px;
color: var(--text);
}
.chat-side-drawer-history-item:hover {
background: var(--surface-hover);
}
.chat-side-drawer-history-item.active {
background: var(--accent-soft);
}
.chat-side-drawer-history-role {
color: var(--muted);
font-family: var(--mono);
font-size: 10.5px;
min-width: 42px;
flex: 0 0 auto;
text-transform: uppercase;
}
.chat-side-drawer-history-summary {
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.chat-side-drawer-empty {
color: var(--muted);
font-size: 12px;
padding: 8px 4px;
}
/* Session Graph view. Hidden when the List tab is active; when Graph is
* active the stream container gets [data-chat-view="graph"] and the
* inline .chat-session-graph replaces its content area. */
.chat-session-graph {
padding: 20px;
overflow: auto;
height: 100%;
}
.chat-session-graph[hidden] { display: none; }
.chat-session-graph-empty {
color: var(--muted);
font-size: 13px;
text-align: center;
padding: 40px 20px;
}
.chat-session-graph svg {
display: block;
max-width: 100%;
}
.chat-session-graph .graph-node {
cursor: pointer;
}
.chat-session-graph .graph-node circle {
fill: var(--bg-elev);
stroke: var(--turn-action-edge);
stroke-width: 2;
}
.chat-session-graph .graph-node.node-user circle {
stroke: var(--muted);
}
.chat-session-graph .graph-node.node-interrupt circle {
stroke: var(--turn-interrupt-marker);
}
.chat-session-graph .graph-node.active circle {
fill: var(--accent-soft);
}
.chat-session-graph .graph-node text {
fill: var(--text);
font-size: 11px;
font-family: var(--mono);
}
.chat-session-graph .graph-edge {
stroke: var(--border-strong);
stroke-width: 1.5;
fill: none;
}
.chat-session-graph .graph-edge.edge-fork {
stroke-dasharray: 4 3;
}
.chat-session-graph .graph-edge.edge-interrupt {
stroke: var(--turn-interrupt-marker);
stroke-width: 2;
}
/* Stream shows only for [data-chat-view="list"], graph only for
* [data-chat-view="graph"]. The pane is the parent that carries the
* data attribute so a single toggle switches both children. */
.pane[data-pane="chat"][data-chat-view="graph"] .stream { display: none; }
.pane[data-pane="chat"][data-chat-view="list"] .chat-session-graph { display: none; }
/* Give the pane a positioning context so the absolute drawer anchors
* inside it, not against the viewport root. */
.pane[data-pane="chat"] { position: relative; }