Files
apes-sidebar/src/client/sidechat-transcript.ts
T
Menghuan1918 b7af498427 perf: stabilize renders (transcript row reuse, tree Sets, shared + options, batched drags)
SideChat transcript: every 2s poll rebuilt ALL row objects, so every
row (markdown parse + DOMPurify included) re-rendered even when the
log had not changed. transcriptRows now takes the previous poll's
rows and re-adopts the position-aligned rows whose content is
identical (reuseRows) — only the changed tail rebuilds. The view
feeds the memoized array back through a ref; unit tests pin the
identity contract (unchanged rows keep object identity, changed rows
and everything past the change rebuild, tool-result landings never
re-adopt).

FileTree: expanded/revealed membership was an includes() scan per
rendered row (O(rows × expanded) per render on deep trees); one Set
per render keeps it O(1) per row.

Shell: buildNewTabOptions was inlined in the two Workbench JSX
sites — two fresh arrays per render (drag state, resizes) re-rendered
every LeafView's + affordance. One shared memo now.

DiffPane resize / TerminalView ResizeObserver: pointer and resize
streams fired a setState / glyph-measuring fit() per event; both ride
per-frame batching now (frame-batcher in the core, a local rAF gate
in the lazy terminal chunk). SessionLens sizes each row's content
with a Blob (a UTF-8 encode of the whole edit) — that moved from
per-row-per-render into one memoized pass per changed fold.
2026-09-01 16:01:34 +08:00

543 lines
25 KiB
TypeScript

