feat(sidechat): main-conversation Blocks, turn usage tail, reconnect banner

Rendering-layer upgrade for the Side Chat transcript (data already rides
the raw events; the mapping just stopped discarding it):

- Tool rows with a derivable structure now render the host's
  ui-primitives atoms instead of a generic double <pre>: bash calls map
  onto TerminalBlock (command from call args, output + exit pill
  recovered from the result's trailing exit marker), edit/write map onto
  DiffBlock (call-time literal replacement, refined by the result meta's
  contextual hunks), read maps onto ReadBlock (line-window meta, same
  semantic narrowing the host applies). Every narrowing declines to the
  generic row on malformed input.
- turn/end now emits a turnSummary row: token usage aggregated from
  assistant/message.usage (output summed across steps, input from the
  last request) plus the envelope-time wall duration, printed with the
  main conversation's formatTokens/formatDurationMs cadence.
- The client entry injects 'connection' (DSH 0.1.2-alpha.2+) and the
  thread view shows the host's ConnectionIndicator while the Remote
  transport is down; the reconnect control calls reconnect() and a
  disconnected -> connected transition pulls the transcript immediately
  instead of waiting for the next poll tick.

19 new locale keys propagated across zh/en and all 19 third-language
dictionaries; unit coverage in sidechat-transcript.spec and a new
sidechat-view render spec (jsdom). Real-host mount smoke: 14/14.
This commit is contained in:
Menghuan1918
2026-08-31 23:28:20 +08:00
parent d70db8fb02
commit 561ad66071
27 changed files with 1175 additions and 17 deletions
+123 -13
View File
@@ -27,6 +27,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useSyncExternalStore } from 'react'
import clsx from 'clsx'
import {
ConnectionIndicator,
DiffBlock,
IconChevronRightOutline14,
IconNewChatOutline16,
IconPlusOutline16,
@@ -34,8 +36,13 @@ import {
IconStopFill16,
MarkdownText,
Menu,
ReadBlock,
StateDot,
TerminalBlock,
type DiffBlockLabels,
type MenuEntry,
type ReadBlockLabels,
type TerminalBlockLabels,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { markdownTextProps } from './markdown-labels.tsx'
import { IconHistoryOutline16, IconSaveOutline16 } from './icons.tsx'
@@ -48,7 +55,14 @@ import {
threadTrailingPending,
type SidechatThreadInfo,
} from '../sidechat-core.ts'
import { toolArgsSummary, transcriptRows, type SidechatTranscriptRow } from './sidechat-transcript.ts'
import {
formatDurationMs,
formatTokens,
toolArgsSummary,
transcriptRows,
type SidechatToolCard,
type SidechatTranscriptRow,
} from './sidechat-transcript.ts'
import { api } from './api.ts'
import { t } from './locales.ts'
import type { SessionScope } from './api.ts'
@@ -100,6 +114,9 @@ interface RowLabels {
copiedLabel: string
thinkLabel: string
injectionLabel: string
terminal: TerminalBlockLabels
diff: DiffBlockLabels
read: ReadBlockLabels
}
/** Merge history entries by event seq (newest wins), log order preserved. */
@@ -176,6 +193,28 @@ function CollapsibleRow(props: {
)
}
/** The host Block body for a structured tool card (main-conversation atoms:
* terminal surface, diff hunks, line-numbered read window). */
function toolCardBody(card: SidechatToolCard, executing: boolean, labels: RowLabels): React.ReactNode {
if (card.type === 'terminal') {
return (
<TerminalBlock
command={card.command}
cwd={card.cwd}
output={card.output}
exitCode={card.exitCode}
signal={card.signal}
running={executing}
labels={labels.terminal}
/>
)
}
if (card.type === 'diff') {
return <DiffBlock diffs={card.diffs} labels={labels.diff} />
}
return <ReadBlock label={card.label} lines={card.lines} totalLines={card.totalLines} lang={card.lang} labels={labels.read} />
}
/** One row renderer (React keys ride the source event seq). */
function renderRow(row: SidechatTranscriptRow, labels: RowLabels): React.ReactNode {
switch (row.kind) {
@@ -207,13 +246,30 @@ function renderRow(row: SidechatTranscriptRow, labels: RowLabels): React.ReactNo
<div className={css.sidechatRowProse}>{row.text}</div>
</CollapsibleRow>
)
case 'tool': {
const body = (
<>
{row.args !== undefined && <pre className={css.sidechatRowCode}>{row.args}</pre>}
{row.resultText !== undefined && <pre className={css.sidechatRowCode}>{row.resultText}</pre>}
</>
case 'turnSummary': {
// Quiet turn-tail metrics: token usage + wall duration, main-pane
// StatsLine formatting. Parts appear only when computable.
const parts: string[] = []
if (row.inputTokens !== undefined && row.outputTokens !== undefined) {
parts.push(t('sideChatTurnUsage', { input: formatTokens(row.inputTokens), output: formatTokens(row.outputTokens) }))
}
if (row.durationMs !== undefined) parts.push(formatDurationMs(row.durationMs))
if (parts.length === 0) return null
return (
<div key={`${row.kind}:${row.seq}`} className={css.sidechatTurnSummary}>
{parts.join(' · ')}
</div>
)
}
case 'tool': {
const body = row.card !== undefined
? toolCardBody(row.card, row.executing === true, labels)
: (
<>
{row.args !== undefined && <pre className={css.sidechatRowCode}>{row.args}</pre>}
{row.resultText !== undefined && <pre className={css.sidechatRowCode}>{row.resultText}</pre>}
</>
)
return (
<CollapsibleRow
key={`${row.kind}:${row.seq}`}
@@ -222,7 +278,7 @@ function renderRow(row: SidechatTranscriptRow, labels: RowLabels): React.ReactNo
mono
streaming={row.executing === true}
failed={row.failed}
{...(row.args === undefined && row.resultText === undefined ? {} : { children: body })}
{...(row.args === undefined && row.resultText === undefined && row.card === undefined ? {} : { children: body })}
/>
)
}
@@ -237,15 +293,35 @@ export function SideChatView(props: {
visible: boolean
}): React.ReactNode {
const { ctx, scope, tab, visible } = props
const rowLabels = useMemo<RowLabels>(
() => ({
const rowLabels = useMemo<RowLabels>(() => {
// Shared Block chrome: copy buttons reuse the sidebar's copy pair, the
// collapse/expand family is common to every Block kind.
const shared = {
copy: t('copy'),
copied: t('copied'),
collapse: t('sideChatBlockCollapse'),
collapseAria: t('sideChatBlockCollapseAria'),
expand: (hidden: number) => t('sideChatBlockExpand', { hidden }),
expandAria: (hidden: number) => t('sideChatBlockExpandAria', { hidden }),
}
return {
copyLabel: t('copy'),
copiedLabel: t('copied'),
thinkLabel: t('sideChatThink'),
injectionLabel: t('sideChatInjection'),
}),
[],
)
terminal: {
...shared,
signal: (signal: string) => t('sideChatBlockSignal', { signal }),
exitCode: (exitCode: number) => t('sideChatBlockExitCode', { code: exitCode }),
running: t('sideChatBlockRunning'),
failed: t('sideChatBlockFailed'),
done: t('sideChatBlockDone'),
noOutput: t('sideChatBlockNoOutput'),
},
diff: { ...shared, files: (count: number) => t('sideChatBlockFiles', { count }) },
read: { ...shared, window: (shown: number, total: number) => t('sideChatBlockWindow', { shown, total }) },
}
}, [])
// The session list feed: thread rows (the header menu) + running states.
const list = useSyncExternalStore(
@@ -277,6 +353,15 @@ export function SideChatView(props: {
const summary = threadId === undefined ? undefined : list.byId[threadId]
const running = summary?.running === true
// Connection recovery state (DSH 0.1.2-alpha.2+): drives the disconnect
// banner and an immediate catch-up pull when the wire comes back. The poll
// loop itself stays silent on wire failures; absent service (older host)
// reads as `undefined` = never show the banner.
const connectionState = useSyncExternalStore(
useMemo(() => (callback: () => void) => ctx.connection?.state.subscribe(callback) ?? (() => {}), [ctx]),
useCallback(() => ctx.connection?.state.getSnapshot(), [ctx]),
)
/** The agent-identity badge of the thread header (preset · model). */
const agentBadge = useMemo(() => {
if (info === null) return ''
@@ -382,6 +467,19 @@ export function SideChatView(props: {
useEffect(() => () => { controllerRef.current?.abort() }, [])
// When the wire recovers (disconnected → connected), pull immediately
// instead of waiting for the next poll tick — or, on an idle thread that
// stopped polling, forever.
const prevConnectionRef = useRef(connectionState)
useEffect(() => {
const previous = prevConnectionRef.current
prevConnectionRef.current = connectionState
if (previous === 'disconnected' && connectionState === 'connected' && threadId !== undefined) {
void fetchThread(threadId)
void fetchInfo(threadId)
}
}, [connectionState, threadId, fetchThread, fetchInfo])
const rows = useMemo(
() => (threadId === undefined ? [] : transcriptRows(cacheRef.current.entries)),
// The cache is a ref; revision bumps on every successful pull.
@@ -560,6 +658,18 @@ export function SideChatView(props: {
<IconSaveOutline16 />
</button>
</div>
{connectionState !== undefined && connectionState !== 'connected' && (
<ConnectionIndicator
state={connectionState}
disconnectedLabel={t('sideChatConnDisconnected')}
reconnectLabel={t('sideChatConnReconnect')}
connectingLabel={t('sideChatConnConnecting')}
recoveredLabel={t('sideChatConnRecovered')}
reconnectActionLabel={t('sideChatConnReconnectAction')}
restartActionLabel={t('sideChatConnRestartAction')}
onReconnect={() => { ctx.connection?.reconnect() }}
/>
)}
{!canSave && !freshThread && <div className={css.sidechatHint}>{t('sideChatNoTurn')}</div>}
{canSave && trailingPending
&& <div className={css.sidechatHint}>{t('sideChatPendingDrop')}</div>}