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
+189 -1
View File
@@ -8,7 +8,13 @@
import { describe, expect, it } from 'vitest'
import type { SidebarHistoryEntry, SidebarSessionEvent } from '../src/context-types.ts'
import { SIDE_BOUNDARY_PREFIX, SIDE_BOUNDARY_PROMPT, SIDE_INJECTION_PLUGIN } from '../src/sidechat-core.ts'
import { toolArgsSummary, transcriptRows, type SidechatTranscriptRow } from '../src/client/sidechat-transcript.ts'
import {
formatDurationMs,
formatTokens,
toolArgsSummary,
transcriptRows,
type SidechatTranscriptRow,
} from '../src/client/sidechat-transcript.ts'
/** One history entry (event + optional view). */
function entry(event: SidebarSessionEvent): SidebarHistoryEntry {
@@ -164,6 +170,188 @@ describe('transcriptRows', () => {
})
})
describe('turnSummary rows', () => {
it('emits one tail row per turn: envelope-time duration + last-input/summed-output usage', () => {
const entries = [
entry(ev('session/end-seed', 0)),
entry(ev('turn/start', 1, { turn: 1 })),
entry(ev('assistant/message', 2, { turn: 1, step: 1, message: { content: textBlocks('a') }, usage: { inputTokens: 100, outputTokens: 40 } })),
entry(ev('assistant/message', 3, { turn: 1, step: 2, message: { content: textBlocks('b') }, usage: { inputTokens: 160, outputTokens: 30 } })),
entry(ev('turn/end', 4, { turn: 1, reason: 'completed' })),
]
const rows = transcriptRows(entries)
expect(rows.at(-1)).toEqual({ kind: 'turnSummary', seq: 4, inputTokens: 160, outputTokens: 70, durationMs: 3000 })
})
it('keeps a duration-only tail without usage and a usage-only tail without a turn/start', () => {
const cancelled = [
entry(ev('session/end-seed', 0)),
entry(ev('turn/start', 2, { turn: 1 })),
entry(ev('turn/end', 7, { turn: 1, reason: 'cancelled' })),
]
expect(transcriptRows(cancelled).at(-1)).toEqual({ kind: 'turnSummary', seq: 7, durationMs: 5000 })
const seedCutStart = [
entry(ev('session/end-seed', 0)),
entry(ev('assistant/message', 2, { turn: 1, step: 1, message: { content: textBlocks('a') }, usage: { inputTokens: 10, outputTokens: 5 } })),
entry(ev('turn/end', 3, { turn: 1, reason: 'completed' })),
]
expect(transcriptRows(seedCutStart).at(-1)).toEqual({ kind: 'turnSummary', seq: 3, inputTokens: 10, outputTokens: 5 })
})
it('skips the tail entirely when nothing is computable', () => {
const entries = [
entry(ev('session/end-seed', 0)),
entry(ev('turn/end', 1, { turn: 1, reason: 'completed' })),
]
expect(transcriptRows(entries)).toEqual([])
})
})
describe('tool cards', () => {
const callRow = (seq: number, name: string, args: string): SidebarHistoryEntry =>
entry(ev('tool/call', seq, { turn: 1, step: 1, callId: `c${seq}`, name, arguments: args }))
const resultRow = (seq: number, callId: string, text: string, extra: Record<string, unknown> = {}): SidebarHistoryEntry =>
entry(ev('tool/result', seq, {
turn: 1,
step: 1,
message: {
source: { kind: 'tool', callId },
content: [{ type: 'tool-result', toolCallId: callId, content: [{ type: 'text', text }] }],
},
...extra,
}))
const toolRow = (entries: readonly SidebarHistoryEntry[]) => {
const rows = transcriptRows(entries)
expect(rows).toHaveLength(1)
return rows[0] as Extract<SidechatTranscriptRow, { kind: 'tool' }>
}
it('derives the call-time terminal card for bash and refines output/exit from the result marker', () => {
const row = toolRow([
entry(ev('session/end-seed', 0)),
callRow(1, 'bash', '{"command":"echo hi","workdir":"/w"}'),
resultRow(2, 'c1', 'hi\n[exit code: 3]'),
])
expect(row.card).toEqual({ type: 'terminal', command: 'echo hi', cwd: '/w', output: 'hi', exitCode: 3 })
})
it('recovers the signal pill and defaults a marker-less settle to exit code 0', () => {
const signaled = toolRow([
entry(ev('session/end-seed', 0)),
callRow(1, 'bash', '{"command":"x"}'),
resultRow(2, 'c1', 'boom\n[killed by signal: SIGKILL]'),
])
expect(signaled.card).toEqual({ type: 'terminal', command: 'x', output: 'boom', signal: 'SIGKILL' })
const clean = toolRow([
entry(ev('session/end-seed', 0)),
callRow(1, 'bash', '{"command":"x"}'),
resultRow(2, 'c1', 'ok'),
])
expect(clean.card).toEqual({ type: 'terminal', command: 'x', output: 'ok', exitCode: 0 })
})
it('skips the terminal card for background bash and drops it on failed results', () => {
const background = toolRow([
entry(ev('session/end-seed', 0)),
callRow(1, 'bash', '{"command":"x","run_in_background":true}'),
])
expect(background.card).toBeUndefined()
const failed = toolRow([
entry(ev('session/end-seed', 0)),
callRow(1, 'bash', '{"command":"x"}'),
resultRow(2, 'c1', 'denied', { error: { name: 'EACCES', code: 'EACCES' } }),
])
expect(failed.card).toBeUndefined()
})
it('derives literal diff cards for edit/write calls and refines from the result meta hunks', () => {
const edit = toolRow([
entry(ev('session/end-seed', 0)),
callRow(1, 'edit', '{"file_path":"/a.ts","old_string":"x","new_string":"y"}'),
])
expect(edit.card).toEqual({ type: 'diff', diffs: [{ path: '/a.ts', oldText: 'x', newText: 'y' }] })
const write = toolRow([
entry(ev('session/end-seed', 0)),
callRow(1, 'write', '{"file_path":"/new.ts","content":"body"}'),
])
expect(write.card).toEqual({ type: 'diff', diffs: [{ path: '/new.ts', oldText: null, newText: 'body' }] })
const refined = toolRow([
entry(ev('session/end-seed', 0)),
callRow(1, 'edit', '{"file_path":"/a.ts","old_string":"x","new_string":"y"}'),
resultRow(2, 'c1', 'ok', { meta: { diffs: [{ path: '/a.ts', oldText: 'x\nctx', newText: 'y\nctx' }] } }),
])
expect(refined.card).toEqual({ type: 'diff', diffs: [{ path: '/a.ts', oldText: 'x\nctx', newText: 'y\nctx' }] })
const malformed = toolRow([
entry(ev('session/end-seed', 0)),
callRow(1, 'edit', '{"file_path":"/a.ts","old_string":"x","new_string":"y"}'),
resultRow(2, 'c1', 'ok', { meta: { diffs: [{ path: 7, oldText: 'x', newText: 'y' }] } }),
])
// Malformed meta declines to the call-time literal card, never throws.
expect(malformed.card).toEqual({ type: 'diff', diffs: [{ path: '/a.ts', oldText: 'x', newText: 'y' }] })
})
it('builds the read card from the result line-window meta and declines malformed windows', () => {
const read = toolRow([
entry(ev('session/end-seed', 0)),
callRow(1, 'read', '{"path":"/a.ts","offset":2,"limit":2}'),
resultRow(2, 'c1', 'window text', {
meta: { path: '/a.ts', offset: 2, lines: [{ number: 2, text: 'b' }, { number: 3, text: 'c' }], totalLines: 5, lang: 'ts' },
}),
])
expect(read.card).toEqual({
type: 'read',
label: '/a.ts',
lines: [{ number: 2, text: 'b' }, { number: 3, text: 'c' }],
totalLines: 5,
lang: 'ts',
})
const nonMonotonic = toolRow([
entry(ev('session/end-seed', 0)),
callRow(1, 'read', '{"path":"/a.ts"}'),
resultRow(2, 'c1', 'window text', {
meta: { path: '/a.ts', offset: 1, lines: [{ number: 3, text: 'c' }, { number: 2, text: 'b' }], totalLines: 5 },
}),
])
expect(nonMonotonic.card).toBeUndefined()
})
it('leaves unknown tools on the generic row', () => {
const row = toolRow([
entry(ev('session/end-seed', 0)),
callRow(1, 'todo_write', '{"todos":[]}'),
])
expect(row.card).toBeUndefined()
})
})
describe('usage formatting', () => {
it('formatTokens mirrors the main conversation K/M compaction', () => {
expect(formatTokens(517)).toBe('517')
expect(formatTokens(999)).toBe('999')
expect(formatTokens(1_234)).toBe('1.2K')
expect(formatTokens(12_234)).toBe('12.2K')
expect(formatTokens(122_345)).toBe('122K')
expect(formatTokens(1_234_567)).toBe('1.2M')
})
it('formatDurationMs mirrors the main conversation s / m+s cadence', () => {
expect(formatDurationMs(0)).toBe('0s')
expect(formatDurationMs(500)).toBe('0.5s')
expect(formatDurationMs(45_200)).toBe('45.2s')
expect(formatDurationMs(61_000)).toBe('1m1s')
expect(formatDurationMs(162_000)).toBe('2m42s')
})
})
describe('toolArgsSummary', () => {
it('picks the most identifying string field', () => {
expect(toolArgsSummary('{"command":"ls -la","timeout":1000}')).toBe('ls -la')