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:
+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>}
|
||||
|
||||
Reference in New Issue
Block a user