feat(sidechat): main-conversation Blocks, turn usage tail, reconnect banner

Rendering-layer upgrade for the Side Chat transcript (data already rides
the raw events; the mapping just stopped discarding it):

- Tool rows with a derivable structure now render the host's
  ui-primitives atoms instead of a generic double <pre>: bash calls map
  onto TerminalBlock (command from call args, output + exit pill
  recovered from the result's trailing exit marker), edit/write map onto
  DiffBlock (call-time literal replacement, refined by the result meta's
  contextual hunks), read maps onto ReadBlock (line-window meta, same
  semantic narrowing the host applies). Every narrowing declines to the
  generic row on malformed input.
- turn/end now emits a turnSummary row: token usage aggregated from
  assistant/message.usage (output summed across steps, input from the
  last request) plus the envelope-time wall duration, printed with the
  main conversation's formatTokens/formatDurationMs cadence.
- The client entry injects 'connection' (DSH 0.1.2-alpha.2+) and the
  thread view shows the host's ConnectionIndicator while the Remote
  transport is down; the reconnect control calls reconnect() and a
  disconnected -> connected transition pulls the transcript immediately
  instead of waiting for the next poll tick.

19 new locale keys propagated across zh/en and all 19 third-language
dictionaries; unit coverage in sidechat-transcript.spec and a new
sidechat-view render spec (jsdom). Real-host mount smoke: 14/14.
This commit is contained in:
Menghuan1918
2026-08-31 23:28:20 +08:00
parent d70db8fb02
commit 561ad66071
27 changed files with 1175 additions and 17 deletions
+218 -1
View File
@@ -17,9 +17,29 @@
* reasoning deltas. The mapping accumulates both per block and supersedes
* them with the assembled message once it lands (settled rows).
*/
import type { DiffHunk, ReadBlockLine } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SidebarHistoryEntry } from '../context-types.ts'
import { isContextInjectionMessage, SIDE_BOUNDARY_PROMPT } from '../sidechat-core.ts'
/**
* Structured render payload for a tool row that maps onto one of the host's
* ui-primitives Blocks (the same atoms the main conversation renders). Derived
* defensively from raw `tool/call` arguments and `tool/result` `meta` — the
* producing tool owns the meta shape, so every field is narrowed and any
* malformed input silently falls back to the generic text row.
*/
export type SidechatToolCard =
| {
type: 'terminal'
command: string
cwd?: string
output?: string
exitCode?: number
signal?: string
}
| { type: 'diff'; diffs: DiffHunk[] }
| { type: 'read'; label: string; lines: ReadBlockLine[]; totalLines: number; lang?: string }
/** One compact transcript row rendered in the thread view. `seq` is the
* source event's log sequence — stable row identity for React keys across
* polls (streaming caches ride the key, so window slides must not re-key
@@ -45,7 +65,31 @@ export type SidechatTranscriptRow =
resultText?: string
/** True while the call's result has not landed yet. */
executing?: boolean
/** Structured render payload (host Block atoms); absent = generic row. */
card?: SidechatToolCard
}
/** One turn's tail metrics, emitted at `turn/end`: the turn's token usage
* (aggregated from `assistant/message.usage`) and wall duration (envelope
* time delta from `turn/start`), whichever are computable. */
| { kind: 'turnSummary'; seq: number; inputTokens?: number; outputTokens?: number; durationMs?: number }
/** Compact token count the way the main conversation prints usage
* (517 / 12.2K / 1.2M — the host's own formatter is not exported). */
export function formatTokens(n: number): string {
const scaled = (v: number): string => (v >= 100 ? String(Math.round(v)) : String(Math.round(v * 10) / 10))
if (n < 1_000) return String(n)
if (n < 1_000_000) return `${scaled(n / 1_000)}K`
return `${scaled(n / 1_000_000)}M`
}
/** Compact duration the way the main conversation prints run times
* (45.2s / 2m42s — sub-minute keeps one decimal). */
export function formatDurationMs(ms: number): string {
const seconds = ms / 1_000
if (seconds < 60) return `${Math.round(seconds * 10) / 10}s`
const whole = Math.round(seconds)
return `${Math.floor(whole / 60)}m${whole % 60}s`
}
/** Extract the visible text of a content-block list (`text` blocks verbatim,
* joined by blank lines); empty reads `…` so rows never render blank. */
@@ -127,6 +171,131 @@ function lastSeedEnd(events: readonly { type: string }[]): number {
return -1
}
/** Parse the tool's raw arguments JSON to an object, or undefined. */
function parseArgsObject(args: string | undefined): Record<string, unknown> | undefined {
if (args === undefined) return undefined
try {
const parsed = JSON.parse(args) as unknown
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined
return parsed as Record<string, unknown>
} catch {
return undefined
}
}
/**
* Call-time card from the raw `tool/call` arguments — the same literal cards
* the host's own presenters derive before any result exists: bash shows the
* command line (foreground only), edit/write show the literal replacement.
* `read` has no call-time window (its structure only exists in the result).
*/
function callCard(name: string, args: string | undefined): SidechatToolCard | undefined {
const parsed = parseArgsObject(args)
if (parsed === undefined) return undefined
if (name === 'bash') {
const command = typeof parsed.command === 'string' && parsed.command !== '' ? parsed.command : undefined
if (command === undefined || parsed.run_in_background === true) return undefined
const cwd = typeof parsed.workdir === 'string' && parsed.workdir !== '' ? parsed.workdir : undefined
return { type: 'terminal', command, ...(cwd !== undefined ? { cwd } : {}) }
}
if (name === 'edit' || name === 'write') {
const path = typeof parsed.file_path === 'string' && parsed.file_path !== '' ? parsed.file_path : undefined
if (path === undefined) return undefined
if (name === 'edit') {
const oldText = typeof parsed.old_string === 'string' ? parsed.old_string : ''
const newText = typeof parsed.new_string === 'string' ? parsed.new_string : ''
return { type: 'diff', diffs: [{ path, oldText: oldText === '' ? null : oldText, newText }] }
}
const newText = typeof parsed.content === 'string' ? parsed.content : ''
return { type: 'diff', diffs: [{ path, oldText: null, newText }] }
}
return undefined
}
/** The `tool/result` `meta` as an object, or undefined (opaque tool payload). */
function metaObject(data: Record<string, unknown>): Record<string, unknown> | undefined {
const meta = data.meta
if (meta === null || typeof meta !== 'object' || Array.isArray(meta)) return undefined
return meta as Record<string, unknown>
}
/** Narrow edit/write's contextual-diff meta (`{ diffs: FileDiff[] }`) to
* DiffHunk[], declining on any malformed entry. */
function diffCardFromMeta(meta: Record<string, unknown>): SidechatToolCard | undefined {
const diffs = meta.diffs
if (!Array.isArray(diffs) || diffs.length === 0) return undefined
const hunks: DiffHunk[] = []
for (const item of diffs) {
if (item === null || typeof item !== 'object' || Array.isArray(item)) return undefined
const { path, oldText, newText } = item as Record<string, unknown>
if (typeof path !== 'string' || typeof newText !== 'string') return undefined
if (oldText !== null && typeof oldText !== 'string') return undefined
hunks.push({ path, oldText, newText })
}
return { type: 'diff', diffs: hunks }
}
/** Narrow read's line-window meta (`{ path, offset, lines, totalLines, lang? }`)
* to a read card, enforcing the same semantic contract the host does: 1-based
* strictly increasing line numbers that never exceed `totalLines`. */
function readCardFromMeta(meta: Record<string, unknown>): SidechatToolCard | undefined {
const { path, offset, lines, totalLines, lang } = meta
if (typeof path !== 'string' || typeof offset !== 'number' || typeof totalLines !== 'number') return undefined
if (!Number.isInteger(offset) || offset < 1) return undefined
if (!Number.isInteger(totalLines) || totalLines < 0) return undefined
if (!Array.isArray(lines)) return undefined
const narrowed: ReadBlockLine[] = []
let previous = offset - 1
for (const line of lines) {
if (line === null || typeof line !== 'object' || Array.isArray(line)) return undefined
const candidate = line as { number?: unknown; text?: unknown }
if (typeof candidate.number !== 'number' || !Number.isInteger(candidate.number)) return undefined
if (candidate.number <= previous || candidate.number > totalLines) return undefined
if (typeof candidate.text !== 'string') return undefined
narrowed.push({ number: candidate.number, text: candidate.text })
previous = candidate.number
}
if (lang !== undefined && typeof lang !== 'string') return undefined
return { type: 'read', label: path, lines: narrowed, totalLines, ...(lang !== undefined ? { lang } : {}) }
}
/** The bash result's trailing exit markers (`[exit code: N]` /
* `[killed by signal: X]` — the model-facing text the tool appends), stripped
* the same way the host's parseExitStatus recovers the exit pill. */
const EXIT_SIGNAL_RE = /\n\[killed by signal: ([^\]\n]+)\]$/
const EXIT_CODE_RE = /\n\[exit code: (\d+)\]$/
/**
* Result-time card refinement: `meta`-carrying structure wins (edit/write's
* applied hunks, read's line window), bash's output gets its exit marker
* stripped into the exit pill, and a failed result always falls back to the
* generic row (the host's isError path renders generic output too).
*/
function resultCard(
name: string,
previous: SidechatToolCard | undefined,
data: Record<string, unknown>,
resultText: string,
): SidechatToolCard | undefined {
if (name === 'bash') {
if (previous === undefined || previous.type !== 'terminal' || resultText === '') return previous
const signal = EXIT_SIGNAL_RE.exec(resultText)
if (signal?.[1] !== undefined) {
return { ...previous, output: resultText.slice(0, signal.index), exitCode: undefined, signal: signal[1] }
}
const exit = EXIT_CODE_RE.exec(resultText)
if (exit?.[1] !== undefined) {
return { ...previous, output: resultText.slice(0, exit.index), exitCode: Number(exit[1]), signal: undefined }
}
return { ...previous, output: resultText, exitCode: 0, signal: undefined }
}
const meta = metaObject(data)
if (meta === undefined) return previous
if (name === 'edit' || name === 'write') return diffCardFromMeta(meta) ?? previous
if (name === 'read') return readCardFromMeta(meta)
return previous
}
/**
* Map a thread child's history rows onto compact transcript rows: the
* inherited fork seed is cut at the last `session/end-seed`, context
@@ -146,12 +315,40 @@ export function transcriptRows(entries: readonly SidebarHistoryEntry[]): Sidecha
const streamRows = new Map<string, number>()
/** tool callId → index of its tool row in `rows` (result pairing). */
const callRows = new Map<string, number>()
/** turn → envelope time of its `turn/start` (turn-tail duration basis). */
const turnStarts = new Map<number, number>()
/** turn → usage aggregate: output accumulates across steps, input takes the
* last request's prompt size (earlier steps' input is mostly the same
* context re-sent, so summing would double-count). */
const turnUsage = new Map<number, { inputTokens: number; outputTokens: number }>()
for (let index = 0; index < events.length; index++) {
if (index <= seedEnd) continue
const event = events[index]
if (event === undefined) continue
const data = event.data as Record<string, unknown>
switch (event.type) {
case 'turn/start': {
const turn = data.turn
if (typeof turn === 'number' && Number.isInteger(turn)) turnStarts.set(turn, event.time)
break
}
case 'turn/end': {
const turn = data.turn
if (typeof turn !== 'number' || !Number.isInteger(turn)) break
const start = turnStarts.get(turn)
turnStarts.delete(turn)
const usage = turnUsage.get(turn)
turnUsage.delete(turn)
const durationMs = start !== undefined ? Math.max(0, event.time - start) : undefined
if (usage === undefined && durationMs === undefined) break
rows.push({
kind: 'turnSummary',
seq: event.seq,
...(usage !== undefined ? { inputTokens: usage.inputTokens, outputTokens: usage.outputTokens } : {}),
...(durationMs !== undefined ? { durationMs } : {}),
})
break
}
case 'user/message': {
const text = blockText(Array.isArray(data.content) ? data.content : [])
// Context injections (the boundary prompt + snapshot, plugin-sourced
@@ -199,6 +396,19 @@ export function transcriptRows(entries: readonly SidebarHistoryEntry[]): Sidecha
break
}
case 'assistant/message': {
// Turn-tail usage: each assembled message carries its step's token
// accounting (absent when the adapter reported none).
const usageTurn = data.turn
if (typeof usageTurn === 'number' && Number.isInteger(usageTurn)) {
const usage = data.usage as { inputTokens?: unknown; outputTokens?: unknown } | undefined
const input = typeof usage?.inputTokens === 'number' ? usage.inputTokens : undefined
const output = typeof usage?.outputTokens === 'number' ? usage.outputTokens : undefined
if (input !== undefined && output !== undefined) {
const aggregate = turnUsage.get(usageTurn)
if (aggregate === undefined) turnUsage.set(usageTurn, { inputTokens: input, outputTokens: output })
else turnUsage.set(usageTurn, { inputTokens: input, outputTokens: aggregate.outputTokens + output })
}
}
const prefix = `${String(data.turn)}:${String(data.step)}:`
const streamed = [...streamRows.entries()]
.filter(([key]) => key.startsWith(prefix))
@@ -228,9 +438,10 @@ export function transcriptRows(entries: readonly SidebarHistoryEntry[]): Sidecha
const callId = data.callId
const name = typeof data.name === 'string' ? data.name : 'tool'
const args = typeof data.arguments === 'string' ? data.arguments : undefined
const card = callCard(name, args)
const rowIndex = rows.length
if (typeof callId === 'string') callRows.set(callId, rowIndex)
rows.push({ kind: 'tool', seq: event.seq, name, failed: false, args, executing: true })
rows.push({ kind: 'tool', seq: event.seq, name, failed: false, args, executing: true, ...(card !== undefined ? { card } : {}) })
break
}
case 'tool/result': {
@@ -242,11 +453,17 @@ export function transcriptRows(entries: readonly SidebarHistoryEntry[]): Sidecha
if (rowIndex !== undefined) {
const row = rows[rowIndex]
if (row !== undefined && row.kind === 'tool') {
// Failed results render the generic row (the host's isError path
// skips its structured cards too); otherwise the result's own
// structure — meta hunks/windows, bash's exit marker — refines or
// replaces the call-time literal card.
const card = failed ? undefined : resultCard(row.name, row.card, data, resultText)
rows[rowIndex] = {
...row,
failed: row.failed || failed,
resultText: resultText === '' ? row.resultText : resultText,
executing: false,
...(card !== undefined ? { card } : { card: undefined }),
}
}
} else if (failed || resultText !== '') {