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
+17
View File
@@ -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(
+123 -13
View File
@@ -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 (
<TerminalBlock
command={card.command}
cwd={card.cwd}
output={card.output}
exitCode={card.exitCode}
signal={card.signal}
running={executing}
labels={labels.terminal}
/>
)
}
if (card.type === 'diff') {
return <DiffBlock diffs={card.diffs} labels={labels.diff} />
}
return <ReadBlock label={card.label} lines={card.lines} totalLines={card.totalLines} lang={card.lang} labels={labels.read} />
}
/** 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
<div className={css.sidechatRowProse}>{row.text}</div>
</CollapsibleRow>
)
case 'tool': {
const body = (
<>
{row.args !== undefined && <pre className={css.sidechatRowCode}>{row.args}</pre>}
{row.resultText !== undefined && <pre className={css.sidechatRowCode}>{row.resultText}</pre>}
</>
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 (
<div key={`${row.kind}:${row.seq}`} className={css.sidechatTurnSummary}>
{parts.join(' · ')}
</div>
)
}
case 'tool': {
const body = row.card !== undefined
? toolCardBody(row.card, row.executing === true, labels)
: (
<>
{row.args !== undefined && <pre className={css.sidechatRowCode}>{row.args}</pre>}
{row.resultText !== undefined && <pre className={css.sidechatRowCode}>{row.resultText}</pre>}
</>
)
return (
<CollapsibleRow
key={`${row.kind}:${row.seq}`}
@@ -222,7 +278,7 @@ function renderRow(row: SidechatTranscriptRow, labels: RowLabels): React.ReactNo
mono
streaming={row.executing === true}
failed={row.failed}
{...(row.args === undefined && row.resultText === undefined ? {} : { children: body })}
{...(row.args === undefined && row.resultText === undefined && row.card === undefined ? {} : { children: body })}
/>
)
}
@@ -237,15 +293,35 @@ export function SideChatView(props: {
visible: boolean
}): React.ReactNode {
const { ctx, scope, tab, visible } = props
const rowLabels = useMemo<RowLabels>(
() => ({
const rowLabels = useMemo<RowLabels>(() => {
// 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: {
<IconSaveOutline16 />
</button>
</div>
{connectionState !== undefined && connectionState !== 'connected' && (
<ConnectionIndicator
state={connectionState}
disconnectedLabel={t('sideChatConnDisconnected')}
reconnectLabel={t('sideChatConnReconnect')}
connectingLabel={t('sideChatConnConnecting')}
recoveredLabel={t('sideChatConnRecovered')}
reconnectActionLabel={t('sideChatConnReconnectAction')}
restartActionLabel={t('sideChatConnRestartAction')}
onReconnect={() => { ctx.connection?.reconnect() }}
/>
)}
{!canSave && !freshThread && <div className={css.sidechatHint}>{t('sideChatNoTurn')}</div>}
{canSave && trailingPending
&& <div className={css.sidechatHint}>{t('sideChatPendingDrop')}</div>}
+4 -2
View File
@@ -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
+19
View File
@@ -351,6 +351,25 @@ export const ar: Record<string, string> = {
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} قيد التشغيل',
+19
View File
@@ -336,6 +336,25 @@ export const de: Record<string, string> = {
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',
+19
View File
@@ -343,6 +343,25 @@ export const fr: Record<string, string> = {
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',
+19
View File
@@ -350,6 +350,25 @@ export const hi: Record<string, string> = {
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} चल रहे',
+19
View File
@@ -348,6 +348,25 @@ export const id: Record<string, string> = {
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',
+19
View File
@@ -341,6 +341,25 @@ export const it: Record<string, string> = {
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',
+19
View File
@@ -350,6 +350,25 @@ export const ja: Record<string, string> = {
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} 個実行中',
+19
View File
@@ -342,6 +342,25 @@ export const ko: Record<string, string> = {
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} 실행 중',
+19
View File
@@ -348,6 +348,25 @@ export const nl: Record<string, string> = {
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',
+19
View File
@@ -352,6 +352,25 @@ export const pl: Record<string, string> = {
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',
+19
View File
@@ -333,6 +333,25 @@ export const pt: Record<string, string> = {
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',
+19
View File
@@ -348,6 +348,25 @@ export const ru: Record<string, string> = {
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} выполняется',
+19
View File
@@ -333,6 +333,25 @@ export const sv: Record<string, string> = {
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',
+19
View File
@@ -350,6 +350,25 @@ export const th: Record<string, string> = {
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} กำลังทำงาน',
+19
View File
@@ -350,6 +350,25 @@ export const tr: Record<string, string> = {
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',
+19
View File
@@ -350,6 +350,25 @@ export const vi: Record<string, string> = {
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',
+19
View File
@@ -365,6 +365,25 @@ export const zhHK: Record<string, string> = {
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} 執行中',
+19
View File
@@ -365,6 +365,25 @@ export const zhMO: Record<string, string> = {
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} 執行中',
+19
View File
@@ -365,6 +365,25 @@ export const zhTW: Record<string, string> = {
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} 執行中',
+38
View File
@@ -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<keyof typeof zh, string> = {
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',
+218 -1
View File
@@ -17,9 +17,29 @@
* reasoning deltas. The mapping accumulates both per block and supersedes
* them with the assembled message once it lands (settled rows).
*/
import type { DiffHunk, ReadBlockLine } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SidebarHistoryEntry } from '../context-types.ts'
import { isContextInjectionMessage, SIDE_BOUNDARY_PROMPT } from '../sidechat-core.ts'
/**
* Structured render payload for a tool row that maps onto one of the host's
* ui-primitives Blocks (the same atoms the main conversation renders). Derived
* defensively from raw `tool/call` arguments and `tool/result` `meta` — the
* producing tool owns the meta shape, so every field is narrowed and any
* malformed input silently falls back to the generic text row.
*/
export type SidechatToolCard =
| {
type: 'terminal'
command: string
cwd?: string
output?: string
exitCode?: number
signal?: string
}
| { type: 'diff'; diffs: DiffHunk[] }
| { type: 'read'; label: string; lines: ReadBlockLine[]; totalLines: number; lang?: string }
/** One compact transcript row rendered in the thread view. `seq` is the
* source event's log sequence — stable row identity for React keys across
* polls (streaming caches ride the key, so window slides must not re-key
@@ -45,7 +65,31 @@ export type SidechatTranscriptRow =
resultText?: string
/** True while the call's result has not landed yet. */
executing?: boolean
/** Structured render payload (host Block atoms); absent = generic row. */
card?: SidechatToolCard
}
/** One turn's tail metrics, emitted at `turn/end`: the turn's token usage
* (aggregated from `assistant/message.usage`) and wall duration (envelope
* time delta from `turn/start`), whichever are computable. */
| { kind: 'turnSummary'; seq: number; inputTokens?: number; outputTokens?: number; durationMs?: number }
/** Compact token count the way the main conversation prints usage
* (517 / 12.2K / 1.2M — the host's own formatter is not exported). */
export function formatTokens(n: number): string {
const scaled = (v: number): string => (v >= 100 ? String(Math.round(v)) : String(Math.round(v * 10) / 10))
if (n < 1_000) return String(n)
if (n < 1_000_000) return `${scaled(n / 1_000)}K`
return `${scaled(n / 1_000_000)}M`
}
/** Compact duration the way the main conversation prints run times
* (45.2s / 2m42s — sub-minute keeps one decimal). */
export function formatDurationMs(ms: number): string {
const seconds = ms / 1_000
if (seconds < 60) return `${Math.round(seconds * 10) / 10}s`
const whole = Math.round(seconds)
return `${Math.floor(whole / 60)}m${whole % 60}s`
}
/** Extract the visible text of a content-block list (`text` blocks verbatim,
* joined by blank lines); empty reads `…` so rows never render blank. */
@@ -127,6 +171,131 @@ function lastSeedEnd(events: readonly { type: string }[]): number {
return -1
}
/** Parse the tool's raw arguments JSON to an object, or undefined. */
function parseArgsObject(args: string | undefined): Record<string, unknown> | undefined {
if (args === undefined) return undefined
try {
const parsed = JSON.parse(args) as unknown
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined
return parsed as Record<string, unknown>
} catch {
return undefined
}
}
/**
* Call-time card from the raw `tool/call` arguments — the same literal cards
* the host's own presenters derive before any result exists: bash shows the
* command line (foreground only), edit/write show the literal replacement.
* `read` has no call-time window (its structure only exists in the result).
*/
function callCard(name: string, args: string | undefined): SidechatToolCard | undefined {
const parsed = parseArgsObject(args)
if (parsed === undefined) return undefined
if (name === 'bash') {
const command = typeof parsed.command === 'string' && parsed.command !== '' ? parsed.command : undefined
if (command === undefined || parsed.run_in_background === true) return undefined
const cwd = typeof parsed.workdir === 'string' && parsed.workdir !== '' ? parsed.workdir : undefined
return { type: 'terminal', command, ...(cwd !== undefined ? { cwd } : {}) }
}
if (name === 'edit' || name === 'write') {
const path = typeof parsed.file_path === 'string' && parsed.file_path !== '' ? parsed.file_path : undefined
if (path === undefined) return undefined
if (name === 'edit') {
const oldText = typeof parsed.old_string === 'string' ? parsed.old_string : ''
const newText = typeof parsed.new_string === 'string' ? parsed.new_string : ''
return { type: 'diff', diffs: [{ path, oldText: oldText === '' ? null : oldText, newText }] }
}
const newText = typeof parsed.content === 'string' ? parsed.content : ''
return { type: 'diff', diffs: [{ path, oldText: null, newText }] }
}
return undefined
}
/** The `tool/result` `meta` as an object, or undefined (opaque tool payload). */
function metaObject(data: Record<string, unknown>): Record<string, unknown> | undefined {
const meta = data.meta
if (meta === null || typeof meta !== 'object' || Array.isArray(meta)) return undefined
return meta as Record<string, unknown>
}
/** Narrow edit/write's contextual-diff meta (`{ diffs: FileDiff[] }`) to
* DiffHunk[], declining on any malformed entry. */
function diffCardFromMeta(meta: Record<string, unknown>): SidechatToolCard | undefined {
const diffs = meta.diffs
if (!Array.isArray(diffs) || diffs.length === 0) return undefined
const hunks: DiffHunk[] = []
for (const item of diffs) {
if (item === null || typeof item !== 'object' || Array.isArray(item)) return undefined
const { path, oldText, newText } = item as Record<string, unknown>
if (typeof path !== 'string' || typeof newText !== 'string') return undefined
if (oldText !== null && typeof oldText !== 'string') return undefined
hunks.push({ path, oldText, newText })
}
return { type: 'diff', diffs: hunks }
}
/** Narrow read's line-window meta (`{ path, offset, lines, totalLines, lang? }`)
* to a read card, enforcing the same semantic contract the host does: 1-based
* strictly increasing line numbers that never exceed `totalLines`. */
function readCardFromMeta(meta: Record<string, unknown>): SidechatToolCard | undefined {
const { path, offset, lines, totalLines, lang } = meta
if (typeof path !== 'string' || typeof offset !== 'number' || typeof totalLines !== 'number') return undefined
if (!Number.isInteger(offset) || offset < 1) return undefined
if (!Number.isInteger(totalLines) || totalLines < 0) return undefined
if (!Array.isArray(lines)) return undefined
const narrowed: ReadBlockLine[] = []
let previous = offset - 1
for (const line of lines) {
if (line === null || typeof line !== 'object' || Array.isArray(line)) return undefined
const candidate = line as { number?: unknown; text?: unknown }
if (typeof candidate.number !== 'number' || !Number.isInteger(candidate.number)) return undefined
if (candidate.number <= previous || candidate.number > totalLines) return undefined
if (typeof candidate.text !== 'string') return undefined
narrowed.push({ number: candidate.number, text: candidate.text })
previous = candidate.number
}
if (lang !== undefined && typeof lang !== 'string') return undefined
return { type: 'read', label: path, lines: narrowed, totalLines, ...(lang !== undefined ? { lang } : {}) }
}
/** The bash result's trailing exit markers (`[exit code: N]` /
* `[killed by signal: X]` — the model-facing text the tool appends), stripped
* the same way the host's parseExitStatus recovers the exit pill. */
const EXIT_SIGNAL_RE = /\n\[killed by signal: ([^\]\n]+)\]$/
const EXIT_CODE_RE = /\n\[exit code: (\d+)\]$/
/**
* Result-time card refinement: `meta`-carrying structure wins (edit/write's
* applied hunks, read's line window), bash's output gets its exit marker
* stripped into the exit pill, and a failed result always falls back to the
* generic row (the host's isError path renders generic output too).
*/
function resultCard(
name: string,
previous: SidechatToolCard | undefined,
data: Record<string, unknown>,
resultText: string,
): SidechatToolCard | undefined {
if (name === 'bash') {
if (previous === undefined || previous.type !== 'terminal' || resultText === '') return previous
const signal = EXIT_SIGNAL_RE.exec(resultText)
if (signal?.[1] !== undefined) {
return { ...previous, output: resultText.slice(0, signal.index), exitCode: undefined, signal: signal[1] }
}
const exit = EXIT_CODE_RE.exec(resultText)
if (exit?.[1] !== undefined) {
return { ...previous, output: resultText.slice(0, exit.index), exitCode: Number(exit[1]), signal: undefined }
}
return { ...previous, output: resultText, exitCode: 0, signal: undefined }
}
const meta = metaObject(data)
if (meta === undefined) return previous
if (name === 'edit' || name === 'write') return diffCardFromMeta(meta) ?? previous
if (name === 'read') return readCardFromMeta(meta)
return previous
}
/**
* Map a thread child's history rows onto compact transcript rows: the
* inherited fork seed is cut at the last `session/end-seed`, context
@@ -146,12 +315,40 @@ export function transcriptRows(entries: readonly SidebarHistoryEntry[]): Sidecha
const streamRows = new Map<string, number>()
/** tool callId → index of its tool row in `rows` (result pairing). */
const callRows = new Map<string, number>()
/** turn → envelope time of its `turn/start` (turn-tail duration basis). */
const turnStarts = new Map<number, number>()
/** turn → usage aggregate: output accumulates across steps, input takes the
* last request's prompt size (earlier steps' input is mostly the same
* context re-sent, so summing would double-count). */
const turnUsage = new Map<number, { inputTokens: number; outputTokens: number }>()
for (let index = 0; index < events.length; index++) {
if (index <= seedEnd) continue
const event = events[index]
if (event === undefined) continue
const data = event.data as Record<string, unknown>
switch (event.type) {
case 'turn/start': {
const turn = data.turn
if (typeof turn === 'number' && Number.isInteger(turn)) turnStarts.set(turn, event.time)
break
}
case 'turn/end': {
const turn = data.turn
if (typeof turn !== 'number' || !Number.isInteger(turn)) break
const start = turnStarts.get(turn)
turnStarts.delete(turn)
const usage = turnUsage.get(turn)
turnUsage.delete(turn)
const durationMs = start !== undefined ? Math.max(0, event.time - start) : undefined
if (usage === undefined && durationMs === undefined) break
rows.push({
kind: 'turnSummary',
seq: event.seq,
...(usage !== undefined ? { inputTokens: usage.inputTokens, outputTokens: usage.outputTokens } : {}),
...(durationMs !== undefined ? { durationMs } : {}),
})
break
}
case 'user/message': {
const text = blockText(Array.isArray(data.content) ? data.content : [])
// Context injections (the boundary prompt + snapshot, plugin-sourced
@@ -199,6 +396,19 @@ export function transcriptRows(entries: readonly SidebarHistoryEntry[]): Sidecha
break
}
case 'assistant/message': {
// Turn-tail usage: each assembled message carries its step's token
// accounting (absent when the adapter reported none).
const usageTurn = data.turn
if (typeof usageTurn === 'number' && Number.isInteger(usageTurn)) {
const usage = data.usage as { inputTokens?: unknown; outputTokens?: unknown } | undefined
const input = typeof usage?.inputTokens === 'number' ? usage.inputTokens : undefined
const output = typeof usage?.outputTokens === 'number' ? usage.outputTokens : undefined
if (input !== undefined && output !== undefined) {
const aggregate = turnUsage.get(usageTurn)
if (aggregate === undefined) turnUsage.set(usageTurn, { inputTokens: input, outputTokens: output })
else turnUsage.set(usageTurn, { inputTokens: input, outputTokens: aggregate.outputTokens + output })
}
}
const prefix = `${String(data.turn)}:${String(data.step)}:`
const streamed = [...streamRows.entries()]
.filter(([key]) => key.startsWith(prefix))
@@ -228,9 +438,10 @@ export function transcriptRows(entries: readonly SidebarHistoryEntry[]): Sidecha
const callId = data.callId
const name = typeof data.name === 'string' ? data.name : 'tool'
const args = typeof data.arguments === 'string' ? data.arguments : undefined
const card = callCard(name, args)
const rowIndex = rows.length
if (typeof callId === 'string') callRows.set(callId, rowIndex)
rows.push({ kind: 'tool', seq: event.seq, name, failed: false, args, executing: true })
rows.push({ kind: 'tool', seq: event.seq, name, failed: false, args, executing: true, ...(card !== undefined ? { card } : {}) })
break
}
case 'tool/result': {
@@ -242,11 +453,17 @@ export function transcriptRows(entries: readonly SidebarHistoryEntry[]): Sidecha
if (rowIndex !== undefined) {
const row = rows[rowIndex]
if (row !== undefined && row.kind === 'tool') {
// Failed results render the generic row (the host's isError path
// skips its structured cards too); otherwise the result's own
// structure — meta hunks/windows, bash's exit marker — refines or
// replaces the call-time literal card.
const card = failed ? undefined : resultCard(row.name, row.card, data, resultText)
rows[rowIndex] = {
...row,
failed: row.failed || failed,
resultText: resultText === '' ? row.resultText : resultText,
executing: false,
...(card !== undefined ? { card } : { card: undefined }),
}
}
} else if (failed || resultText !== '') {
+13
View File
@@ -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
/**
+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')
+212
View File
@@ -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<string, unknown>).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<string, string>): () => 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<typeof makeConnection>
/** The client context face SideChatView touches (everything inert but list). */
function makeCtx(store: ReturnType<typeof makeStore>, 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<typeof makeStore> {
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<HTMLButtonElement>('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()
})
})