/**
* Side Chat transcript mapping (browser half): turns a thread child's own
* events (`sidechat.events` — the plugin route, which reads the log without
* activating the child and cuts the inherited seed host-side) into compact
* display rows.
*
* A thread child's log starts with the ENTIRE inherited parent log as its
* fork seed. The route already cuts everything up to the LAST
* `session/end-seed` marker; the mapping re-applies the same cut so any
* seed event that somehow crosses the wire still never renders, and maps
* context injections (the "Side conversation boundary" prompt,
* plugin-sourced context) onto a collapsible injection row, so the view
* shows only the thread's own conversation.
*
* Live streaming: `assistant/message` events only land when a step
* completes, but `assistant/chunk` events stream token-level text and
* 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
* rows). */
export type SidechatTranscriptRow =
| { kind: 'user'; seq: number; text: string }
/** A context injection (the side boundary prompt + the parked in-progress
* snapshot, or any plugin-sourced context): rendered as one collapsible
* row, never as a user bubble. */
| { kind: 'injection'; seq: number; text: string }
/** `settled` distinguishes an assembled message from a still-streaming
* chunk accumulation (streaming rows are superseded by the settle). */
| { kind: 'assistant'; seq: number; text: string; settled: boolean }
| { kind: 'reasoning'; seq: number; text: string; settled: boolean }
| {
kind: 'tool'
seq: number
name: string
failed: boolean
/** Raw arguments JSON as the model produced it. */
args?: string
/** Plain text of the paired result. */
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. */
export function blockText(content: readonly unknown[]): string {
const parts: string[] = []
for (const block of content) {
if (block === null || typeof block !== 'object') continue
const candidate = block as { type?: unknown; text?: unknown }
if (candidate.type === 'text' && typeof candidate.text === 'string') {
parts.push(candidate.text)
}
}
const text = parts.join('\n\n')
return text === '' ? '…' : text
}
/** Cap for a tool row's one-line argument summary (display only). */
const ARGS_SUMMARY_MAX = 80
/** The most identifying argument keys, in priority order (bash's command,
* fs tools' paths, search's pattern, …). */
const ARGS_SUMMARY_KEYS = ['command', 'file_path', 'path', 'pattern', 'query', 'url', 'prompt'] as const
function flatTruncate(text: string): string {
const flat = text.replace(/\s+/g, ' ').trim()
return flat.length > ARGS_SUMMARY_MAX ? `${flat.slice(0, ARGS_SUMMARY_MAX - 1)}…` : flat
}
/**
* One-line summary of a tool call's raw arguments JSON for the collapsed
* row: the first identifying string field when the JSON parses, else the
* flattened raw text; empty when there is nothing worth showing.
*/
export function toolArgsSummary(args: string | undefined): string {
if (args === undefined) return ''
try {
const parsed = JSON.parse(args) as Record<string, unknown> | null
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
for (const key of ARGS_SUMMARY_KEYS) {
const value = parsed[key]
if (typeof value === 'string' && value.trim() !== '') return flatTruncate(value)
}
}
} catch {
// Raw text fallthrough.
}
return flatTruncate(args)
}
/** The plain text of a tool/result message (text blocks inside its
* `tool-result` content block). */
function resultTextOf(data: Record<string, unknown>): string {
const message = data.message as { content?: unknown } | undefined
const content = message?.content
if (!Array.isArray(content)) return ''
const parts: string[] = []
for (const block of content) {
if (block === null || typeof block !== 'object') continue
const candidate = block as { type?: unknown; content?: unknown }
if (candidate.type !== 'tool-result') continue
const inner = candidate.content
if (!Array.isArray(inner)) continue
for (const item of inner) {
if (item === null || typeof item !== 'object') continue
const textItem = item as { type?: unknown; text?: unknown }
if (textItem.type === 'text' && typeof textItem.text === 'string') {
parts.push(textItem.text)
}
}
}
return parts.join('\n')
}
/** Index of the last `session/end-seed` event (fork seed marker), or -1. */
function lastSeedEnd(events: readonly { type: string }[]): number {
for (let index = events.length - 1; index >= 0; index--) {
if (events[index]?.type === 'session/end-seed') return index
}
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
* injections map onto a collapsible injection row, `assistant/chunk`
* deltas accumulate into streaming rows per (turn, step, block) and are
* superseded by the assembled `assistant/message`, and tool invocations
* render one expandable line each (arguments, paired result text, failure
* marker; a still-executing call is marked until its result lands).
* @param entries - history rows (event + host-computed view) in seq order.
* @returns display rows in log order.
*/
export function transcriptRows(entries: readonly SidebarHistoryEntry[], prev?: readonly SidechatTranscriptRow[]): SidechatTranscriptRow[] {
const events = entries.map(entry => entry.event)
const seedEnd = lastSeedEnd(events)
const rows: SidechatTranscriptRow[] = []
/** (turn, step, index, kind) key → index of its accumulating stream row. */
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
// context) collapse into an injection row; genuine user messages —
// including the FIRST one, which the host now delivers as its own
// event — render as user rows.
if (isContextInjectionMessage(data)) {
const source = data.source as { kind?: unknown } | undefined
// Threads logged BEFORE the host split carry boundary(+snapshot)+
// question in ONE 'user' message. The boundary prompt is a known
// constant, so the message splits THERE: the injection row keeps
// the prompt, the remainder (snapshot + question if any — pure
// question in the common case) renders as the user's real message.
if (source?.kind === 'user' && text.startsWith(`${SIDE_BOUNDARY_PROMPT}\n\n`)) {
rows.push({ kind: 'injection', seq: event.seq, text: SIDE_BOUNDARY_PROMPT })
const body = text.slice(SIDE_BOUNDARY_PROMPT.length + 2)
if (body !== '') rows.push({ kind: 'user', seq: event.seq, text: body })
break
}
rows.push({ kind: 'injection', seq: event.seq, text })
break
}
rows.push({ kind: 'user', seq: event.seq, text })
break
}
case 'assistant/chunk': {
const chunk = data.chunk as { type?: unknown; text?: unknown } | undefined
if (chunk === null || typeof chunk !== 'object') break
const kind = chunk.type === 'text-delta' ? 'assistant' : chunk.type === 'reasoning-delta' ? 'reasoning' : null
if (kind === null || typeof chunk.text !== 'string' || chunk.text === '') break
const turn = data.turn
const step = data.step
const blockIndex = (chunk as { index?: unknown }).index
const key = `${String(turn)}:${String(step)}:${String(blockIndex)}:${kind}`
const existing = streamRows.get(key)
if (existing !== undefined) {
const row = rows[existing]
if (row !== undefined && row.kind === kind && !row.settled) {
rows[existing] = { ...row, text: row.text + chunk.text }
}
} else {
streamRows.set(key, rows.length)
rows.push({ kind, seq: event.seq, text: chunk.text, settled: false })
}
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))
.map(([, rowIndex]) => rowIndex)
for (const key of [...streamRows.keys()]) {
if (key.startsWith(prefix)) streamRows.delete(key)
}
const content = Array.isArray((data.message as { content?: unknown } | undefined)?.content)
? (data.message as { content: readonly unknown[] }).content
: []
const settled: SidechatTranscriptRow[] = content.flatMap((block): SidechatTranscriptRow[] => {
if (block === null || typeof block !== 'object') return []
const candidate = block as { type?: unknown; text?: unknown }
if (candidate.type === 'reasoning' && typeof candidate.text === 'string' && candidate.text !== '') {
return [{ kind: 'reasoning', seq: event.seq, text: candidate.text, settled: true }]
}
if (candidate.type === 'text' && typeof candidate.text === 'string' && candidate.text !== '') {
return [{ kind: 'assistant', seq: event.seq, text: candidate.text, settled: true }]
}
return []
})
if (streamed.length === 0) rows.push(...settled)
else rows.splice(Math.min(...streamed), streamed.length, ...settled)
break
}
case 'tool/call': {
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, ...(card !== undefined ? { card } : {}) })
break
}
case 'tool/result': {
const source = data.message as { source?: { callId?: unknown } } | undefined
const callId = typeof source?.source?.callId === 'string' ? source.source.callId : undefined
const rowIndex = callId === undefined ? undefined : callRows.get(callId)
const failed = data.error !== undefined
const resultText = resultTextOf(data)
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 !== '') {
// Orphan result (no call row in the window): surface it so the row
// stays informative and expandable.
rows.push({
kind: 'tool',
seq: event.seq,
name: callId === undefined ? 'tool' : `tool:${callId.slice(0, 8)}`,
failed,
resultText: resultText === '' ? undefined : resultText,
})
}
break
}
default: {
break
}
}
}
return reuseRows(rows, prev)
}
/** Whether two rows carry identical display content (identity fields plus
* every rendered field of their kind). */
function rowsEqual(a: SidechatTranscriptRow, b: SidechatTranscriptRow): boolean {
if (a.kind !== b.kind || a.seq !== b.seq) return false
switch (a.kind) {
case 'user':
case 'injection': {
const other = b as typeof a
return a.text === other.text
}
case 'assistant':
case 'reasoning': {
const other = b as typeof a
return a.text === other.text && a.settled === other.settled
}
case 'tool': {
// The card is NOT compared by value: its content is a pure function of
// the call-time args and the tool/result landing, and every transition
// (output/exit arriving) flips `executing`/`failed`/`resultText` —
// compared here — so a card whose content changed never slips through
// an equal compare, and an equal compare re-adopting the OLD card
// object (identical content, stale reference) only helps React skip.
const other = b as typeof a
return a.name === other.name && a.failed === other.failed && a.args === other.args
&& a.resultText === other.resultText && a.executing === other.executing
}
case 'turnSummary': {
const other = b as typeof a
return a.inputTokens === other.inputTokens && a.outputTokens === other.outputTokens
&& a.durationMs === other.durationMs
}
}
}
/** Re-adopt the PREVIOUS poll's row objects wherever the content is
* unchanged (position-aligned, append-mostly): settled rows keep their
* object identity across the 2s polls, so React's reconciler and the
* markdown/DOMPurify caches downstream skip them instead of re-rendering
* the whole transcript every poll. Comparing the strings is far cheaper
* than what a fresh reference costs the row below. Rows past the first
* mismatch (an inserted/superseded streaming row) rebuild as usual — that
* is exactly the changed tail. */
function reuseRows(rows: SidechatTranscriptRow[], prev: readonly SidechatTranscriptRow[] | undefined): SidechatTranscriptRow[] {
if (prev === undefined) return rows
const shared = Math.min(rows.length, prev.length)
for (let index = 0; index < shared; index++) {
const before = prev[index]
const after = rows[index]
if (before === undefined || after === undefined) continue
if (rowsEqual(before, after)) rows[index] = before
}
return rows
}