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:
@@ -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
@@ -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>}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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} قيد التشغيل',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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} चल रहे',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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} 個実行中',
|
||||
|
||||
@@ -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} 실행 중',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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} выполняется',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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} กำลังทำงาน',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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} 執行中',
|
||||
|
||||
@@ -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} 執行中',
|
||||
|
||||
@@ -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} 執行中',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 !== '') {
|
||||
|
||||
@@ -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
|
||||
/**
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user