From 561ad660715445abc508fbf65936dddc51774108 Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Mon, 31 Aug 2026 22:15:49 +0800 Subject: [PATCH] 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
: 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.
---
 src/client/SideChatView.module.css |  17 +++
 src/client/SideChatView.tsx        | 136 ++++++++++++++++--
 src/client/index.tsx               |   6 +-
 src/client/locales-ar.ts           |  19 +++
 src/client/locales-de.ts           |  19 +++
 src/client/locales-fr.ts           |  19 +++
 src/client/locales-hi.ts           |  19 +++
 src/client/locales-id.ts           |  19 +++
 src/client/locales-it.ts           |  19 +++
 src/client/locales-ja.ts           |  19 +++
 src/client/locales-ko.ts           |  19 +++
 src/client/locales-nl.ts           |  19 +++
 src/client/locales-pl.ts           |  19 +++
 src/client/locales-pt.ts           |  19 +++
 src/client/locales-ru.ts           |  19 +++
 src/client/locales-sv.ts           |  19 +++
 src/client/locales-th.ts           |  19 +++
 src/client/locales-tr.ts           |  19 +++
 src/client/locales-vi.ts           |  19 +++
 src/client/locales-zh-HK.ts        |  19 +++
 src/client/locales-zh-MO.ts        |  19 +++
 src/client/locales-zh-TW.ts        |  19 +++
 src/client/locales.ts              |  38 +++++
 src/client/sidechat-transcript.ts  | 219 ++++++++++++++++++++++++++++-
 src/context-types.ts               |  13 ++
 tests/sidechat-transcript.spec.ts  | 190 ++++++++++++++++++++++++-
 tests/sidechat-view.spec.tsx       | 212 ++++++++++++++++++++++++++++
 27 files changed, 1175 insertions(+), 17 deletions(-)
 create mode 100644 tests/sidechat-view.spec.tsx

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() + }) +})