diff --git a/src/client/SideChatView.module.css b/src/client/SideChatView.module.css
index 040a9cd..73d657f 100644
--- a/src/client/SideChatView.module.css
+++ b/src/client/SideChatView.module.css
@@ -280,6 +280,23 @@
border-top: 1px solid var(--dsw-alias-hairline);
}
+/* Host Block atoms (terminal/diff/read) inside a row body draw their own
+ * chrome; only a hair of breathing room is added on top. */
+.sidechatRowBody > * {
+ margin-top: 2px;
+}
+
+/* Turn-tail metrics line: quiet, right-aligned, ink-weight only. */
+.sidechatTurnSummary {
+ align-self: flex-end;
+ max-width: 88%;
+ padding: 0 2px;
+ font: var(--dsw-font-xxs-12);
+ font-variant-numeric: tabular-nums;
+ color: var(--dsw-alias-label-tertiary);
+ user-select: none;
+}
+
/* Shimmer sweep text: generating (streaming row labels, creating hero). */
.sidechatShimmerText {
background-image: linear-gradient(
diff --git a/src/client/SideChatView.tsx b/src/client/SideChatView.tsx
index b021acb..ccb4019 100644
--- a/src/client/SideChatView.tsx
+++ b/src/client/SideChatView.tsx
@@ -27,6 +27,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useSyncExternalStore } from 'react'
import clsx from 'clsx'
import {
+ ConnectionIndicator,
+ DiffBlock,
IconChevronRightOutline14,
IconNewChatOutline16,
IconPlusOutline16,
@@ -34,8 +36,13 @@ import {
IconStopFill16,
MarkdownText,
Menu,
+ ReadBlock,
StateDot,
+ TerminalBlock,
+ type DiffBlockLabels,
type MenuEntry,
+ type ReadBlockLabels,
+ type TerminalBlockLabels,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { markdownTextProps } from './markdown-labels.tsx'
import { IconHistoryOutline16, IconSaveOutline16 } from './icons.tsx'
@@ -48,7 +55,14 @@ import {
threadTrailingPending,
type SidechatThreadInfo,
} from '../sidechat-core.ts'
-import { toolArgsSummary, transcriptRows, type SidechatTranscriptRow } from './sidechat-transcript.ts'
+import {
+ formatDurationMs,
+ formatTokens,
+ toolArgsSummary,
+ transcriptRows,
+ type SidechatToolCard,
+ type SidechatTranscriptRow,
+} from './sidechat-transcript.ts'
import { api } from './api.ts'
import { t } from './locales.ts'
import type { SessionScope } from './api.ts'
@@ -100,6 +114,9 @@ interface RowLabels {
copiedLabel: string
thinkLabel: string
injectionLabel: string
+ terminal: TerminalBlockLabels
+ diff: DiffBlockLabels
+ read: ReadBlockLabels
}
/** Merge history entries by event seq (newest wins), log order preserved. */
@@ -176,6 +193,28 @@ function CollapsibleRow(props: {
)
}
+/** The host Block body for a structured tool card (main-conversation atoms:
+ * terminal surface, diff hunks, line-numbered read window). */
+function toolCardBody(card: SidechatToolCard, executing: boolean, labels: RowLabels): React.ReactNode {
+ if (card.type === 'terminal') {
+ return (
+
+ )
+ }
+ if (card.type === 'diff') {
+ return
+ }
+ return
+}
+
/** One row renderer (React keys ride the source event seq). */
function renderRow(row: SidechatTranscriptRow, labels: RowLabels): React.ReactNode {
switch (row.kind) {
@@ -207,13 +246,30 @@ function renderRow(row: SidechatTranscriptRow, labels: RowLabels): React.ReactNo
{row.text}
)
- case 'tool': {
- const body = (
- <>
- {row.args !== undefined && {row.args}}
- {row.resultText !== undefined && {row.resultText}}
- >
+ case 'turnSummary': {
+ // Quiet turn-tail metrics: token usage + wall duration, main-pane
+ // StatsLine formatting. Parts appear only when computable.
+ const parts: string[] = []
+ if (row.inputTokens !== undefined && row.outputTokens !== undefined) {
+ parts.push(t('sideChatTurnUsage', { input: formatTokens(row.inputTokens), output: formatTokens(row.outputTokens) }))
+ }
+ if (row.durationMs !== undefined) parts.push(formatDurationMs(row.durationMs))
+ if (parts.length === 0) return null
+ return (
+
+ {parts.join(' · ')}
+
)
+ }
+ case 'tool': {
+ const body = row.card !== undefined
+ ? toolCardBody(row.card, row.executing === true, labels)
+ : (
+ <>
+ {row.args !== undefined && {row.args}}
+ {row.resultText !== undefined && {row.resultText}}
+ >
+ )
return (
)
}
@@ -237,15 +293,35 @@ export function SideChatView(props: {
visible: boolean
}): React.ReactNode {
const { ctx, scope, tab, visible } = props
- const rowLabels = useMemo(
- () => ({
+ const rowLabels = useMemo(() => {
+ // Shared Block chrome: copy buttons reuse the sidebar's copy pair, the
+ // collapse/expand family is common to every Block kind.
+ const shared = {
+ copy: t('copy'),
+ copied: t('copied'),
+ collapse: t('sideChatBlockCollapse'),
+ collapseAria: t('sideChatBlockCollapseAria'),
+ expand: (hidden: number) => t('sideChatBlockExpand', { hidden }),
+ expandAria: (hidden: number) => t('sideChatBlockExpandAria', { hidden }),
+ }
+ return {
copyLabel: t('copy'),
copiedLabel: t('copied'),
thinkLabel: t('sideChatThink'),
injectionLabel: t('sideChatInjection'),
- }),
- [],
- )
+ terminal: {
+ ...shared,
+ signal: (signal: string) => t('sideChatBlockSignal', { signal }),
+ exitCode: (exitCode: number) => t('sideChatBlockExitCode', { code: exitCode }),
+ running: t('sideChatBlockRunning'),
+ failed: t('sideChatBlockFailed'),
+ done: t('sideChatBlockDone'),
+ noOutput: t('sideChatBlockNoOutput'),
+ },
+ diff: { ...shared, files: (count: number) => t('sideChatBlockFiles', { count }) },
+ read: { ...shared, window: (shown: number, total: number) => t('sideChatBlockWindow', { shown, total }) },
+ }
+ }, [])
// The session list feed: thread rows (the header menu) + running states.
const list = useSyncExternalStore(
@@ -277,6 +353,15 @@ export function SideChatView(props: {
const summary = threadId === undefined ? undefined : list.byId[threadId]
const running = summary?.running === true
+ // Connection recovery state (DSH 0.1.2-alpha.2+): drives the disconnect
+ // banner and an immediate catch-up pull when the wire comes back. The poll
+ // loop itself stays silent on wire failures; absent service (older host)
+ // reads as `undefined` = never show the banner.
+ const connectionState = useSyncExternalStore(
+ useMemo(() => (callback: () => void) => ctx.connection?.state.subscribe(callback) ?? (() => {}), [ctx]),
+ useCallback(() => ctx.connection?.state.getSnapshot(), [ctx]),
+ )
+
/** The agent-identity badge of the thread header (preset · model). */
const agentBadge = useMemo(() => {
if (info === null) return ''
@@ -382,6 +467,19 @@ export function SideChatView(props: {
useEffect(() => () => { controllerRef.current?.abort() }, [])
+ // When the wire recovers (disconnected → connected), pull immediately
+ // instead of waiting for the next poll tick — or, on an idle thread that
+ // stopped polling, forever.
+ const prevConnectionRef = useRef(connectionState)
+ useEffect(() => {
+ const previous = prevConnectionRef.current
+ prevConnectionRef.current = connectionState
+ if (previous === 'disconnected' && connectionState === 'connected' && threadId !== undefined) {
+ void fetchThread(threadId)
+ void fetchInfo(threadId)
+ }
+ }, [connectionState, threadId, fetchThread, fetchInfo])
+
const rows = useMemo(
() => (threadId === undefined ? [] : transcriptRows(cacheRef.current.entries)),
// The cache is a ref; revision bumps on every successful pull.
@@ -560,6 +658,18 @@ export function SideChatView(props: {
+ {connectionState !== undefined && connectionState !== 'connected' && (
+ { ctx.connection?.reconnect() }}
+ />
+ )}
{!canSave && !freshThread && {t('sideChatNoTurn')}
}
{canSave && trailingPending
&& {t('sideChatPendingDrop')}
}
diff --git a/src/client/index.tsx b/src/client/index.tsx
index 6923c5e..30033f9 100644
--- a/src/client/index.tsx
+++ b/src/client/index.tsx
@@ -34,8 +34,10 @@ import './layout.css'
/** Services required before mounting (provided by the client runtime; the
* locale service backs the sidebar's copy — see locales.ts). `modules`
* (rc.8+) is the client module system the chunk loader resolves its
- * externals through — Cordis guards service access without inject. */
-export const inject = ['slots', 'sessions', 'workspaces', 'locale', 'modules']
+ * externals through; `connection` (0.1.2-alpha.2+) is the Remote transport's
+ * recovery lifecycle the side chat's disconnect banner reads — Cordis guards
+ * service access without inject. */
+export const inject = ['slots', 'sessions', 'workspaces', 'locale', 'modules', 'connection']
/**
* Error boundary over the sidebar tree (root scope): a render error in the
diff --git a/src/client/locales-ar.ts b/src/client/locales-ar.ts
index 8d4630d..c58f27d 100644
--- a/src/client/locales-ar.ts
+++ b/src/client/locales-ar.ts
@@ -351,6 +351,25 @@ export const ar: Record = {
sideChatClose: 'إغلاق الخيط',
sideChatCloseTitle: 'تحرير وكيل الخيط (يُحتفظ بالسجل)',
sideChatError: 'خطأ في المحادثة الجانبية: {message}',
+ sideChatTurnUsage: 'إدخال {input} توكن · إخراج {output} توكن',
+ sideChatBlockCollapse: 'طي',
+ sideChatBlockCollapseAria: 'طي',
+ sideChatBlockExpand: 'توسيع {hidden} أسطر',
+ sideChatBlockExpandAria: 'توسيع {hidden} أسطر إضافية',
+ sideChatBlockSignal: 'أُنهي بالإشارة: {signal}',
+ sideChatBlockExitCode: 'رمز الخروج {code}',
+ sideChatBlockRunning: 'قيد التشغيل',
+ sideChatBlockFailed: 'فشل',
+ sideChatBlockDone: 'اكتمل',
+ sideChatBlockNoOutput: '(لا يوجد إخراج)',
+ sideChatBlockFiles: '{count} ملفات',
+ sideChatBlockWindow: '{shown} من {total} سطرًا',
+ sideChatConnDisconnected: 'انقطع الاتصال',
+ sideChatConnReconnect: 'إعادة الاتصال',
+ sideChatConnConnecting: 'جارٍ إعادة الاتصال…',
+ sideChatConnRecovered: 'تم استعادة الاتصال',
+ sideChatConnReconnectAction: 'إعادة الاتصال الآن',
+ sideChatConnRestartAction: 'بدء الاتصال من جديد',
jobs: 'المهام الخلفية',
jobsCount: '{count} مهمة خلفية',
jobsCountRunning: '{count} مهمة خلفية · {running} قيد التشغيل',
diff --git a/src/client/locales-de.ts b/src/client/locales-de.ts
index 19faf81..f9613ac 100644
--- a/src/client/locales-de.ts
+++ b/src/client/locales-de.ts
@@ -336,6 +336,25 @@ export const de: Record = {
sideChatClose: 'Thread schließen',
sideChatCloseTitle: 'Den Agenten des Threads freigeben (Verlauf bleibt erhalten)',
sideChatError: 'Seitenchat-Fehler: {message}',
+ sideChatTurnUsage: 'Eingabe {input} tok · Ausgabe {output} tok',
+ sideChatBlockCollapse: 'Einklappen',
+ sideChatBlockCollapseAria: 'Einklappen',
+ sideChatBlockExpand: '{hidden} Zeilen ausklappen',
+ sideChatBlockExpandAria: '{hidden} weitere Zeilen ausklappen',
+ sideChatBlockSignal: 'Durch Signal beendet: {signal}',
+ sideChatBlockExitCode: 'Exit-Code {code}',
+ sideChatBlockRunning: 'Läuft',
+ sideChatBlockFailed: 'Fehlgeschlagen',
+ sideChatBlockDone: 'Fertig',
+ sideChatBlockNoOutput: '(keine Ausgabe)',
+ sideChatBlockFiles: '{count} Dateien',
+ sideChatBlockWindow: '{shown} von {total} Zeilen',
+ sideChatConnDisconnected: 'Verbindung verloren',
+ sideChatConnReconnect: 'Neu verbinden',
+ sideChatConnConnecting: 'Verbinde neu…',
+ sideChatConnRecovered: 'Verbindung wiederhergestellt',
+ sideChatConnReconnectAction: 'Jetzt neu verbinden',
+ sideChatConnRestartAction: 'Verbindung neu aufbauen',
jobs: 'Hintergrundaufgaben',
jobsCount: '{count} Hintergrundaufgaben',
jobsCountRunning: '{count} Hintergrundaufgaben · {running} läuft',
diff --git a/src/client/locales-fr.ts b/src/client/locales-fr.ts
index 45fe008..7fbc147 100644
--- a/src/client/locales-fr.ts
+++ b/src/client/locales-fr.ts
@@ -343,6 +343,25 @@ export const fr: Record = {
sideChatClose: 'Fermer le fil',
sideChatCloseTitle: 'Libérer l’agent du fil (l’historique est conservé)',
sideChatError: 'Erreur de la discussion latérale : {message}',
+ sideChatTurnUsage: 'Entrée {input} tok · Sortie {output} tok',
+ sideChatBlockCollapse: 'Réduire',
+ sideChatBlockCollapseAria: 'Réduire',
+ sideChatBlockExpand: 'Déplier {hidden} lignes',
+ sideChatBlockExpandAria: 'Déplier {hidden} lignes de plus',
+ sideChatBlockSignal: 'Terminé par le signal : {signal}',
+ sideChatBlockExitCode: 'Code de sortie {code}',
+ sideChatBlockRunning: 'En cours',
+ sideChatBlockFailed: 'Échec',
+ sideChatBlockDone: 'Terminé',
+ sideChatBlockNoOutput: '(aucune sortie)',
+ sideChatBlockFiles: '{count} fichiers',
+ sideChatBlockWindow: '{shown} sur {total} lignes',
+ sideChatConnDisconnected: 'Connexion perdue',
+ sideChatConnReconnect: 'Reconnecter',
+ sideChatConnConnecting: 'Reconnexion…',
+ sideChatConnRecovered: 'Connexion rétablie',
+ sideChatConnReconnectAction: 'Reconnecter maintenant',
+ sideChatConnRestartAction: 'Relancer la connexion',
jobs: 'Tâches d’arrière-plan',
jobsCount: '{count} tâche(s) d’arrière-plan',
jobsCountRunning: '{count} tâche(s) d’arrière-plan · {running} en cours',
diff --git a/src/client/locales-hi.ts b/src/client/locales-hi.ts
index f4a2925..fc928ca 100644
--- a/src/client/locales-hi.ts
+++ b/src/client/locales-hi.ts
@@ -350,6 +350,25 @@ export const hi: Record = {
sideChatClose: 'थ्रेड बंद करें',
sideChatCloseTitle: 'थ्रेड का एजेंट रिलीज़ करें (इतिहास रखा जाता है)',
sideChatError: 'साइड चैट त्रुटि: {message}',
+ sideChatTurnUsage: 'इनपुट {input} टोकन · आउटपुट {output} टोकन',
+ sideChatBlockCollapse: 'सिकोड़ें',
+ sideChatBlockCollapseAria: 'सिकोड़ें',
+ sideChatBlockExpand: '{hidden} पंक्तियाँ विस्तृत करें',
+ sideChatBlockExpandAria: 'आगे की {hidden} पंक्तियाँ विस्तृत करें',
+ sideChatBlockSignal: 'सिग्नल से समाप्त: {signal}',
+ sideChatBlockExitCode: 'एग्जिट कोड {code}',
+ sideChatBlockRunning: 'चल रहा है',
+ sideChatBlockFailed: 'विफल',
+ sideChatBlockDone: 'पूर्ण',
+ sideChatBlockNoOutput: '(कोई आउटपुट नहीं)',
+ sideChatBlockFiles: '{count} फ़ाइलें',
+ sideChatBlockWindow: 'कुल {total} में से {shown} पंक्तियाँ',
+ sideChatConnDisconnected: 'कनेक्शन टूट गया',
+ sideChatConnReconnect: 'फिर से कनेक्ट करें',
+ sideChatConnConnecting: 'फिर से कनेक्ट हो रहा है…',
+ sideChatConnRecovered: 'कनेक्शन बहाल हुआ',
+ sideChatConnReconnectAction: 'अभी फिर से कनेक्ट करें',
+ sideChatConnRestartAction: 'कनेक्शन फिर से शुरू करें',
jobs: 'बैकग्राउंड कार्य',
jobsCount: '{count} बैकग्राउंड कार्य',
jobsCountRunning: '{count} बैकग्राउंड कार्य · {running} चल रहे',
diff --git a/src/client/locales-id.ts b/src/client/locales-id.ts
index 35077d4..88c061c 100644
--- a/src/client/locales-id.ts
+++ b/src/client/locales-id.ts
@@ -348,6 +348,25 @@ export const id: Record = {
sideChatClose: 'Tutup thread',
sideChatCloseTitle: 'Lepaskan agen thread (riwayat dipertahankan)',
sideChatError: 'Kesalahan Side Chat: {message}',
+ sideChatTurnUsage: 'Input {input} tok · Output {output} tok',
+ sideChatBlockCollapse: 'Lipat',
+ sideChatBlockCollapseAria: 'Lipat',
+ sideChatBlockExpand: 'Perlihatkan {hidden} baris',
+ sideChatBlockExpandAria: 'Perlihatkan {hidden} baris lagi',
+ sideChatBlockSignal: 'Diakhiri sinyal: {signal}',
+ sideChatBlockExitCode: 'Kode keluar {code}',
+ sideChatBlockRunning: 'Berjalan',
+ sideChatBlockFailed: 'Gagal',
+ sideChatBlockDone: 'Selesai',
+ sideChatBlockNoOutput: '(tanpa output)',
+ sideChatBlockFiles: '{count} file',
+ sideChatBlockWindow: '{shown} dari {total} baris',
+ sideChatConnDisconnected: 'Koneksi terputus',
+ sideChatConnReconnect: 'Sambungkan ulang',
+ sideChatConnConnecting: 'Menyambung ulang…',
+ sideChatConnRecovered: 'Koneksi dipulihkan',
+ sideChatConnReconnectAction: 'Sambungkan ulang sekarang',
+ sideChatConnRestartAction: 'Mulai ulang koneksi',
jobs: 'Tugas latar',
jobsCount: '{count} tugas latar',
jobsCountRunning: '{count} tugas latar · {running} berjalan',
diff --git a/src/client/locales-it.ts b/src/client/locales-it.ts
index 81e6a2f..5e8dd45 100644
--- a/src/client/locales-it.ts
+++ b/src/client/locales-it.ts
@@ -341,6 +341,25 @@ export const it: Record = {
sideChatClose: 'Chiudi conversazione',
sideChatCloseTitle: 'Rilascia l’agente della conversazione (la cronologia è conservata)',
sideChatError: 'Errore della chat laterale: {message}',
+ sideChatTurnUsage: 'Input {input} tok · Output {output} tok',
+ sideChatBlockCollapse: 'Comprimi',
+ sideChatBlockCollapseAria: 'Comprimi',
+ sideChatBlockExpand: 'Espandi {hidden} righe',
+ sideChatBlockExpandAria: 'Espandi altre {hidden} righe',
+ sideChatBlockSignal: 'Terminato dal segnale: {signal}',
+ sideChatBlockExitCode: 'Codice di uscita {code}',
+ sideChatBlockRunning: 'In esecuzione',
+ sideChatBlockFailed: 'Non riuscito',
+ sideChatBlockDone: 'Completato',
+ sideChatBlockNoOutput: '(nessun output)',
+ sideChatBlockFiles: '{count} file',
+ sideChatBlockWindow: '{shown} di {total} righe',
+ sideChatConnDisconnected: 'Connessione persa',
+ sideChatConnReconnect: 'Riconnetti',
+ sideChatConnConnecting: 'Riconnessione…',
+ sideChatConnRecovered: 'Connessione ripristinata',
+ sideChatConnReconnectAction: 'Riconnetti ora',
+ sideChatConnRestartAction: 'Riavvia la connessione',
jobs: 'Attività in background',
jobsCount: '{count} attività in background',
jobsCountRunning: '{count} attività in background · {running} in esecuzione',
diff --git a/src/client/locales-ja.ts b/src/client/locales-ja.ts
index fb3d8df..756a94e 100644
--- a/src/client/locales-ja.ts
+++ b/src/client/locales-ja.ts
@@ -350,6 +350,25 @@ export const ja: Record = {
sideChatClose: 'スレッドを閉じる',
sideChatCloseTitle: 'スレッドの agent を解放(履歴は保持)',
sideChatError: 'サイド会話エラー:{message}',
+ sideChatTurnUsage: '入力 {input} tok · 出力 {output} tok',
+ sideChatBlockCollapse: '折りたたむ',
+ sideChatBlockCollapseAria: '折りたたむ',
+ sideChatBlockExpand: '{hidden} 行を展開',
+ sideChatBlockExpandAria: '残り {hidden} 行を展開',
+ sideChatBlockSignal: 'シグナルで終了:{signal}',
+ sideChatBlockExitCode: '終了コード {code}',
+ sideChatBlockRunning: '実行中',
+ sideChatBlockFailed: '失敗',
+ sideChatBlockDone: '完了',
+ sideChatBlockNoOutput: '(出力なし)',
+ sideChatBlockFiles: '{count} 個のファイル',
+ sideChatBlockWindow: '全 {total} 行中 {shown} 行を表示',
+ sideChatConnDisconnected: '接続が切断されました',
+ sideChatConnReconnect: '再接続',
+ sideChatConnConnecting: '再接続中…',
+ sideChatConnRecovered: '接続が復旧しました',
+ sideChatConnReconnectAction: '今すぐ再接続',
+ sideChatConnRestartAction: '接続をやり直す',
jobs: 'バックグラウンドタスク',
jobsCount: '{count} 個のバックグラウンドタスク',
jobsCountRunning: '{count} 個のバックグラウンドタスク ・ {running} 個実行中',
diff --git a/src/client/locales-ko.ts b/src/client/locales-ko.ts
index c4136ef..4aaa3cb 100644
--- a/src/client/locales-ko.ts
+++ b/src/client/locales-ko.ts
@@ -342,6 +342,25 @@ export const ko: Record = {
sideChatClose: '스레드 닫기',
sideChatCloseTitle: '스레드의 agent를 해제합니다(기록은 유지)',
sideChatError: '사이드 대화 오류: {message}',
+ sideChatTurnUsage: '입력 {input} tok · 출력 {output} tok',
+ sideChatBlockCollapse: '접기',
+ sideChatBlockCollapseAria: '접기',
+ sideChatBlockExpand: '{hidden}줄 펼치기',
+ sideChatBlockExpandAria: '나머지 {hidden}줄 펼치기',
+ sideChatBlockSignal: '시그널로 종료됨: {signal}',
+ sideChatBlockExitCode: '종료 코드 {code}',
+ sideChatBlockRunning: '실행 중',
+ sideChatBlockFailed: '실패',
+ sideChatBlockDone: '완료',
+ sideChatBlockNoOutput: '(출력 없음)',
+ sideChatBlockFiles: '파일 {count}개',
+ sideChatBlockWindow: '전체 {total}줄 중 {shown}줄 표시',
+ sideChatConnDisconnected: '연결이 끊겼습니다',
+ sideChatConnReconnect: '다시 연결',
+ sideChatConnConnecting: '다시 연결 중…',
+ sideChatConnRecovered: '연결이 복원되었습니다',
+ sideChatConnReconnectAction: '지금 다시 연결',
+ sideChatConnRestartAction: '연결 다시 시작',
jobs: '백그라운드 작업',
jobsCount: '백그라운드 작업 {count}개',
jobsCountRunning: '백그라운드 작업 {count}개 · {running} 실행 중',
diff --git a/src/client/locales-nl.ts b/src/client/locales-nl.ts
index fbab59f..f413688 100644
--- a/src/client/locales-nl.ts
+++ b/src/client/locales-nl.ts
@@ -348,6 +348,25 @@ export const nl: Record = {
sideChatClose: 'Thread sluiten',
sideChatCloseTitle: 'De agent van de thread vrijgeven (geschiedenis blijft behouden)',
sideChatError: 'Side Chat-fout: {message}',
+ sideChatTurnUsage: 'Invoer {input} tok · Uitvoer {output} tok',
+ sideChatBlockCollapse: 'Invouwen',
+ sideChatBlockCollapseAria: 'Invouwen',
+ sideChatBlockExpand: '{hidden} regels uitvouwen',
+ sideChatBlockExpandAria: '{hidden} regels meer uitvouwen',
+ sideChatBlockSignal: 'Beëindigd door signaal: {signal}',
+ sideChatBlockExitCode: 'Exitcode {code}',
+ sideChatBlockRunning: 'Wordt uitgevoerd',
+ sideChatBlockFailed: 'Mislukt',
+ sideChatBlockDone: 'Voltooid',
+ sideChatBlockNoOutput: '(geen uitvoer)',
+ sideChatBlockFiles: '{count} bestanden',
+ sideChatBlockWindow: '{shown} van {total} regels',
+ sideChatConnDisconnected: 'Verbinding verbroken',
+ sideChatConnReconnect: 'Opnieuw verbinden',
+ sideChatConnConnecting: 'Opnieuw verbinden…',
+ sideChatConnRecovered: 'Verbinding hersteld',
+ sideChatConnReconnectAction: 'Nu opnieuw verbinden',
+ sideChatConnRestartAction: 'Verbinding herstarten',
jobs: 'Achtergrondtaken',
jobsCount: '{count} achtergrondtaken',
jobsCountRunning: '{count} achtergrondtaken · {running} actief',
diff --git a/src/client/locales-pl.ts b/src/client/locales-pl.ts
index 9600fd6..610ad13 100644
--- a/src/client/locales-pl.ts
+++ b/src/client/locales-pl.ts
@@ -352,6 +352,25 @@ export const pl: Record = {
sideChatClose: 'Zamknij wątek',
sideChatCloseTitle: 'Zwolnij agenta wątku (historia jest zachowana)',
sideChatError: 'Błąd czatu bocznego: {message}',
+ sideChatTurnUsage: 'Wejście {input} tok · Wyjście {output} tok',
+ sideChatBlockCollapse: 'Zwiń',
+ sideChatBlockCollapseAria: 'Zwiń',
+ sideChatBlockExpand: 'Rozwiń {hidden} wierszy',
+ sideChatBlockExpandAria: 'Rozwiń kolejne {hidden} wierszy',
+ sideChatBlockSignal: 'Zakończono sygnałem: {signal}',
+ sideChatBlockExitCode: 'Kod zakończenia {code}',
+ sideChatBlockRunning: 'Uruchomione',
+ sideChatBlockFailed: 'Niepowodzenie',
+ sideChatBlockDone: 'Zakończono',
+ sideChatBlockNoOutput: '(brak wyniku)',
+ sideChatBlockFiles: 'Pliki: {count}',
+ sideChatBlockWindow: '{shown} z {total} wierszy',
+ sideChatConnDisconnected: 'Połączenie utracone',
+ sideChatConnReconnect: 'Połącz ponownie',
+ sideChatConnConnecting: 'Łączenie ponownie…',
+ sideChatConnRecovered: 'Połączenie przywrócone',
+ sideChatConnReconnectAction: 'Połącz ponownie teraz',
+ sideChatConnRestartAction: 'Uruchom połączenie ponownie',
jobs: 'Zadania w tle',
jobsCount: '{count} zadań w tle',
jobsCountRunning: '{count} zadań w tle · {running} działa',
diff --git a/src/client/locales-pt.ts b/src/client/locales-pt.ts
index 90c8462..8fc42ed 100644
--- a/src/client/locales-pt.ts
+++ b/src/client/locales-pt.ts
@@ -333,6 +333,25 @@ export const pt: Record = {
sideChatClose: 'Fechar tópico',
sideChatCloseTitle: 'Liberar o agente do tópico (o histórico é mantido)',
sideChatError: 'Erro no chat lateral: {message}',
+ sideChatTurnUsage: 'Entrada {input} tok · Saída {output} tok',
+ sideChatBlockCollapse: 'Recolher',
+ sideChatBlockCollapseAria: 'Recolher',
+ sideChatBlockExpand: 'Expandir {hidden} linhas',
+ sideChatBlockExpandAria: 'Expandir mais {hidden} linhas',
+ sideChatBlockSignal: 'Encerrado por sinal: {signal}',
+ sideChatBlockExitCode: 'Código de saída {code}',
+ sideChatBlockRunning: 'Em execução',
+ sideChatBlockFailed: 'Falhou',
+ sideChatBlockDone: 'Concluído',
+ sideChatBlockNoOutput: '(sem saída)',
+ sideChatBlockFiles: '{count} arquivos',
+ sideChatBlockWindow: '{shown} de {total} linhas',
+ sideChatConnDisconnected: 'Conexão perdida',
+ sideChatConnReconnect: 'Reconectar',
+ sideChatConnConnecting: 'Reconectando…',
+ sideChatConnRecovered: 'Conexão restabelecida',
+ sideChatConnReconnectAction: 'Reconectar agora',
+ sideChatConnRestartAction: 'Reiniciar conexão',
jobs: 'Tarefas em segundo plano',
jobsCount: '{count} tarefas em segundo plano',
jobsCountRunning: '{count} tarefas em segundo plano · {running} executando',
diff --git a/src/client/locales-ru.ts b/src/client/locales-ru.ts
index bac9aab..dedb0f4 100644
--- a/src/client/locales-ru.ts
+++ b/src/client/locales-ru.ts
@@ -348,6 +348,25 @@ export const ru: Record = {
sideChatClose: 'Закрыть поток',
sideChatCloseTitle: 'Освободить агента потока (история сохраняется)',
sideChatError: 'Ошибка бокового чата: {message}',
+ sideChatTurnUsage: 'Вход {input} tok · Выход {output} tok',
+ sideChatBlockCollapse: 'Свернуть',
+ sideChatBlockCollapseAria: 'Свернуть',
+ sideChatBlockExpand: 'Развернуть {hidden} строк',
+ sideChatBlockExpandAria: 'Развернуть ещё {hidden} строк',
+ sideChatBlockSignal: 'Завершён сигналом: {signal}',
+ sideChatBlockExitCode: 'Код выхода {code}',
+ sideChatBlockRunning: 'Выполняется',
+ sideChatBlockFailed: 'Ошибка',
+ sideChatBlockDone: 'Готово',
+ sideChatBlockNoOutput: '(нет вывода)',
+ sideChatBlockFiles: 'Файлов: {count}',
+ sideChatBlockWindow: '{shown} из {total} строк',
+ sideChatConnDisconnected: 'Соединение потеряно',
+ sideChatConnReconnect: 'Переподключиться',
+ sideChatConnConnecting: 'Переподключение…',
+ sideChatConnRecovered: 'Соединение восстановлено',
+ sideChatConnReconnectAction: 'Переподключиться сейчас',
+ sideChatConnRestartAction: 'Перезапустить соединение',
jobs: 'Фоновые задачи',
jobsCount: '{count} фоновых задач',
jobsCountRunning: '{count} фоновых задач · {running} выполняется',
diff --git a/src/client/locales-sv.ts b/src/client/locales-sv.ts
index 4e2426c..4687e4a 100644
--- a/src/client/locales-sv.ts
+++ b/src/client/locales-sv.ts
@@ -333,6 +333,25 @@ export const sv: Record = {
sideChatClose: 'Stäng tråd',
sideChatCloseTitle: 'Frigör trådens agent (historik behålls)',
sideChatError: 'Sidochattsfel: {message}',
+ sideChatTurnUsage: 'In {input} tok · Ut {output} tok',
+ sideChatBlockCollapse: 'Fäll ihop',
+ sideChatBlockCollapseAria: 'Fäll ihop',
+ sideChatBlockExpand: 'Visa {hidden} rader',
+ sideChatBlockExpandAria: 'Visa {hidden} rader till',
+ sideChatBlockSignal: 'Avslutad av signal: {signal}',
+ sideChatBlockExitCode: 'Slutkod {code}',
+ sideChatBlockRunning: 'Körs',
+ sideChatBlockFailed: 'Misslyckades',
+ sideChatBlockDone: 'Klar',
+ sideChatBlockNoOutput: '(ingen utdata)',
+ sideChatBlockFiles: '{count} filer',
+ sideChatBlockWindow: '{shown} av {total} rader',
+ sideChatConnDisconnected: 'Anslutningen bröts',
+ sideChatConnReconnect: 'Anslut igen',
+ sideChatConnConnecting: 'Ansluter igen…',
+ sideChatConnRecovered: 'Anslutningen återställd',
+ sideChatConnReconnectAction: 'Anslut igen nu',
+ sideChatConnRestartAction: 'Starta om anslutningen',
jobs: 'Bakgrundsjobb',
jobsCount: '{count} bakgrundsjobb',
jobsCountRunning: '{count} bakgrundsjobb · {running} kör',
diff --git a/src/client/locales-th.ts b/src/client/locales-th.ts
index 0245b39..7777ce5 100644
--- a/src/client/locales-th.ts
+++ b/src/client/locales-th.ts
@@ -350,6 +350,25 @@ export const th: Record = {
sideChatClose: 'ปิดเธรด',
sideChatCloseTitle: 'ปล่อยตัวแทนของเธรด (ประวัติยังคงอยู่)',
sideChatError: 'ข้อผิดพลาดแชทด้านข้าง: {message}',
+ sideChatTurnUsage: 'อินพุต {input} tok · เอาต์พุต {output} tok',
+ sideChatBlockCollapse: 'ย่อ',
+ sideChatBlockCollapseAria: 'ย่อ',
+ sideChatBlockExpand: 'ขยาย {hidden} บรรทัด',
+ sideChatBlockExpandAria: 'ขยายอีก {hidden} บรรทัด',
+ sideChatBlockSignal: 'ถูกยุตด้วยสัญญาณ: {signal}',
+ sideChatBlockExitCode: 'รหัสออก {code}',
+ sideChatBlockRunning: 'กำลังทำงาน',
+ sideChatBlockFailed: 'ล้มเหลว',
+ sideChatBlockDone: 'เสร็จสิ้น',
+ sideChatBlockNoOutput: '(ไม่มีเอาต์พุต)',
+ sideChatBlockFiles: '{count} ไฟล์',
+ sideChatBlockWindow: '{shown} จาก {total} บรรทัด',
+ sideChatConnDisconnected: 'การเชื่อมต่อขาดหาย',
+ sideChatConnReconnect: 'เชื่อมต่อใหม่',
+ sideChatConnConnecting: 'กำลังเชื่อมต่อใหม่…',
+ sideChatConnRecovered: 'กู้คืนการเชื่อมต่อแล้ว',
+ sideChatConnReconnectAction: 'เชื่อมต่อใหม่ตอนนี้',
+ sideChatConnRestartAction: 'เริ่มการเชื่อมต่อใหม่',
jobs: 'งานเบื้องหลัง',
jobsCount: '{count} งานเบื้องหลัง',
jobsCountRunning: '{count} งานเบื้องหลัง · {running} กำลังทำงาน',
diff --git a/src/client/locales-tr.ts b/src/client/locales-tr.ts
index 48bcf97..a658e3b 100644
--- a/src/client/locales-tr.ts
+++ b/src/client/locales-tr.ts
@@ -350,6 +350,25 @@ export const tr: Record = {
sideChatClose: 'İş parçacığını kapat',
sideChatCloseTitle: 'İş parçacığının aracısını serbest bırak (geçmiş korunur)',
sideChatError: 'Yan sohbet hatası: {message}',
+ sideChatTurnUsage: 'Giriş {input} tok · Çıkış {output} tok',
+ sideChatBlockCollapse: 'Daralt',
+ sideChatBlockCollapseAria: 'Daralt',
+ sideChatBlockExpand: '{hidden} satırı genişlet',
+ sideChatBlockExpandAria: '{hidden} satır daha genişlet',
+ sideChatBlockSignal: 'Sinyalle sonlandırıldı: {signal}',
+ sideChatBlockExitCode: 'Çıkış kodu {code}',
+ sideChatBlockRunning: 'Çalışıyor',
+ sideChatBlockFailed: 'Başarısız',
+ sideChatBlockDone: 'Tamamlandı',
+ sideChatBlockNoOutput: '(çıktı yok)',
+ sideChatBlockFiles: '{count} dosya',
+ sideChatBlockWindow: '{total} satırdan {shown} satır',
+ sideChatConnDisconnected: 'Bağlantı koptu',
+ sideChatConnReconnect: 'Yeniden bağlan',
+ sideChatConnConnecting: 'Yeniden bağlanıyor…',
+ sideChatConnRecovered: 'Bağlantı geri yüklendi',
+ sideChatConnReconnectAction: 'Şimdi yeniden bağlan',
+ sideChatConnRestartAction: 'Bağlantıyı yeniden başlat',
jobs: 'Arka plan işleri',
jobsCount: '{count} arka plan işi',
jobsCountRunning: '{count} arka plan işi · {running} çalışıyor',
diff --git a/src/client/locales-vi.ts b/src/client/locales-vi.ts
index 0926641..e67245e 100644
--- a/src/client/locales-vi.ts
+++ b/src/client/locales-vi.ts
@@ -350,6 +350,25 @@ export const vi: Record = {
sideChatClose: 'Đóng luồng',
sideChatCloseTitle: 'Giải phóng agent của luồng (lịch sử được giữ)',
sideChatError: 'Lỗi chat bên: {message}',
+ sideChatTurnUsage: 'Vào {input} tok · Ra {output} tok',
+ sideChatBlockCollapse: 'Thu gọn',
+ sideChatBlockCollapseAria: 'Thu gọn',
+ sideChatBlockExpand: 'Mở rộng {hidden} dòng',
+ sideChatBlockExpandAria: 'Mở rộng thêm {hidden} dòng',
+ sideChatBlockSignal: 'Kết thúc bởi tín hiệu: {signal}',
+ sideChatBlockExitCode: 'Mã thoát {code}',
+ sideChatBlockRunning: 'Đang chạy',
+ sideChatBlockFailed: 'Thất bại',
+ sideChatBlockDone: 'Hoàn tất',
+ sideChatBlockNoOutput: '(không có đầu ra)',
+ sideChatBlockFiles: '{count} tệp',
+ sideChatBlockWindow: '{shown} trong {total} dòng',
+ sideChatConnDisconnected: 'Mất kết nối',
+ sideChatConnReconnect: 'Kết nối lại',
+ sideChatConnConnecting: 'Đang kết nối lại…',
+ sideChatConnRecovered: 'Đã khôi phục kết nối',
+ sideChatConnReconnectAction: 'Kết nối lại ngay',
+ sideChatConnRestartAction: 'Khởi động lại kết nối',
jobs: 'Tác vụ nền',
jobsCount: '{count} tác vụ nền',
jobsCountRunning: '{count} tác vụ nền · {running} đang chạy',
diff --git a/src/client/locales-zh-HK.ts b/src/client/locales-zh-HK.ts
index 7644961..3bc82e0 100644
--- a/src/client/locales-zh-HK.ts
+++ b/src/client/locales-zh-HK.ts
@@ -365,6 +365,25 @@ export const zhHK: Record = {
sideChatClose: '關閉執行緒',
sideChatCloseTitle: '釋放執行緒的 agent(歷史保留)',
sideChatError: '側邊對話出錯:{message}',
+ sideChatTurnUsage: '輸入 {input} tok · 輸出 {output} tok',
+ sideChatBlockCollapse: '收起',
+ sideChatBlockCollapseAria: '收起',
+ sideChatBlockExpand: '展開 {hidden} 行',
+ sideChatBlockExpandAria: '展開其餘 {hidden} 行',
+ sideChatBlockSignal: '訊號終止:{signal}',
+ sideChatBlockExitCode: '結束碼 {code}',
+ sideChatBlockRunning: '執行中',
+ sideChatBlockFailed: '失敗',
+ sideChatBlockDone: '完成',
+ sideChatBlockNoOutput: '(無輸出)',
+ sideChatBlockFiles: '{count} 個檔案',
+ sideChatBlockWindow: '共 {total} 行 · 顯示 {shown} 行',
+ sideChatConnDisconnected: '連線已中斷',
+ sideChatConnReconnect: '重新連線',
+ sideChatConnConnecting: '正在重新連線…',
+ sideChatConnRecovered: '連線已恢復',
+ sideChatConnReconnectAction: '立即重新連線',
+ sideChatConnRestartAction: '重新發起連線',
jobs: '背景任務',
jobsCount: '{count} 個背景任務',
jobsCountRunning: '{count} 個背景任務 · {running} 執行中',
diff --git a/src/client/locales-zh-MO.ts b/src/client/locales-zh-MO.ts
index b99213d..d58bc12 100644
--- a/src/client/locales-zh-MO.ts
+++ b/src/client/locales-zh-MO.ts
@@ -365,6 +365,25 @@ export const zhMO: Record = {
sideChatClose: '關閉執行緒',
sideChatCloseTitle: '釋放執行緒的 agent(歷史保留)',
sideChatError: '側邊對話出錯:{message}',
+ sideChatTurnUsage: '輸入 {input} tok · 輸出 {output} tok',
+ sideChatBlockCollapse: '收起',
+ sideChatBlockCollapseAria: '收起',
+ sideChatBlockExpand: '展開 {hidden} 行',
+ sideChatBlockExpandAria: '展開其餘 {hidden} 行',
+ sideChatBlockSignal: '訊號終止:{signal}',
+ sideChatBlockExitCode: '結束碼 {code}',
+ sideChatBlockRunning: '執行中',
+ sideChatBlockFailed: '失敗',
+ sideChatBlockDone: '完成',
+ sideChatBlockNoOutput: '(無輸出)',
+ sideChatBlockFiles: '{count} 個檔案',
+ sideChatBlockWindow: '共 {total} 行 · 顯示 {shown} 行',
+ sideChatConnDisconnected: '連線已中斷',
+ sideChatConnReconnect: '重新連線',
+ sideChatConnConnecting: '正在重新連線…',
+ sideChatConnRecovered: '連線已恢復',
+ sideChatConnReconnectAction: '立即重新連線',
+ sideChatConnRestartAction: '重新發起連線',
jobs: '背景任務',
jobsCount: '{count} 個背景任務',
jobsCountRunning: '{count} 個背景任務 · {running} 執行中',
diff --git a/src/client/locales-zh-TW.ts b/src/client/locales-zh-TW.ts
index ff8d965..35625b4 100644
--- a/src/client/locales-zh-TW.ts
+++ b/src/client/locales-zh-TW.ts
@@ -365,6 +365,25 @@ export const zhTW: Record = {
sideChatClose: '關閉執行緒',
sideChatCloseTitle: '釋放執行緒的 agent(歷史保留)',
sideChatError: '側邊對話出錯:{message}',
+ sideChatTurnUsage: '輸入 {input} tok · 輸出 {output} tok',
+ sideChatBlockCollapse: '收起',
+ sideChatBlockCollapseAria: '收起',
+ sideChatBlockExpand: '展開 {hidden} 行',
+ sideChatBlockExpandAria: '展開其餘 {hidden} 行',
+ sideChatBlockSignal: '訊號終止:{signal}',
+ sideChatBlockExitCode: '結束碼 {code}',
+ sideChatBlockRunning: '執行中',
+ sideChatBlockFailed: '失敗',
+ sideChatBlockDone: '完成',
+ sideChatBlockNoOutput: '(無輸出)',
+ sideChatBlockFiles: '{count} 個檔案',
+ sideChatBlockWindow: '共 {total} 行 · 顯示 {shown} 行',
+ sideChatConnDisconnected: '連線已中斷',
+ sideChatConnReconnect: '重新連線',
+ sideChatConnConnecting: '正在重新連線…',
+ sideChatConnRecovered: '連線已恢復',
+ sideChatConnReconnectAction: '立即重新連線',
+ sideChatConnRestartAction: '重新發起連線',
jobs: '背景任務',
jobsCount: '{count} 個背景任務',
jobsCountRunning: '{count} 個背景任務 · {running} 執行中',
diff --git a/src/client/locales.ts b/src/client/locales.ts
index d1af57f..584a7b7 100644
--- a/src/client/locales.ts
+++ b/src/client/locales.ts
@@ -357,6 +357,25 @@ export const zh = {
sideChatClose: '关闭线程',
sideChatCloseTitle: '释放线程的 agent(历史保留)',
sideChatError: '侧边对话出错:{message}',
+ sideChatTurnUsage: '输入 {input} tok · 输出 {output} tok',
+ sideChatBlockCollapse: '收起',
+ sideChatBlockCollapseAria: '收起',
+ sideChatBlockExpand: '展开 {hidden} 行',
+ sideChatBlockExpandAria: '展开其余 {hidden} 行',
+ sideChatBlockSignal: '信号终止:{signal}',
+ sideChatBlockExitCode: '退出码 {code}',
+ sideChatBlockRunning: '运行中',
+ sideChatBlockFailed: '失败',
+ sideChatBlockDone: '完成',
+ sideChatBlockNoOutput: '(无输出)',
+ sideChatBlockFiles: '{count} 个文件',
+ sideChatBlockWindow: '共 {total} 行 · 显示 {shown} 行',
+ sideChatConnDisconnected: '连接已断开',
+ sideChatConnReconnect: '重新连接',
+ sideChatConnConnecting: '正在重连…',
+ sideChatConnRecovered: '连接已恢复',
+ sideChatConnReconnectAction: '立即重连',
+ sideChatConnRestartAction: '重新发起连接',
jobs: '后台任务',
jobsCount: '{count} 个后台任务',
jobsCountRunning: '{count} 个后台任务 · {running} 运行中',
@@ -747,6 +766,25 @@ export const en: Record = {
sideChatClose: 'Close thread',
sideChatCloseTitle: 'Release the thread\'s agent (history is kept)',
sideChatError: 'Side Chat error: {message}',
+ sideChatTurnUsage: 'Input {input} tok · Output {output} tok',
+ sideChatBlockCollapse: 'Collapse',
+ sideChatBlockCollapseAria: 'Collapse',
+ sideChatBlockExpand: 'Expand {hidden} lines',
+ sideChatBlockExpandAria: 'Expand {hidden} more lines',
+ sideChatBlockSignal: 'Killed by signal: {signal}',
+ sideChatBlockExitCode: 'Exit code {code}',
+ sideChatBlockRunning: 'Running',
+ sideChatBlockFailed: 'Failed',
+ sideChatBlockDone: 'Done',
+ sideChatBlockNoOutput: '(no output)',
+ sideChatBlockFiles: '{count} files',
+ sideChatBlockWindow: '{shown} of {total} lines',
+ sideChatConnDisconnected: 'Connection lost',
+ sideChatConnReconnect: 'Reconnect',
+ sideChatConnConnecting: 'Reconnecting…',
+ sideChatConnRecovered: 'Connection restored',
+ sideChatConnReconnectAction: 'Reconnect now',
+ sideChatConnRestartAction: 'Restart connection',
jobs: 'Background jobs',
jobsCount: '{count} background jobs',
jobsCountRunning: '{count} background jobs · {running} running',
diff --git a/src/client/sidechat-transcript.ts b/src/client/sidechat-transcript.ts
index 9850d31..61dde7a 100644
--- a/src/client/sidechat-transcript.ts
+++ b/src/client/sidechat-transcript.ts
@@ -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 | 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
+ } 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): Record | undefined {
+ const meta = data.meta
+ if (meta === null || typeof meta !== 'object' || Array.isArray(meta)) return undefined
+ return meta as Record
+}
+
+/** Narrow edit/write's contextual-diff meta (`{ diffs: FileDiff[] }`) to
+ * DiffHunk[], declining on any malformed entry. */
+function diffCardFromMeta(meta: Record): 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
+ 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): 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,
+ 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()
/** tool callId → index of its tool row in `rows` (result pairing). */
const callRows = new Map()
+ /** turn → envelope time of its `turn/start` (turn-tail duration basis). */
+ const turnStarts = new Map()
+ /** 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()
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
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 !== '') {
diff --git a/src/context-types.ts b/src/context-types.ts
index 251fc5b..bd2c18c 100644
--- a/src/context-types.ts
+++ b/src/context-types.ts
@@ -522,6 +522,19 @@ export interface SidebarContextShape {
sessionTitle: SidebarSessionTitleService
/** The host session-persistence service (optional; side chat cold resume). */
sessionPersistence: SidebarSessionPersistenceService
+ /**
+ * The client connection lifecycle (DSH 0.1.2-alpha.2+; optional so older
+ * hosts and test fakes simply hide the disconnect banner): the observable
+ * recovery state of the Remote transport (`undefined` before the first
+ * connection outcome) and an immediate-reconnect request.
+ */
+ connection?: {
+ state: {
+ getSnapshot(): 'connected' | 'disconnected' | 'connecting' | undefined
+ subscribe(listener: () => void): () => void
+ }
+ reconnect(): void
+ }
/** The composer draft face (client ui-conversation, lazy `ctx.get` probe). */
conversation: SidebarConversation
/**
diff --git a/tests/sidechat-transcript.spec.ts b/tests/sidechat-transcript.spec.ts
index d47650d..fbe5a6a 100644
--- a/tests/sidechat-transcript.spec.ts
+++ b/tests/sidechat-transcript.spec.ts
@@ -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 = {}): 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
+ }
+
+ 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')
diff --git a/tests/sidechat-view.spec.tsx b/tests/sidechat-view.spec.tsx
new file mode 100644
index 0000000..0915199
--- /dev/null
+++ b/tests/sidechat-view.spec.tsx
@@ -0,0 +1,212 @@
+/**
+ * Side Chat view render tests: tool rows with structured cards render the
+ * host's ui-primitives Blocks (terminal surface from a bash call/result
+ * pair), the turn-tail usage/duration line renders from the mapped
+ * turnSummary row, and the connection banner follows `ctx.connection.state`
+ * (shown + reconnect-click while disconnected, hidden once connected, with
+ * an immediate transcript pull on recovery).
+ */
+// @vitest-environment jsdom
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { createElement, type ReactNode } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { act } from 'react-dom/test-utils'
+import { SideChatView } from '../src/client/SideChatView.tsx'
+import { attachLocale } from '../src/client/locales.ts'
+import type { Context, SidebarSessionList } from '../src/context-types.ts'
+import type { SidebarTab } from '../src/client/state.ts'
+
+;(globalThis as Record).IS_REACT_ACT_ENVIRONMENT = true
+
+/** Minimal structural fake of the DSH LocaleService face the sidebar uses. */
+class FakeLocale {
+ active: string = 'zh'
+ getSnapshot(): { active: string } {
+ return { active: this.active }
+ }
+ subscribe(_fn: () => void): () => void {
+ return () => {}
+ }
+ register(_ns: string, _locale: string, _dict: Record): () => void {
+ return () => {}
+ }
+}
+
+/** The thread's own (seed-cut) event log the sidechat.events stub serves. */
+const EVENTS = [
+ { type: 'session/end-seed', seq: 0, time: 0, data: {} },
+ { type: 'user/message', seq: 1, time: 1_000, data: { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, surfaceOp: 'append' },
+ { type: 'turn/start', seq: 2, time: 2_000, data: { turn: 1 } },
+ { type: 'tool/call', seq: 3, time: 3_000, data: { turn: 1, step: 1, callId: 'c1', name: 'bash', arguments: '{"command":"echo sidechat-ok-42"}' } },
+ {
+ type: 'tool/result',
+ seq: 4,
+ time: 4_000,
+ surfaceOp: 'append',
+ data: {
+ turn: 1,
+ step: 1,
+ message: {
+ source: { kind: 'tool', callId: 'c1' },
+ content: [{ type: 'tool-result', toolCallId: 'c1', content: [{ type: 'text', text: 'sidechat-ok-42\n[exit code: 0]' }] }],
+ },
+ },
+ },
+ {
+ type: 'assistant/message',
+ seq: 5,
+ time: 5_000,
+ surfaceOp: 'append',
+ data: { turn: 1, step: 2, message: { content: [{ type: 'text', text: 'done' }] }, usage: { inputTokens: 1_200, outputTokens: 345 } },
+ },
+ { type: 'turn/end', seq: 6, time: 6_000, data: { turn: 1, reason: 'completed' } },
+]
+
+/** A subscribable sessions-list snapshot (mirror of the runtime list feed). */
+function makeStore(initial: SidebarSessionList) {
+ let snapshot = initial
+ const listeners = new Set<() => void>()
+ return {
+ getSnapshot: (): SidebarSessionList => snapshot,
+ subscribe: (fn: () => void) => {
+ listeners.add(fn)
+ return () => { listeners.delete(fn) }
+ },
+ }
+}
+
+/** A settable connection-state snapshot (the alpha.2 ConnectionHandle face). */
+function makeConnection(initial: 'connected' | 'disconnected' | 'connecting') {
+ let snapshot: 'connected' | 'disconnected' | 'connecting' = initial
+ const listeners = new Set<() => void>()
+ return {
+ state: {
+ getSnapshot: () => snapshot,
+ subscribe: (fn: () => void) => {
+ listeners.add(fn)
+ return () => { listeners.delete(fn) }
+ },
+ },
+ reconnect: vi.fn(),
+ set(next: 'connected' | 'disconnected' | 'connecting'): void {
+ snapshot = next
+ for (const fn of [...listeners]) fn()
+ },
+ }
+}
+
+type Connection = ReturnType
+
+/** The client context face SideChatView touches (everything inert but list). */
+function makeCtx(store: ReturnType, connection?: Connection): Context {
+ return {
+ sessions: { list: store },
+ ...(connection !== undefined ? { connection } : {}),
+ get: (key: string) => (key === 'betterSidebar' ? { updateTab: vi.fn(), openTab: vi.fn() } : undefined),
+ } as unknown as Context
+}
+
+function jsonResponse(value: unknown): Response {
+ return { ok: true, status: 200, json: async () => value } as unknown as Response
+}
+
+/** Render `node` into a detached body container under React's act(). */
+function mount(node: ReactNode): { container: HTMLDivElement; unmount: () => void } {
+ const container = document.createElement('div')
+ document.body.append(container)
+ const root: Root = createRoot(container)
+ act(() => { root.render(node) })
+ const unmount = (): void => {
+ act(() => { root.unmount() })
+ container.remove()
+ }
+ return { container, unmount }
+}
+
+let eventsPulls = 0
+
+beforeEach(() => {
+ attachLocale(new FakeLocale())
+ eventsPulls = 0
+ vi.stubGlobal('fetch', async (url: string | URL) => {
+ const method = String(url).split('/').pop()
+ if (method === 'sidechat.events') {
+ eventsPulls += 1
+ return jsonResponse({ ok: true, value: { events: EVENTS } })
+ }
+ if (method === 'sidechat.info') {
+ return jsonResponse({ ok: true, value: { live: false, provider: 'deepseek', preset: 'side' } })
+ }
+ return jsonResponse({ ok: true, value: {} })
+ })
+})
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+ attachLocale(undefined)
+})
+
+/** The bound-thread view props (tab meta pins threadId 't1'). */
+function viewProps(ctx: Context) {
+ const tab: SidebarTab = { id: 'tab1', type: 'sidechat', title: '线程一', meta: { threadId: 't1' } }
+ return { ctx, scope: { sessionId: 'root', cwd: '/p' }, tab, visible: true }
+}
+
+/** The sessions-list snapshot with one idle side thread bound to 'root'. */
+function threadStore(): ReturnType {
+ return makeStore({
+ current: 'root',
+ byId: {
+ root: { id: 'root', displayTitle: '主会话', running: false },
+ t1: { id: 't1', displayTitle: 'Side: 线程一', origin: 'subagent', parentId: 'root', running: false },
+ },
+ subagentsByParent: {},
+ jobsBySession: {},
+ })
+}
+
+describe('SideChatView rendering', () => {
+ it('renders the bash pair as a TerminalBlock and the turn tail with usage + duration', async () => {
+ const props = viewProps(makeCtx(threadStore()))
+ const { container, unmount } = mount(createElement(SideChatView, props))
+ await act(async () => {}) // flush the initial transcript pull
+ const text = container.textContent ?? ''
+ // Terminal surface: the command line and the marker-stripped output body.
+ expect(text).toContain('echo sidechat-ok-42')
+ expect(text).toContain('sidechat-ok-42')
+ // Turn tail: 1.2K/345 compaction + 4s envelope-time duration.
+ expect(text).toContain('输入 1.2K tok · 输出 345 tok · 4s')
+ unmount()
+ })
+
+ it('shows the disconnect banner, reconnects on click, and pulls immediately on recovery', async () => {
+ const connection = makeConnection('disconnected')
+ const props = viewProps(makeCtx(threadStore(), connection))
+ const { container, unmount } = mount(createElement(SideChatView, props))
+ await act(async () => {}) // flush the initial transcript pull
+ expect(container.textContent ?? '').toContain('连接已断开')
+
+ const button = container.querySelector('button[aria-label="立即重连"]')
+ expect(button).not.toBeNull()
+ act(() => { button?.click() })
+ expect(connection.reconnect).toHaveBeenCalledTimes(1)
+
+ // Recovery hides the banner and triggers an immediate transcript pull.
+ const pullsBefore = eventsPulls
+ act(() => { connection.set('connected') })
+ expect(container.textContent ?? '').not.toContain('连接已断开')
+ expect(eventsPulls).toBeGreaterThan(pullsBefore)
+ unmount()
+ })
+
+ it('renders no banner without a connection service or while connected', async () => {
+ const absent = mount(createElement(SideChatView, viewProps(makeCtx(threadStore()))))
+ expect(absent.container.textContent ?? '').not.toContain('连接已断开')
+ absent.unmount()
+
+ const connection = makeConnection('connected')
+ const connected = mount(createElement(SideChatView, viewProps(makeCtx(threadStore(), connection))))
+ expect(connected.container.textContent ?? '').not.toContain('连接已断开')
+ connected.unmount()
+ })
+})