fix: docs
This commit is contained in:
imccyu
2026-07-30 15:56:41 +08:00
parent 085383b145
commit 1e10966ef6
160 changed files with 2521 additions and 1135 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
README.md: bfb56dc52406e1377cd84c87866a644ade10293a
README.zh.md: e09bdcc4bebd8176a3061b221e07ae73a7a8941c
README.md: b4b1e5653705c76bac3e0227e6df77143a11cbbe
README.zh.md: 74e0f3dc0ebaf74e2e065c6b88f3a30fce94b391

View File

@@ -24,7 +24,7 @@ The todo surfaces are two registrations over that shape, both plain registrant p
Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks.
The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `command.hint` locale namespace this package registers and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar renders inert (machine faces absent, `disabled` owner prop) instead of swapping in a parallel disabled tree, so the textarea DOM survives the workspace pick; the strict-session control seats simply stay empty until a session exists.
The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `conversation` locale namespace this package registers (the `placeholder.plan` / `hint.plan` keys) and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar renders inert (machine faces absent, `disabled` owner prop) instead of swapping in a parallel disabled tree, so the textarea DOM survives the workspace pick; the strict-session control seats simply stay empty until a session exists.
`src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath).

View File

@@ -24,7 +24,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。
输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `command.hint` locale 命名空间本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 以不可交互状态渲染(machine face 均缺席、`disabled` owner prop),而不是换入一棵平行的 disabled 树,因此选择 workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。
输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `conversation` locale 命名空间(`placeholder.plan` / `hint.plan` 键)本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 以不可交互状态渲染(machine face 均缺席、`disabled` owner prop),而不是换入一棵平行的 disabled 树,因此选择 workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。
`src/client/` 按未来的包拆分组织:`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明 + 组合后的 slot props,包括工具行契约、`views.ts` 共享原语、`tool-call-model.ts`);`skeleton/`、`chat/` 和 `toolviews/`(示例注册方)领域目录只导入 contract 文件,彼此绝不导入;`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply`/`inject`、两个服务类和 `contract/` 类型家族;实现组件(骨架、聊天行)与 store factory 保持内部状态,只能通过 apply 的 slot 注册到达页面(测试通过 `./src/*` 子路径获取它们)。

View File

@@ -1,6 +1,6 @@
/** Registers the conversation components, shared store, and service callbacks. */
import type { Context } from 'cordis'
import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
import { resolveSlotLabel, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
import type { ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
@@ -28,6 +28,14 @@ import { queueDockEntry } from './queue/QueueDock.tsx'
import { ConversationRoot } from './skeleton/ConversationRoot.tsx'
import { ConversationSession } from './skeleton/ConversationSession.tsx'
import { DetailsPanel } from './skeleton/DetailsPanel.tsx'
import { en, NS, zh, type ConversationKey } from './locales.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
/** The conversation surfaces' copy (skeleton, chat view, toolviews, docks). */
conversation: ConversationKey
}
}
/** Services required by the conversation plugin. */
export const inject = ['slots', 'layout', 'sessions', 'workspaces', 'locale']
@@ -68,32 +76,12 @@ export function apply(ctx: Context): void {
const layout = ctx.layout
const slots = ctx.slots
// Command hint locale: friendly placeholder text for claimed commands. The
// claimed /plan hint and the plan-mode textarea placeholder share one
// string: both describe the same next action.
const HINT_NS = 'command.hint'
const PLAN_HINT_ZH = '描述你的任务以生成计划'
const PLAN_HINT_EN = 'describe your task to generate plan'
ctx.effect(() => {
const disposers = [
ctx.locale.register(HINT_NS, 'zh', {
plan: PLAN_HINT_ZH,
goal: '输入目标,智能体将持续执行',
'goal.active': '当前目标进行中。可输入 edit 修改 / pause 暂停 / resume 继续 / clear 清除',
'placeholder.plan': PLAN_HINT_ZH,
'placeholder.default': '给智能体发消息',
}),
ctx.locale.register(HINT_NS, 'en', {
plan: PLAN_HINT_EN,
goal: 'describe the objective for a long-running task',
'goal.active': 'goal active — edit / pause / resume / clear',
'placeholder.plan': PLAN_HINT_EN,
'placeholder.default': 'Message the agent',
}),
]
return () => { for (const dispose of disposers) dispose() }
}, 'ui-conversation: command hint dictionaries')
const translateHint = ctx.locale.bind(HINT_NS)
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-conversation: dictionaries')
// Registration-time text (the view tab label) reads through the bound
// translate as a thunk, so it follows the active locale without
// re-registration; components read the standard `t` seat instead.
const t = ctx.locale.bind(NS)
// Apply-time construction keeps store identity bound to this fiber.
const chatStore = createChatStore()
@@ -103,7 +91,7 @@ export function apply(ctx: Context): void {
for (const entry of slots.entries('conversation.view')) {
/* v8 ignore next -- unreachable: list registration validates id at load. */
if (entry.options.id === undefined) continue
tabs.push({ id: entry.options.id, label: entry.options.label ?? entry.options.id })
tabs.push({ id: entry.options.id, label: resolveSlotLabel(entry.options.label) ?? entry.options.id })
}
return tabs
}
@@ -132,6 +120,7 @@ export function apply(ctx: Context): void {
// frame while strict session slots fill only their session-bound regions.
slots.register({
name: 'conversation',
locale: NS,
children: {
'conversation.session': { kind: 'single', scope: 'session' },
'conversation.composer': { kind: 'chain', scope: 'session' },
@@ -163,6 +152,7 @@ export function apply(ctx: Context): void {
// the resident parent keeps Hero and composer layout identity stable.
slots.register({
name: 'conversation.session',
locale: NS,
children: { 'conversation.view': { kind: 'list', scope: 'session' } },
store: chatStore,
inject: (sessionId: SessionId, _actions: BoundActions<typeof chatStore>): ConversationSessionInjected => ({
@@ -185,6 +175,7 @@ export function apply(ctx: Context): void {
// observableHook caching and hook order stay stable across transitions).
slots.register({
name: 'conversation.composer.bar',
locale: NS,
// The two named control seats in the bar's tool row (plan beside the
// access control, model right); empty until their owning plugins
// register (B ruling).
@@ -198,7 +189,6 @@ export function apply(ctx: Context): void {
keyboard: undefined,
stop: undefined,
command: undefined,
translateHint,
hooks: { notices: ABSENT_NOTICES, lexicon: ABSENT_LEXICON },
}
}
@@ -216,7 +206,6 @@ export function apply(ctx: Context): void {
const result = await session.command(line)
return result.ok && result.value.matched
},
translateHint,
hooks: { notices: shell.notices, lexicon: shell.lexicon },
}
},
@@ -230,7 +219,7 @@ export function apply(ctx: Context): void {
// pending — a question is a conversation the model is waiting on, while an
// approval only blocks one tool call; answering the question first cannot
// strand the approval (it re-elects the moment the question resolves).
slots.register({ name: 'conversation.composer', select: selectApproval, priority: 1 }, ApprovalPanel)
slots.register({ name: 'conversation.composer', select: selectApproval, priority: 1, locale: NS }, ApprovalPanel)
// The chat view: first entry of the ring this package just declared.
// Declaring the keyed toolview hole here is claiming it: ChatView is the
@@ -241,7 +230,8 @@ export function apply(ctx: Context): void {
name: 'conversation.view',
id: 'chat',
order: 0,
label: 'Chat',
label: () => t('view.chat'),
locale: NS,
children: {
'conversation.chat.toolview': { kind: 'keyed', scope: 'session' },
'conversation.chat.commandview': { kind: 'keyed', scope: 'session' },
@@ -303,6 +293,7 @@ export function apply(ctx: Context): void {
slots.register({
name: 'details',
locale: NS,
store: chatStore,
inject: (): DetailsInjected => ({
closeDetails: () => { layout.closeDetails() },

View File

@@ -8,11 +8,12 @@
// ends (`time` is omitted for mid-turn narration); Think / tool-head-only
// nodes stay chrome-free.
import { memo } from 'react'
import { memo, useMemo } from 'react'
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
import {
IconThinkOutline14, JsonBlock, MarkdownText,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { MessageIconActions } from './MessageIconActions.tsx'
import { ToolRow } from './ToolRow.tsx'
import css from './AssistantMarkdown.module.css'
@@ -20,7 +21,7 @@ import css from './AssistantMarkdown.module.css'
export interface AssistantMarkdownProps {
blocks: readonly AssistantBlock[]
streaming: boolean
/** Frozen partial of an aborted turn: rendered with a 已停止 marker. */
/** Frozen partial of an aborted turn: rendered with a stopped marker. */
interrupted?: boolean | undefined
/** Unix epoch ms for the IconActions clock; omitted while streaming or when
* the parent withholds chrome (mid-turn content assistants). */
@@ -29,6 +30,8 @@ export interface AssistantMarkdownProps {
seq?: number | undefined
/** Fork the session through the turn containing this finalized message. */
onFork?: ((seq: number) => void) | undefined
/** The owning view's locale seat, passed down as a plain prop. */
t: ChatViewSlotProps['t']
}
function firstLine(text: string): string {
@@ -51,9 +54,10 @@ function hasContentText(blocks: readonly AssistantBlock[]): boolean {
}
/** Reasoning block as the Think variant summary row (figma 39:28304). */
function ThinkRow({ text, running }: { text: string; running: boolean }) {
function ThinkRow({ text, running, t }: { text: string; running: boolean; t: AssistantMarkdownProps['t'] }) {
return (
<ToolRow
t={t}
variant="think"
icon={<IconThinkOutline14 size={14} />}
title="Think"
@@ -66,8 +70,11 @@ function ThinkRow({ text, running }: { text: string; running: boolean }) {
}
export const AssistantMarkdown = memo(function AssistantMarkdown({
blocks, streaming, interrupted, time, seq, onFork,
blocks, streaming, interrupted, time, seq, onFork, t,
}: AssistantMarkdownProps) {
// Stable per locale revision (t identity changes on switch): a fresh object
// per render would rebuild MarkdownText's component table every chunk.
const codeLabels = useMemo(() => ({ copyLabel: t('copy'), copiedLabel: t('copied') }), [t])
const last = blocks.length - 1
// Tool-call heads render as tool rows in the chat view's grouping pass, so
// a node that is only those heads (or empty) would paint an empty root
@@ -83,14 +90,23 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
<div className={css.body}>
{blocks.map((block, i) => {
switch (block.kind) {
case 'text': return <MarkdownText key={i} text={block.text} streaming={streaming} />
case 'reasoning': return <ThinkRow key={i} text={block.text} running={streaming && i === last} />
case 'text': return (
<MarkdownText key={i} text={block.text} streaming={streaming} codeLabels={codeLabels} />
)
case 'reasoning': return <ThinkRow key={i} text={block.text} running={streaming && i === last} t={t} />
// Grouped into tool rows by ChatView; hasVisible above skips an empty shell.
case 'tool-call': return null
default: return <JsonBlock key={i} label="未知内容块" payload={block.block} />
default: return (
<JsonBlock
key={i}
label={t('message.unknownBlock')}
payload={block.block}
truncatedLabel={total => t('json.truncated', { total })}
/>
)
}
})}
{interrupted && <span className={css.stopped}>已停止</span>}
{interrupted && <span className={css.stopped}>{t('message.stopped')}</span>}
</div>
{showActions && (
<MessageIconActions
@@ -99,6 +115,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
clock="end"
onBranch={onFork === undefined || seq === undefined ? undefined : () => { onFork(seq) }}
className={css.actions}
t={t}
/>
)}
</div>

View File

@@ -57,12 +57,13 @@ type UseConversation = SnapshotSelectorHook<ConversationSnapshot>
* top-level call (same registrations, same fallback), nested by the parent.
* A started-but-unsettled sub-call arrives as the RunningToolCall shape and
* renders the running state exactly as a native in-flight row. */
const SubCallRow = memo(function SubCallRow({ renderSlot, node, openFile, selected, cwd }: {
const SubCallRow = memo(function SubCallRow({ renderSlot, node, openFile, selected, cwd, t }: {
renderSlot: RenderToolRow
node: CodeSubCall
openFile: OpenFile
selected: boolean
cwd: string | undefined
t: ChatViewSlotProps['t']
}) {
const settled = 'kind' in node
const toolName = settled ? node.call?.name ?? '' : node.name
@@ -73,7 +74,7 @@ const SubCallRow = memo(function SubCallRow({ renderSlot, node, openFile, select
<div className={css.callRow} data-selected={selected || undefined}>
{renderSlot('conversation.chat.toolview', owner, {
entryKey: toolName,
fallback: <GenericToolCard {...owner} />,
fallback: <GenericToolCard {...owner} t={t} />,
})}
</div>
)
@@ -85,7 +86,7 @@ const SubCallRow = memo(function SubCallRow({ renderSlot, node, openFile, select
* renders its logged sub-dispatches as always-visible indented rows —
* each one the same keyed-slot dispatch as a native top-level call. */
const CallRow = memo(function CallRow({
renderSlot, callId, toolName, block, openFile, selected, subCalls, selectedCallId, cwd,
renderSlot, callId, toolName, block, openFile, selected, subCalls, selectedCallId, cwd, t,
}: {
renderSlot: RenderToolRow
callId: string
@@ -100,6 +101,7 @@ const CallRow = memo(function CallRow({
selectedCallId?: string | undefined
/** Session workspace root for path-relative summaries. */
cwd: string | undefined
t: ChatViewSlotProps['t']
}) {
const owner = useMemo(() => ({
callId, toolName, block, openFile, cwd,
@@ -108,7 +110,7 @@ const CallRow = memo(function CallRow({
<div className={css.callRow} data-selected={selected || undefined}>
{renderSlot('conversation.chat.toolview', owner, {
entryKey: toolName,
fallback: <GenericToolCard {...owner} />,
fallback: <GenericToolCard {...owner} t={t} />,
})}
{subCalls !== undefined && subCalls.length > 0 && (
<div className={css.subCalls} data-subcalls>
@@ -120,6 +122,7 @@ const CallRow = memo(function CallRow({
openFile={openFile}
selected={node.callId === selectedCallId}
cwd={cwd}
t={t}
/>
))}
</div>
@@ -129,7 +132,7 @@ const CallRow = memo(function CallRow({
})
/** Consecutive tool results as one step-run group (uniform 16px rhythm). */
const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selectedCallId, codeDispatches, cwd }: {
const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selectedCallId, codeDispatches, cwd, t }: {
renderSlot: RenderToolRow
results: readonly ToolResultNode[]
openFile: OpenFile
@@ -139,6 +142,7 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selec
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
/** Session workspace root for path-relative summaries. */
cwd: string | undefined
t: ChatViewSlotProps['t']
}) {
return (
<div className={css.toolGroup}>
@@ -154,6 +158,7 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selec
subCalls={codeDispatches.get(node.callId)}
selectedCallId={selectedCallId}
cwd={cwd}
t={t}
/>
))}
</div>
@@ -163,16 +168,17 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selec
/** One command lifecycle row: keyed dispatch on the command name with the
* generic card as the render-site fallback (zero registration required). A
* run-less cross-window node has no name and always lands on the fallback. */
const CommandRow = memo(function CommandRow({ renderSlot, node }: {
const CommandRow = memo(function CommandRow({ renderSlot, node, t }: {
renderSlot: RenderToolRow
node: CommandNode
t: ChatViewSlotProps['t']
}) {
const owner = useMemo(() => ({ node }), [node])
return (
<div className={css.callRow}>
{renderSlot('conversation.chat.commandview', owner, {
entryKey: node.name ?? '',
fallback: <GenericCommandCard {...owner} />,
fallback: <GenericCommandCard {...owner} t={t} />,
})}
</div>
)
@@ -214,23 +220,24 @@ function TurnDots() {
/** The streaming partial, isolated so chunk batches re-render only this tail.
* onGrow lets the scroll owner follow content the parent never re-renders for. */
function StreamingTail({ useSession, onGrow }: {
function StreamingTail({ useSession, onGrow, t }: {
useSession: UseConversation
onGrow: () => void
t: ChatViewSlotProps['t']
}) {
const partial = useSession(s => s.partial)
useLayoutEffect(() => {
onGrow()
})
if (partial === null) return null
return <AssistantMarkdown blocks={partial.blocks} streaming />
return <AssistantMarkdown blocks={partial.blocks} streaming t={t} />
}
/**
* The chat view slot entry: pure component over the composed props (tool rows
* render through the declared keyed hole's renderSlot share).
*/
export function ChatView({ useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, forkAt }: ChatViewSlotProps) {
export function ChatView({ useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, forkAt, t }: ChatViewSlotProps) {
const nodes = useSession(s => s.nodes)
// Workspace root off the session list row: path summaries display relative to it.
const cwd = useSessions(s => s.byId[sessionId]?.cwd)
@@ -238,7 +245,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
const runningCalls = useSession(s => s.runningCalls)
const codeDispatches = useSession(s => s.codeDispatches)
const openState = useSession(s => s.openState)
const openErrorMessage = useSession(s => s.openError === null ? null : `${s.openError.message}(${s.openError.code})`)
const openError = useSession(s => s.openError)
const hasMore = useSession(s => s.hasMore)
const loadingOlder = useSession(s => s.loadingOlder)
const selectedCallId = useStore(s => s.selection?.callId)
@@ -368,6 +375,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
selectedCallId={inGroup ? selectedCallId : undefined}
codeDispatches={codeDispatches}
cwd={cwd}
t={t}
/>
)
}
@@ -382,32 +390,37 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
time={actionSeqs.has(node.seq) ? node.time : undefined}
seq={node.seq}
onFork={forkAt}
t={t}
/>
)
}
if (node.kind === 'command') {
return <CommandRow key={item.key} renderSlot={renderSlot} node={node} />
return <CommandRow key={item.key} renderSlot={renderSlot} node={node} t={t} />
}
/* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */
if (node.kind === 'tool-result') return null
return <MessageItem key={item.key} node={node} onFork={forkAt} />
return <MessageItem key={item.key} node={node} onFork={forkAt} t={t} />
}
return (
<div className={css.root}>
<div ref={listRef} className={css.scroll}>
<div className={css.column}>
{openState === 'loading' && <div className={css.hint}>载入历史…</div>}
{openState === 'error' && <div className={css.openError}>历史加载失败:{openErrorMessage}</div>}
{openState === 'loading' && <div className={css.hint}>{t('chat.loadingHistory')}</div>}
{openState === 'error' && openError !== null && (
<div className={css.openError}>
{t('chat.loadError', { message: openError.message, code: openError.code })}
</div>
)}
{hasMore && (
<div className={css.older}>
<button type="button" disabled={loadingOlder} onClick={loadOlderAnchored}>
{loadingOlder ? '加载中…' : '加载更早'}
{loadingOlder ? t('loading') : t('chat.loadOlder')}
</button>
</div>
)}
{items.map(renderItem)}
<StreamingTail useSession={useSession} onGrow={onGrow} />
<StreamingTail useSession={useSession} onGrow={onGrow} t={t} />
{runningCalls.length > 0 && (
<div className={css.toolGroup}>
{runningCalls.map(call => (
@@ -422,6 +435,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
subCalls={codeDispatches.get(call.callId)}
selectedCallId={selectedCallId}
cwd={cwd}
t={t}
/>
))}
</div>
@@ -438,7 +452,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
<button
type="button"
className={css.toBottom}
aria-label="回到底部"
aria-label={t('chat.toBottom')}
onClick={() => {
const local = listRef.current
/* v8 ignore next -- ref-null guard: the button only renders alongside the mounted list. */

View File

@@ -1,5 +1,6 @@
import { useMemo, useState } from 'react'
import type { ContextMessageNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { IconBrowseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import { DisclosureRow } from './DisclosureRow.tsx'
import css from './ContextInjectionRow.module.css'
@@ -47,6 +48,8 @@ function inlineJson(payload: unknown): string {
export interface ContextInjectionRowProps {
content: ContextMessageNode['content']
source: ContextMessageNode['source']
/** The owning view's locale seat, passed down as a plain prop. */
t: ChatViewSlotProps['t']
}
/**
@@ -54,22 +57,22 @@ export interface ContextInjectionRowProps {
* @param props - Durable content and source provenance.
* @returns A collapsed context row with a bounded JSON body.
*/
export function ContextInjectionRow({ content, source }: ContextInjectionRowProps) {
export function ContextInjectionRow({ content, source, t }: ContextInjectionRowProps) {
const [open, setOpen] = useState(false)
const body = useMemo(() => {
if (!open) return ''
const text = inlineJson({ content, source })
return text.length > MAX_CHARS
? `${text.slice(0, MAX_CHARS)}\n… 已截断,共 ${text.length} 字符`
? `${text.slice(0, MAX_CHARS)}\n${t('json.truncated', { total: text.length })}`
: text
}, [content, open, source])
}, [content, open, source, t])
return (
<DisclosureRow
className={css.root}
icon={<IconBrowseOutline16 size={14} />}
chevronClassName={css.chevron}
title="上下文注入"
title={t('message.contextInjection')}
open={open}
expandable
expandOnRowClick

View File

@@ -6,7 +6,7 @@
import { ToolRow } from './ToolRow.tsx'
import type { ToolRowState } from '../contract/tool-call-model.ts'
import type { CommandRowOwnerProps } from '../contract/slots.ts'
import type { ChatViewSlotProps, CommandRowOwnerProps } from '../contract/slots.ts'
import { IconApiOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
/** Node state → row state semantic (running while unsettled; outcome kind after). */
@@ -15,18 +15,24 @@ function stateOf(outcome: CommandRowOwnerProps['node']['outcome']): ToolRowState
return outcome.kind === 'error' ? 'error' : 'ok'
}
export function GenericCommandCard({ node }: CommandRowOwnerProps) {
/** Card props: the owner payload plus the render site's locale seat (plain prop). */
export interface GenericCommandCardProps extends CommandRowOwnerProps {
t: ChatViewSlotProps['t']
}
export function GenericCommandCard({ node, t }: GenericCommandCardProps) {
const text = node.outcome?.text
const summary = node.outcome === null
? '执行中…'
: text ?? (node.outcome.kind === 'error' ? '命令失败' : '已完成')
? t('command.running')
: text ?? (node.outcome.kind === 'error' ? t('command.failed') : t('command.done'))
// Title is the bare command name: the row already reads `name · outcome`,
// and the dispatched line's own `/` and arguments only restate what the
// settlement text says (`permission · preset workspace-write`). A
// cross-window node whose run page fell out of the window has no name.
const title = node.name ?? '命令'
const title = node.name ?? t('command.title')
return (
<ToolRow
t={t}
variant="others"
icon={<IconApiOutline14 size={16} />}
title={title}

View File

@@ -9,7 +9,7 @@ import {
IconApiOutline14, IconBrowseOutline16, IconCodeOutline16, IconEditOutline16, IconSearchOutline16, IconSparkle16,
IconThinkOutline14,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolRowOwnerProps } from '../contract/slots.ts'
import type { ChatViewSlotProps, ToolRowOwnerProps } from '../contract/slots.ts'
import { terminalCardModel } from '../contract/terminal-card-model.ts'
import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.ts'
import { ToolRow } from './ToolRow.tsx'
@@ -26,12 +26,18 @@ const VARIANT_ICONS: Record<ToolRowVariant, ReactNode> = {
others: <IconSparkle16 size={14} />,
}
export function GenericToolCard({ toolName, block, cwd, openFile }: ToolRowOwnerProps) {
/** Card props: the owner payload plus the render site's locale seat (plain prop). */
export interface GenericToolCardProps extends ToolRowOwnerProps {
t: ChatViewSlotProps['t']
}
export function GenericToolCard({ toolName, block, cwd, openFile, t }: GenericToolCardProps) {
const model = toolRowModel(toolName, block, cwd)
const terminal = terminalCardModel(block, cwd)
const singleFile = model.filePath !== undefined
return (
<ToolRow
t={t}
variant={model.variant}
toolName={toolName}
icon={VARIANT_ICONS[model.variant]}

View File

@@ -6,6 +6,7 @@ import { useCallback } from 'react'
import {
IconBranchOutline16, IconCopyOutline16, IconEditOutline16, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { formatMessageClock, writeClipboard } from './message-chrome.ts'
import { useCalendarDay } from './use-calendar-day.ts'
import css from './MessageIconActions.module.css'
@@ -23,6 +24,8 @@ export interface MessageIconActionsProps {
onBranch?: (() => void) | undefined
/** Parent layout class composed onto the actions row. */
className?: string | undefined
/** The owning view's locale seat, passed down as a plain prop. */
t: ChatViewSlotProps['t']
}
/**
@@ -31,7 +34,7 @@ export interface MessageIconActionsProps {
* @returns The actions row element.
*/
export function MessageIconActions({
text, time, clock, edit, onBranch, className,
text, time, clock, edit, onBranch, className, t,
}: MessageIconActionsProps) {
const day = useCalendarDay()
const onCopy = useCallback(() => {
@@ -39,25 +42,25 @@ export function MessageIconActions({
}, [text])
const clockEl = (
<span className={clock === 'start' ? css.timeStart : css.timeEnd}>
{formatMessageClock(time, day)}
{formatMessageClock(time, t, day)}
</span>
)
return (
<div className={className === undefined ? css.actions : `${css.actions} ${className}`}>
{clock === 'start' ? clockEl : null}
<Tooltip label="复制" side="bottom">
<button type="button" className={css.action} aria-label="复制" onClick={onCopy}>
<Tooltip label={t('copy')} side="bottom">
<button type="button" className={css.action} aria-label={t('copy')} onClick={onCopy}>
<IconCopyOutline16 />
</button>
</Tooltip>
<Tooltip label="在新对话中分支" side="bottom">
<button type="button" className={css.action} aria-label="在新对话中分支" onClick={onBranch}>
<Tooltip label={t('message.branch')} side="bottom">
<button type="button" className={css.action} aria-label={t('message.branch')} onClick={onBranch}>
<IconBranchOutline16 />
</button>
</Tooltip>
{edit === true && (
<Tooltip label="编辑" side="bottom">
<button type="button" className={css.action} aria-label="编辑">
<Tooltip label={t('edit')} side="bottom">
<button type="button" className={css.action} aria-label={t('edit')}>
<IconEditOutline16 />
</button>
</Tooltip>

View File

@@ -10,6 +10,7 @@ import type {
ContextMessageNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { ContextInjectionRow } from './ContextInjectionRow.tsx'
import { MessageIconActions } from './MessageIconActions.tsx'
import css from './MessageItem.module.css'
@@ -18,6 +19,8 @@ export interface MessageItemProps {
node: UserMessageNode | SteeringMessageNode | ContextMessageNode | UnknownSurfaceNode
/** Fork the session through the turn containing this message (user-bubble branch action). */
onFork?: (seq: number) => void
/** The owning view's locale seat, passed down as a plain prop. */
t: ChatViewSlotProps['t']
}
function contentText(content: readonly unknown[]): { text: string; rest: unknown[] } {
@@ -63,7 +66,8 @@ function projectUserText(text: string): ReactNode {
return <>{parts}</>
}
export const MessageItem = memo(function MessageItem({ node, onFork }: MessageItemProps) {
export const MessageItem = memo(function MessageItem({ node, onFork, t }: MessageItemProps) {
const truncated = (total: number): string => t('json.truncated', { total })
switch (node.kind) {
case 'user': {
const { text, rest } = contentText(node.content)
@@ -71,7 +75,7 @@ export const MessageItem = memo(function MessageItem({ node, onFork }: MessageIt
<div className={css.userRow}>
<div className={css.bubble}>
{projectUserText(text)}
{rest.map((block, i) => <JsonBlock key={i} label="附加内容块" payload={block} />)}
{rest.map((block, i) => <JsonBlock key={i} label={t('message.extraBlock')} payload={block} truncatedLabel={truncated} />)}
</div>
<MessageIconActions
text={text}
@@ -80,6 +84,7 @@ export const MessageItem = memo(function MessageItem({ node, onFork }: MessageIt
edit
onBranch={onFork === undefined ? undefined : () => { onFork(node.seq) }}
className={css.actions}
t={t}
/>
</div>
)
@@ -89,21 +94,21 @@ export const MessageItem = memo(function MessageItem({ node, onFork }: MessageIt
return (
<div className={css.userRow}>
<div className={css.bubble}>
<span className={css.badge}>插话</span>
<span className={css.badge}>{t('message.steering')}</span>
{projectUserText(text)}
{rest.map((block, i) => <JsonBlock key={i} label="附加内容块" payload={block} />)}
{rest.map((block, i) => <JsonBlock key={i} label={t('message.extraBlock')} payload={block} truncatedLabel={truncated} />)}
</div>
</div>
)
}
case 'context':
return (
<ContextInjectionRow content={node.content} source={node.source} />
<ContextInjectionRow content={node.content} source={node.source} t={t} />
)
default:
return (
<div className={css.contextRow}>
<JsonBlock label={`未知 surface 事件:${node.type}`} payload={node.data} />
<JsonBlock label={t('message.unknownSurface', { type: node.type })} payload={node.data} truncatedLabel={truncated} />
</div>
)
}

View File

@@ -10,12 +10,15 @@
import { useState, type MouseEvent, type ReactNode } from 'react'
import { CodeBlock, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import { CHAT_TERMINAL_MAX_LINES, type TerminalCardModel } from '../contract/terminal-card-model.ts'
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
import { CHAT_TERMINAL_MAX_LINES, terminalBlockLabels, type TerminalCardModel } from '../contract/terminal-card-model.ts'
import type { ToolRowState, ToolRowVariant } from '../contract/tool-call-model.ts'
import { DisclosureRow } from './DisclosureRow.tsx'
import css from './ToolRow.module.css'
export interface ToolRowProps {
/** The render site's conversation locale seat (terminal/code body copy). */
t: TranslateNS<'conversation'>
variant: ToolRowVariant
/** Wire tool name for tool-owned styling layered over the generic variant. */
toolName?: string | undefined
@@ -56,6 +59,7 @@ function leadingFor(state: ToolRowState, icon: ReactNode): ReactNode {
}
export function ToolRow({
t,
variant,
toolName,
icon,
@@ -125,9 +129,16 @@ export function ToolRow({
<div className={css.terminalDescription}>{terminalBody.description}</div>
)}
{terminalBody !== null
? <TerminalBlock {...terminalBody.card} maxLines={CHAT_TERMINAL_MAX_LINES} className={css.terminalBody} />
? (
<TerminalBlock
{...terminalBody.card}
maxLines={CHAT_TERMINAL_MAX_LINES}
labels={terminalBlockLabels(t)}
className={css.terminalBody}
/>
)
: variant === 'code'
? <CodeBlock code={text} lang="typescript" className={css.codeBody} />
? <CodeBlock code={text} lang="typescript" copyLabel={t('copy')} copiedLabel={t('copied')} className={css.codeBody} />
: <div className={css.body}>{text}</div>}
</DisclosureRow>
</div>

View File

@@ -1,6 +1,11 @@
// Shared chrome helpers for user/assistant IconActions rows: clipboard write
// and the compact date+clock label from a session-event epoch.
import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
/** The date-template share of the conversation dictionary the clock consumes. */
export type ClockTranslate = Translate<'clock.md' | 'clock.ymd'>
/**
* Best-effort clipboard write; rejections stay swallowed (no success chrome).
* @param text - Plain text to place on the clipboard.
@@ -67,14 +72,16 @@ export function msUntilNextLocalMidnight(ms: number): number {
}
/**
* Compact local timestamp for message IconActions.
* Same calendar day → `HH:mm`; earlier this year → `M月D日 HH:mm`;
* other years → `YYYY年M月D日 HH:mm`.
* Compact local timestamp for message IconActions. Same calendar day →
* `HH:mm`; earlier this year → the `clock.md` date template + clock; other
* years → the `clock.ymd` template + clock. Pure: the date templates arrive
* through the caller's locale seat.
* @param time - Unix epoch ms from the source session event.
* @param t - translate seat supplying the `clock.md` / `clock.ymd` templates.
* @param now - Reference instant for the day/year cut (defaults to wall clock).
* @returns Date-aware clock string (24-hour, zero-padded time).
*/
export function formatMessageClock(time: number, now: number = Date.now()): string {
export function formatMessageClock(time: number, t: ClockTranslate, now: number = Date.now()): string {
const d = new Date(time)
const n = new Date(now)
const clock = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`
@@ -85,7 +92,7 @@ export function formatMessageClock(time: number, now: number = Date.now()): stri
) {
return clock
}
const md = `${d.getMonth() + 1}月${d.getDate()}日`
if (d.getFullYear() === n.getFullYear()) return `${md} ${clock}`
return `${d.getFullYear()}年${md} ${clock}`
const params = { y: d.getFullYear(), m: d.getMonth() + 1, d: d.getDate() }
const md = d.getFullYear() === n.getFullYear() ? t('clock.md', params) : t('clock.ymd', params)
return `${md} ${clock}`
}

View File

@@ -1,7 +1,7 @@
/** Conversation slot declarations and their composed component props. */
import type { ReactNode, RefObject } from 'react'
import type {
InjectFace, MaybeSnapshotSelectorHook, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook,
InjectFace, MaybeSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook,
} from '@deepseek-ai/dsh-client-ui-slots'
import type { CommandNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
@@ -282,8 +282,6 @@ export interface ComposerBarInjected {
* Resolves admission: false = rejected/unmatched/transport failure.
*/
command: ((line: string) => Promise<boolean>) | undefined
/** Locale-aware hint translator for claimed command placeholders (session-independent — always present). */
translateHint: (key: string) => string
/**
* Registrant hooks compartment: the renderer binds these to
* useNotices/useLexicon (static absent sources without a session — hook
@@ -306,11 +304,12 @@ export interface InputControlOwnerProps {
locked: boolean
}
/** Full composer-bar component props: standard kit & owner share & control-seat render share & injected share (hooks compartment bound). */
/** Full composer-bar props: standard kit & owner share & control-seat render share & injected share (hooks bound) & locale seat. */
export type ComposerBarProps =
PropsRuntime<'conversation.composer.bar'>
& PropsRenderSlots<'conversation.input.plan' | 'conversation.input.model'>
& InjectFace<ComposerBarInjected>
& PropsLocale<'conversation'>
/**
* Composer chain currency: what ConversationRoot dispatches at its
@@ -325,7 +324,8 @@ export interface ComposerChainProps {
/**
* Full conversation-slot component props: runtime & child-render (view ring
* + composer chain/bar + input-region + hero picker slots) & store & injected shares.
* + composer chain/bar + input-region + hero picker slots) & store & injected
* shares & the locale seat.
*/
export type ConversationSlotProps =
PropsRuntime<'conversation'> & PropsRenderSlots<
@@ -336,13 +336,15 @@ export type ConversationSlotProps =
| 'conversation.hero.workspace'
>
& ConversationInjected
& PropsLocale<'conversation'>
/** Full strict-session content props: per-session store, view ring, and callbacks. */
/** Full strict-session content props: per-session store, view ring, callbacks, and the locale seat. */
export type ConversationSessionSlotProps =
PropsRuntime<'conversation.session'>
& PropsRenderSlots<'conversation.view'>
& PropsStore<ChatStore>
& ConversationSessionInjected
& PropsLocale<'conversation'>
/** The pending approval carrier the owner dispatches into the composer chain. */
export type ApprovalWait = PendingWait<'approval'>
@@ -400,11 +402,13 @@ export class PendingApproval {
/**
* Full approval-composer props: the framework runtime share (chain currency +
* session/global standard kit) plus the chain `matched` share — the entry's
* selector result, already narrowed to the approval carrier. No injected
* share: the carrier plus the domain face above carry the whole behavior
* surface; the paired command line derives from useSession in-component.
* selector result, already narrowed to the approval carrier — plus the
* standard locale seat. No injected share: the carrier plus the domain face
* above carry the whole behavior surface; the paired command line derives
* from useSession in-component.
*/
export type ApprovalComposerProps = PropsRuntime<'conversation.composer'> & { matched: ApprovalWait }
export type ApprovalComposerProps =
PropsRuntime<'conversation.composer'> & { matched: ApprovalWait } & PropsLocale<'conversation'>
/**
* Injected share of the chat view entry: the two callbacks whose targets live
@@ -423,10 +427,10 @@ export interface ChatViewInjected {
forkAt: (seq: number) => void
}
/** Full chat-view component props: runtime share & the declared toolview/commandview holes' render share & store share & injected share. */
/** Full chat-view component props: runtime & the declared toolview/commandview holes' render share & store & injected & locale seat. */
export type ChatViewSlotProps =
PropsRuntime<'conversation.view'> & PropsRenderSlots<'conversation.chat.toolview' | 'conversation.chat.commandview'>
& PropsStore<ChatStore> & ChatViewInjected
& PropsStore<ChatStore> & ChatViewInjected & PropsLocale<'conversation'>
/**
* Injected share of the details slot: the panel is otherwise a pure reader of
@@ -437,8 +441,8 @@ export interface DetailsInjected {
closeDetails: () => void
}
/** Full details-slot component props: selection arrives through the shared store, call material through useSession. */
export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore<ChatStore> & DetailsInjected
/** Full details-slot component props: selection rides the shared store, call material useSession; copy the locale seat. */
export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore<ChatStore> & DetailsInjected & PropsLocale<'conversation'>
/** Owner share common to the hero / New-Session Workspace pickers. */
export interface EmptyWorkspaceOwnerProps {

View File

@@ -8,9 +8,35 @@
* are derived once.
* @module
*/
import type { TerminalBlockProps } from '@deepseek-ai/dsh-client-ui-primitives'
import type { TerminalBlockLabels, TerminalBlockProps } from '@deepseek-ai/dsh-client-ui-primitives'
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
import { resolveToolPath, type ToolCallBlock } from './tool-call-model.ts'
/**
* Build the TerminalBlock display copy from the conversation locale seat —
* the one place the primitive's label surface pairs with this package's
* dictionary, shared by every terminal render site (chat row, bash row,
* details panel).
* @param t - the render site's conversation locale seat.
* @returns the full label set for {@link TerminalBlockProps}'s `labels`.
*/
export function terminalBlockLabels(t: TranslateNS<'conversation'>): TerminalBlockLabels {
return {
signal: signal => t('terminal.signal', { signal }),
exitCode: code => t('terminal.exitCode', { code }),
running: t('terminal.running'),
failed: t('terminal.failed'),
done: t('terminal.done'),
copy: t('copy'),
copied: t('copied'),
noOutput: t('terminal.noOutput'),
collapseAria: t('terminal.collapseAria'),
collapse: t('collapse'),
expandAria: hidden => t('terminal.expandAria', { n: hidden }),
expand: hidden => t('terminal.expandRest', { n: hidden }),
}
}
/**
* Output lines the chat row's expanded terminal body shows before collapsing
* the middle — half the primitive's own default, which the details panel

View File

@@ -11,6 +11,7 @@ export type {
CallId, ChatStoreState, SelectionTarget, ViewTab,
} from './contract/views.ts'
export type { ToolCallBlock } from './contract/tool-call-model.ts'
export type { ConversationKey } from './locales.ts'
export type {
ChatStore, ChatViewInjected, ChatViewSlotProps, CommandRowOwnerProps, CommandRowProps, ComposerBarInjected,
ComposerChainProps, ConversationInjected,

View File

@@ -0,0 +1,170 @@
/** `conversation` namespace dictionaries. */
/** Dictionary namespace owned by this plugin. */
export const NS = 'conversation'
// The claimed /plan hint and the plan-mode textarea placeholder share one
// string: both describe the same next action.
const PLAN_NEXT_ACTION_ZH = '描述你的任务以生成计划'
const PLAN_NEXT_ACTION_EN = 'describe your task to generate plan'
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'view.chat': '对话',
'hint.plan': PLAN_NEXT_ACTION_ZH,
'hint.goal': '输入目标,智能体将持续执行',
'hint.goal.active': '当前目标进行中。可输入 edit 修改 / pause 暂停 / resume 继续 / clear 清除',
'placeholder.plan': PLAN_NEXT_ACTION_ZH,
'placeholder.default': '给智能体发消息',
'placeholder.unavailable': '会话不可用',
'placeholder.hero': '描述你想要构建的内容',
'placeholder.workspace': '选择一个工作区开始',
'input.addAttachment': '添加附件',
'input.stop': '停止生成',
'input.send': '发送消息',
'input.accessMode': '访问模式,当前:{name}',
'hero.headline': '开始构建吧',
'hero.chooseWorkspace': '选择工作区',
'session.hierarchy': '会话层级',
'details.title': '详情',
'details.close': '关闭详情',
'details.empty': '点击消息流中的工具行查看详情',
'details.notInWindow': '该调用不在当前窗口内',
'details.input': '输入',
'details.output': '输出',
'details.running': '运行中…',
'todo.title': '任务清单',
'todo.progress': '{done}/{total} 项任务 · {active} 项进行中',
'todo.rowTitle': '更新任务清单',
'todo.completed': '{done}/{total} 已完成',
'chat.loadingHistory': '载入历史…',
'chat.loadError': '历史加载失败:{message}({code})',
'chat.loadOlder': '加载更早',
'chat.toBottom': '回到底部',
'message.extraBlock': '附加内容块',
'message.steering': '插话',
'message.contextInjection': '上下文注入',
'message.unknownSurface': '未知 surface 事件:{type}',
'message.unknownBlock': '未知内容块',
'message.stopped': '已停止',
'message.branch': '在新对话中分支',
'command.running': '执行中…',
'command.failed': '命令失败',
'command.done': '已完成',
'command.title': '命令',
'approval.waiting': '等待审批',
'approval.detail.aria': '审批详情',
'approval.escalation': '工具 {toolName} 请求越权执行',
'approval.reject': '拒绝',
'approval.allowOnce': '允许一次',
'ask.rowTitle': '提问',
'ask.waiting': '等待回答',
'ask.cancelled': '已取消',
'ask.interrupted': '已中断',
'ask.answered': '{answered}/{total} 已回答',
'bash.running': '运行中',
'bash.failed': '失败',
'bash.stopped': '已停止',
'queue.count': '{n} 条排队消息',
'queue.edit': '编辑排队消息',
'queue.edit.unsupported': '包含非文本内容,暂不支持编辑',
'queue.save': '保存排队消息',
'queue.cancelEdit': '取消编辑',
'queue.remove': '删除排队消息',
'queue.editFailed': '编辑失败:这条消息可能已经开始发送。',
'queue.removeFailed': '删除失败:这条消息可能已经开始发送。',
'terminal.signal': '信号 {signal}',
'terminal.exitCode': '退出码 {code}',
'terminal.running': '运行中',
'terminal.failed': '失败',
'terminal.done': '已完成',
'terminal.noOutput': '无输出',
'terminal.collapseAria': '收起输出',
'terminal.expandAria': '展开其余 {n} 行输出',
'terminal.expandRest': '… 其余 {n} 行',
'json.truncated': '… 已截断,共 {total} 字符',
'clock.md': '{m}月{d}日',
'clock.ymd': '{y}年{m}月{d}日',
} satisfies Record<string, string>
/** The conversation namespace key union. */
export type ConversationKey = keyof typeof zh
/** English dictionary, checked complete against the zh key set. */
export const en = {
'view.chat': 'Chat',
'hint.plan': PLAN_NEXT_ACTION_EN,
'hint.goal': 'describe the objective for a long-running task',
'hint.goal.active': 'goal active — edit / pause / resume / clear',
'placeholder.plan': PLAN_NEXT_ACTION_EN,
'placeholder.default': 'Message the agent',
'placeholder.unavailable': 'Session unavailable',
'placeholder.hero': 'Describe what you want to build',
'placeholder.workspace': 'Choose a workspace to start',
'input.addAttachment': 'Add attachment',
'input.stop': 'Stop generating',
'input.send': 'Send message',
'input.accessMode': 'Access mode, current: {name}',
'hero.headline': 'Let\'s start building',
'hero.chooseWorkspace': 'Choose workspace',
'session.hierarchy': 'Session hierarchy',
'details.title': 'Details',
'details.close': 'Close details',
'details.empty': 'Click a tool row in the message flow to view its details',
'details.notInWindow': 'This call is outside the current window',
'details.input': 'Input',
'details.output': 'Output',
'details.running': 'Running…',
'todo.title': 'To-dos',
'todo.progress': '{done}/{total} tasks · {active} in progress',
'todo.rowTitle': 'Update to-do list',
'todo.completed': '{done}/{total} completed',
'chat.loadingHistory': 'Loading history…',
'chat.loadError': 'Failed to load history: {message} ({code})',
'chat.loadOlder': 'Load earlier',
'chat.toBottom': 'Back to bottom',
'message.extraBlock': 'Extra content block',
'message.steering': 'Interjection',
'message.contextInjection': 'Context injection',
'message.unknownSurface': 'Unknown surface event: {type}',
'message.unknownBlock': 'Unknown content block',
'message.stopped': 'Stopped',
'message.branch': 'Branch into a new conversation',
'command.running': 'Running…',
'command.failed': 'Command failed',
'command.done': 'Completed',
'command.title': 'Command',
'approval.waiting': 'Waiting for approval',
'approval.detail.aria': 'Approval details',
'approval.escalation': 'Tool {toolName} requests privileged execution',
'approval.reject': 'Reject',
'approval.allowOnce': 'Allow once',
'ask.rowTitle': 'Ask question',
'ask.waiting': 'waiting',
'ask.cancelled': 'cancelled',
'ask.interrupted': 'interrupted',
'ask.answered': '{answered}/{total} answered',
'bash.running': 'Running',
'bash.failed': 'Failed',
'bash.stopped': 'Stopped',
'queue.count': '{n} queued messages',
'queue.edit': 'Edit queued message',
'queue.edit.unsupported': 'Contains non-text content; editing is not supported yet',
'queue.save': 'Save queued message',
'queue.cancelEdit': 'Cancel editing',
'queue.remove': 'Remove queued message',
'queue.editFailed': 'Edit failed: this message may have already started sending.',
'queue.removeFailed': 'Removal failed: this message may have already started sending.',
'terminal.signal': 'signal {signal}',
'terminal.exitCode': 'exit code {code}',
'terminal.running': 'Running',
'terminal.failed': 'Failed',
'terminal.done': 'Done',
'terminal.noOutput': 'No output',
'terminal.collapseAria': 'Collapse output',
'terminal.expandAria': 'Expand the remaining {n} output lines',
'terminal.expandRest': '… {n} more lines',
'json.truncated': '… truncated, {total} characters total',
'clock.md': '{m}/{d}',
'clock.ymd': '{y}-{m}-{d}',
} satisfies Record<ConversationKey, string>

View File

@@ -5,13 +5,14 @@
// ../contract/slots.ts beside the other input-region slots.
import type { Context } from 'cordis'
import { useEffect, useId, useState } from 'react'
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import {
IconCheckOutline16, IconChevronDownOutline14, IconChevronUpOutline14,
IconCloseOutline16, IconEditOutline16, IconTrashOutline16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { QueueAction, QueueItemId } from '../contract/queue.ts'
import { NS } from '../locales.ts'
import css from './QueueDock.module.css'
/** Queue operations injected by the session-scoped registration. */
@@ -20,14 +21,14 @@ export interface QueueDockInjected {
notify: (level: 'info' | 'error', text: string) => void
}
/** Full props of a dock entry: InputZone owner share + session standard kit + global seat. */
export type QueueDockProps = PropsRuntime<'conversation.input.dock'> & QueueDockInjected
/** Full props of a dock entry: InputZone owner share + session standard kit + global seat + the locale seat. */
export type QueueDockProps = PropsRuntime<'conversation.input.dock'> & QueueDockInjected & PropsLocale<'conversation'>
/**
* Queue strip: one item renders directly; multiple items default to a
* collapsible count header; an empty queue renders nothing.
*/
export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) {
export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps) {
const queue = useSession(s => s.queue)
const [editing, setEditing] = useState<{ id: QueueItemId; text: string } | null>(null)
const [busy, setBusy] = useState<QueueItemId | null>(null)
@@ -67,7 +68,7 @@ export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) {
if (await applyAction(
editing.id,
{ kind: 'edit', content: [{ type: 'text', text: editing.text }] },
'编辑失败:这条消息可能已经开始发送。',
t('queue.editFailed'),
)) setEditing(null)
}
@@ -83,7 +84,7 @@ export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) {
disabled={interactionActive}
onClick={() => { setCollapsed(value => !value) }}
>
<span className={css.count}>{queue.length} 条排队消息</span>
<span className={css.count}>{t('queue.count', { n: queue.length })}</span>
<span className={css.chevron} aria-hidden>
{expanded ? <IconChevronDownOutline14 /> : <IconChevronUpOutline14 />}
</span>
@@ -97,7 +98,7 @@ export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) {
<input
autoFocus
className={css.editor}
aria-label="编辑排队消息"
aria-label={t('queue.edit')}
value={editing.text}
onChange={(event) => { setEditing({ id: row.id, text: event.currentTarget.value }) }}
onKeyDown={(event) => {
@@ -120,8 +121,8 @@ export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) {
<button
type="button"
className={css.action}
aria-label="保存排队消息"
title="保存排队消息"
aria-label={t('queue.save')}
title={t('queue.save')}
disabled={busy !== null || editing.text.trim() === ''}
onClick={() => { void saveEdit() }}
>
@@ -130,8 +131,8 @@ export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) {
<button
type="button"
className={css.action}
aria-label="取消编辑"
title="取消编辑"
aria-label={t('queue.cancelEdit')}
title={t('queue.cancelEdit')}
disabled={busy !== null}
onClick={() => { setEditing(null) }}
>
@@ -144,8 +145,8 @@ export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) {
<button
type="button"
className={css.action}
aria-label="编辑排队消息"
title={row.text === null ? '包含非文本内容,暂不支持编辑' : '编辑排队消息'}
aria-label={t('queue.edit')}
title={row.text === null ? t('queue.edit.unsupported') : t('queue.edit')}
disabled={busy !== null || row.text === null}
onClick={() => {
if (row.text !== null) setEditing({ id: row.id, text: row.text })
@@ -156,14 +157,14 @@ export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) {
<button
type="button"
className={css.action}
aria-label="删除排队消息"
title="删除排队消息"
aria-label={t('queue.remove')}
title={t('queue.remove')}
disabled={busy !== null}
onClick={() => {
void applyAction(
row.id,
{ kind: 'remove' },
'删除失败:这条消息可能已经开始发送。',
t('queue.removeFailed'),
)
}}
>
@@ -196,6 +197,7 @@ export const queueDockEntry = {
name: 'conversation.input.dock',
id: 'queue',
order: 20,
locale: NS,
inject: (sessionId: SessionId): QueueDockInjected => {
const actx = ctx.sessions.scope(sessionId)
if (actx === undefined) throw new Error(`queue dock: session "${sessionId}" resolved no scope`)

View File

@@ -41,10 +41,14 @@ export function ApprovalPanel(props: ApprovalComposerProps) {
const approval = useMemo(() => new PendingApproval(props.matched), [props.matched])
const command = props.useSession(s => commandOf(
approval.callId === undefined ? undefined : s.runningCalls.find(call => call.callId === approval.callId)))
return <ApprovalFlow key={approval.key} pending={approval} {...command === undefined ? {} : { command }} />
return <ApprovalFlow key={approval.key} pending={approval} t={props.t} {...command === undefined ? {} : { command }} />
}
function ApprovalFlow({ pending, command }: { pending: PendingApproval; command?: string }) {
function ApprovalFlow({ pending, command, t }: {
pending: PendingApproval
command?: string
t: ApprovalComposerProps['t']
}) {
// Local one-shot latch: the panel leaves only when the resolved frame
// lands; until then the buttons must not re-fire. An answer failure
// (rejected receipt / transport) re-arms them for retry.
@@ -56,20 +60,20 @@ function ApprovalFlow({ pending, command }: { pending: PendingApproval; command?
return (
<div className={css.root} data-approval-key={pending.key}>
<div className={css.card}>
<div className={css.strip}><span className={css.dot} />等待审批</div>
<div className={css.strip}><span className={css.dot} />{t('approval.waiting')}</div>
{/* Tab stop: the region scrolls once the command passes the cap and
holds nothing focusable of its own, so without one a keyboard-only
user cannot reach the command's tail before answering. */}
<div className={css.body} data-approval-scroll="" tabIndex={0} role="group" aria-label="审批详情">
<div className={css.headline}>{pending.reason ?? `工具 ${pending.toolName} 请求越权执行`}</div>
<div className={css.body} data-approval-scroll="" tabIndex={0} role="group" aria-label={t('approval.detail.aria')}>
<div className={css.headline}>{pending.reason ?? t('approval.escalation', { toolName: pending.toolName })}</div>
{command !== undefined && <div className={css.command}>{command}</div>}
</div>
<div className={css.actionRow}>
<button type="button" className={css.reject} disabled={answered} onClick={() => { answer('rejected') }}>
拒绝
{t('approval.reject')}
</button>
<button type="button" className={css.allow} disabled={answered} onClick={() => { answer('allowed-once') }}>
允许一次
{t('approval.allowOnce')}
</button>
</div>
</div>

View File

@@ -14,7 +14,7 @@ export type ConversationRootProps = ConversationSlotProps
export function ConversationRoot({
sessionId, useSession, useSessions, useWorkspaces, useInput,
renderSlot, renderSlotChain, selectWorkspace,
renderSlot, renderSlotChain, selectWorkspace, t,
}: ConversationRootProps) {
const openState = useSession(s => s.openState)
const composerPhase = useSession(s => s.composerPhase)
@@ -94,6 +94,7 @@ export function ConversationRoot({
label={chipTitle}
menuOpen={pickerOpen}
onClick={() => { setPickerOpen(open => !open) }}
t={t}
/>
{renderSlot('conversation.hero.workspace', {
open: pickerOpen,
@@ -120,8 +121,8 @@ export function ConversationRoot({
const inputBar = renderSlot('conversation.composer.bar', {
variant: hero ? 'hero' : 'composer',
...(inert
? { disabled: true, placeholder: 'Choose a workspace to start' }
: hero ? { placeholder: 'Describe what you want to build' } : {}),
? { disabled: true, placeholder: t('placeholder.workspace') }
: hero ? { placeholder: t('placeholder.hero') } : {}),
overlay: renderSlot('conversation.input.overlay', {}),
leftItems: zone === undefined ? null : renderSlot('conversation.input.left', zone),
rightItems: zone === undefined ? null : renderSlot('conversation.input.right', zone),
@@ -133,7 +134,7 @@ export function ConversationRoot({
const composerBar = (
<div className={clsx(css.composerStack, hero && css.composerHero)}>
{hero && <HeroGlow className={css.heroGlow} />}
{hero && <HeroShell />}
{hero && <HeroShell t={t} />}
{hero && heroWorkspaceRow}
{!hero && zone !== undefined && renderSlot('conversation.input.dock', zone)}
{inputBar}

View File

@@ -24,7 +24,7 @@ function deriveAncestry(list: SessionListState, id: SessionId): readonly Session
export function ConversationSession({
sessionId, useSession, useSessions, useInput, inputActions, useStore, actions,
renderSlot, views, bindDraftMirror, open, wrapActiveBody,
renderSlot, views, bindDraftMirror, open, wrapActiveBody, t,
}: ConversationSessionProps) {
useSyncExternalStore(views.subscribe, views.version)
const tabs = views.list()
@@ -65,7 +65,7 @@ export function ConversationSession({
{!hideChrome && (
<>
<div className={css.crumbRow}>
<nav className={css.crumbs} aria-label="Session hierarchy">
<nav className={css.crumbs} aria-label={t('session.hierarchy')}>
{ancestry.map((summary, index) => {
const last = index === ancestry.length - 1
return (

View File

@@ -11,7 +11,7 @@ import { CodeBlock, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { DetailsSlotProps } from '../contract/slots.ts'
import { terminalCardModel } from '../contract/terminal-card-model.ts'
import { terminalBlockLabels, terminalCardModel } from '../contract/terminal-card-model.ts'
import type { ToolCallBlock } from '../contract/tool-call-model.ts'
import css from './DetailsPanel.module.css'
@@ -68,7 +68,7 @@ function pretty(raw: string): string {
}
}
export function DetailsPanel({ useSession, useSessions, sessionId, useStore, closeDetails }: DetailsPanelProps) {
export function DetailsPanel({ useSession, useSessions, sessionId, useStore, closeDetails, t }: DetailsPanelProps) {
const selection = useStore(s => s.selection)
// Session workspace root: an omitted or relative terminal cwd resolves
// against it, which the pure presenter cannot see.
@@ -84,10 +84,10 @@ export function DetailsPanel({ useSession, useSessions, sessionId, useStore, clo
<div className={css.root}>
<div className={css.header}>
<div className={css.title}>
{selection === null ? '详情' : material?.name ?? selection.toolName ?? '详情'}
{selection === null ? t('details.title') : material?.name ?? selection.toolName ?? t('details.title')}
</div>
<button
type="button" className={css.close} aria-label="关闭详情"
type="button" className={css.close} aria-label={t('details.close')}
onClick={() => { closeDetails() }}
>
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden>
@@ -97,24 +97,24 @@ export function DetailsPanel({ useSession, useSessions, sessionId, useStore, clo
</div>
<div className={css.body}>
{selection === null || callId === undefined
? <div className={css.empty}>点击消息流中的工具行查看详情</div>
? <div className={css.empty}>{t('details.empty')}</div>
: material === null
? <div className={css.empty}>该调用不在当前窗口内</div>
? <div className={css.empty}>{t('details.notInWindow')}</div>
: (
<>
{material.argsRaw !== null && (
<section className={css.section}>
<div className={css.sectionLabel}>Input</div>
<CodeBlock code={pretty(material.argsRaw)} lang="json" />
<div className={css.sectionLabel}>{t('details.input')}</div>
<CodeBlock code={pretty(material.argsRaw)} lang="json" copyLabel={t('copy')} copiedLabel={t('copied')} />
</section>
)}
<section className={css.section}>
<div className={css.sectionLabel}>Output</div>
<div className={css.sectionLabel}>{t('details.output')}</div>
{/* Keyed by the selected call: the body owns per-call view
state (the terminal card's expand and copy), which React
would otherwise carry into the next selection because the
panel does not unmount between calls. */}
<OutputBody key={callId} material={material} cwd={sessionCwd} />
<OutputBody key={callId} material={material} cwd={sessionCwd} t={t} />
</section>
</>
)}
@@ -131,9 +131,10 @@ export function DetailsPanel({ useSession, useSessions, sessionId, useStore, clo
* a running call with no terminal card yet, keeps the flattened text form.
* @param props.material - the selected call's material from {@link materialFor}.
* @param props.cwd - the session workspace root, resolving the terminal view's cwd.
* @param props.t - the panel's locale seat, passed down as a plain prop.
* @returns the Output section's body element.
*/
function OutputBody({ material, cwd }: { material: CallMaterial; cwd: string | undefined }) {
function OutputBody({ material, cwd, t }: { material: CallMaterial; cwd: string | undefined; t: DetailsPanelProps['t'] }) {
const terminal = terminalCardModel(material.block, cwd)
if (terminal !== null) {
// The contract renders the presenter's description above the card, and the
@@ -143,13 +144,13 @@ function OutputBody({ material, cwd }: { material: CallMaterial; cwd: string | u
{terminal.description !== undefined && (
<div className={css.terminalDescription}>{terminal.description}</div>
)}
<TerminalBlock {...terminal.card} className={css.terminal} />
<TerminalBlock {...terminal.card} labels={terminalBlockLabels(t)} className={css.terminal} />
</>
)
}
// A settled call always carries the result node the flattened form needs;
// the running shape has no result to flatten.
if (!('kind' in material.block)) return <div className={css.empty}>运行中…</div>
if (!('kind' in material.block)) return <div className={css.empty}>{t('details.running')}</div>
const result = material.block
return (
<pre className={css.code} data-error={result.isError || undefined}>

View File

@@ -10,8 +10,12 @@ import {
FishLogo, IconChevronDownOutline14, IconFolderClose16, IconFolderOpen16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { workspaceTitleOf } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSlotProps } from '../contract/slots.ts'
import css from './HeroShell.module.css'
/** The owner's locale seat type, passed to hero chrome as a plain prop. */
type HeroTranslate = ConversationSlotProps['t']
/**
* Basename label for the workspace chip (the shared derivation);
* separator-only paths echo the raw cwd.
@@ -34,18 +38,19 @@ export function workspaceLabel(cwd: string): string {
* @param props.onClick - menu toggle.
* @returns the chip button element.
*/
export function WorkspaceChip({ buttonRef, label, menuOpen = false, onClick }: {
export function WorkspaceChip({ buttonRef, label, menuOpen = false, onClick, t }: {
buttonRef?: RefObject<HTMLButtonElement>
label?: string | undefined
menuOpen?: boolean
onClick?: () => void
t: HeroTranslate
}) {
return (
<button
ref={buttonRef}
type="button"
className={css.workspace}
aria-label="Choose workspace"
aria-label={t('hero.chooseWorkspace')}
aria-haspopup="menu"
aria-expanded={menuOpen}
onClick={onClick}
@@ -53,7 +58,7 @@ export function WorkspaceChip({ buttonRef, label, menuOpen = false, onClick }: {
{label === undefined
? <IconFolderClose16 className={css.folder} size={16} />
: <IconFolderOpen16 className={css.folder} size={16} />}
<span className={css.workspaceLabel}>{label ?? 'Choose workspace'}</span>
<span className={css.workspaceLabel}>{label ?? t('hero.chooseWorkspace')}</span>
<IconChevronDownOutline14 className={css.chevron} size={12} />
</button>
)
@@ -95,6 +100,8 @@ export function HeroGlow({ className }: { className?: string | undefined }) {
/** Hero chrome props. The workspace row rides the InputBar accessory hole, not here. */
export interface HeroShellProps {
/** The owner's locale seat, passed down as a plain prop. */
t: HeroTranslate
/** Overlay content after the stack (modals). */
children?: ReactNode
}
@@ -105,14 +112,14 @@ export interface HeroShellProps {
* @param props - see {@link HeroShellProps}.
* @returns the centered hero element tree.
*/
export function HeroShell({ children }: HeroShellProps) {
export function HeroShell({ t, children }: HeroShellProps) {
return (
<div className={css.root}>
<div className={css.stack}>
<div className={css.headline}>
{/* figma 34:10412: fish 34×25 leading the headline, gap 10. */}
<FishLogo size={34} className={css.fish} />
Let&apos;s start building
{t('hero.headline')}
</div>
<div className={css.body}>
{/* The resident composer (ConversationRoot wrapActiveBody seat; the

View File

@@ -15,6 +15,7 @@ import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type {} from '@deepseek-ai/dsh-plan-mode/client'
// Type-only: the `goal` projection key merge (hint disambiguation).
import type {} from '@deepseek-ai/dsh-goal/client'
import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
import type { ComposerBarProps } from '../contract/slots.ts'
import { deriveDecorations } from '../input/decorations.ts'
import type { DraftDecorations } from '../input/decorations.ts'
@@ -33,9 +34,9 @@ export interface InputBarError {
export type InputBarProps = ComposerBarProps
export function InputBar({
useSession, useInput, inputActions, keyboard, stop, command, translateHint, renderSlot, useNotices, useLexicon,
useSession, useInput, inputActions, keyboard, stop, command, t, renderSlot, useNotices, useLexicon,
useProjection, sessionId, variant, disabled: inert = false, placeholder, accessory, overlay, leftItems, rightItems, footer,
onAdd, addLabel = 'Add attachment',
onAdd, addLabel,
}: InputBarProps) {
const input = useInput(s => s)
const notice = useNotices(s => s)
@@ -256,7 +257,8 @@ export function InputBar({
inputRef.current?.focus()
}
const primaryLabel = running ? 'Stop generating' : 'Send message'
const addText = addLabel ?? t('input.addAttachment')
const primaryLabel = running ? t('input.stop') : t('input.send')
const onPrimary = (): void => {
if (inputActions === undefined || stop === undefined) return // absent machine: the button is disabled
if (running) {
@@ -272,7 +274,7 @@ export function InputBar({
// or while the command face is absent with the session).
const accessSelect: ReactNode = command === undefined
? null
: <PermissionSelect value={permissions} locked={locked} command={command} />
: <PermissionSelect value={permissions} locked={locked} command={command} t={t} />
// Mirror-layer decorations: a visible backdrop with transparent text. The
// claim token highlights through behind the textarea glyphs; each U+FFFC
@@ -341,8 +343,10 @@ export function InputBar({
if (deco.hint !== null) {
// Claim tokens are shaped `/name ` (trailing space); trim to the bare name.
const commandName = input?.claim?.token.slice(1).trim() ?? ''
const hintKey = commandName === 'goal' && hasGoal ? 'goal.active' : commandName
const translated = translateHint(hintKey)
const hintKey = `hint.${commandName === 'goal' && hasGoal ? 'goal.active' : commandName}`
// Dynamic lookup by claimed command name: unknown commands miss the
// dictionary and keep the machine's own hint, so the call is wide.
const translated = (t as Translate)(hintKey)
const displayHint = translated !== hintKey ? translated : deco.hint
backdrop.push(<span key="hint" className={css.hint} data-decoration="hint">{displayHint}</span>)
}
@@ -376,8 +380,8 @@ export function InputBar({
readOnly={machineBusy}
data-phase={input?.phase ?? 'inert'}
placeholder={placeholder ?? (disabled
? 'Session unavailable'
: planActive ? translateHint('placeholder.plan') : translateHint('placeholder.default'))}
? t('placeholder.unavailable')
: planActive ? t('placeholder.plan') : t('placeholder.default'))}
rows={2}
onChange={onChange}
onKeyDown={onKeyDown}
@@ -395,8 +399,8 @@ export function InputBar({
<button
type="button"
className={css.add}
aria-label={addLabel}
title={addLabel}
aria-label={addText}
title={addText}
disabled={locked}
onMouseDown={keepFocus}
onClick={onAdd}

View File

@@ -2,6 +2,7 @@ import { useState } from 'react'
import type { PermissionSelect as PermissionSelectValue } from '@deepseek-ai/dsh-permission/client'
import { Menu } from '@deepseek-ai/dsh-client-ui-primitives'
import type { MenuEntry } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ComposerBarProps } from '../contract/slots.ts'
import css from './PermissionSelect.module.css'
/**
@@ -19,9 +20,11 @@ export interface PermissionSelectProps {
value: PermissionSelectValue | undefined
locked: boolean
command: (line: string) => Promise<boolean>
/** The owning bar's locale seat, passed down as a plain prop. */
t: ComposerBarProps['t']
}
export function PermissionSelect({ value, locked, command }: PermissionSelectProps) {
export function PermissionSelect({ value, locked, command, t }: PermissionSelectProps) {
const [pick, setPick] = useState<string | null>(null)
const [open, setOpen] = useState(false)
@@ -56,7 +59,7 @@ export function PermissionSelect({ value, locked, command }: PermissionSelectPro
<button
type="button"
className={css.trigger}
aria-label={`Access mode, current: ${displayName(current?.name ?? currentValue)}`}
aria-label={t('input.accessMode', { name: displayName(current?.name ?? currentValue) })}
title={current?.description}
disabled={locked || busy}
onClick={() => { setOpen(!open) }}

View File

@@ -7,18 +7,21 @@
import { useId, useState } from 'react'
import type { Context } from 'cordis'
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
// The domain's client-namespace pure-type outlet: one import edge delivers
// the `todos` projection-key merge (single source, no consumer-side restated
// declare) and the payload type. Type-only by construction — the outlet is
// free of host value imports, so no host Context merge enters this program.
import type { TodoItem } from '@deepseek-ai/dsh-tool-todo/client'
import { IconChevronDownOutline14, IconChevronUpOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import { NS } from '../locales.ts'
import css from './TodoPanel.module.css'
export interface TodoPanelProps {
/** The session's current plan (empty renders nothing) — selected by the dock adapter. */
todos: readonly TodoItem[]
/** The dock entry's locale seat, passed down as a plain prop. */
t: TodoDockProps['t']
}
/** Local exhaustiveness helper — client packages do not depend on `dsh-llm`. */
@@ -76,18 +79,18 @@ function StatusGlyph({ status }: { status: TodoItem['status'] }) {
}
/** Header summary: "<done>/<total> tasks · <n> in progress". */
function progressLabel(todos: readonly TodoItem[]): string {
const done = todos.filter(t => t.status === 'completed').length
const active = todos.filter(t => t.status === 'in_progress').length
return `${done}/${todos.length} tasks · ${active} in progress`
function progressLabel(todos: readonly TodoItem[], t: TodoPanelProps['t']): string {
const done = todos.filter(item => item.status === 'completed').length
const active = todos.filter(item => item.status === 'in_progress').length
return t('todo.progress', { done, total: todos.length, active })
}
export function TodoPanel({ todos }: TodoPanelProps) {
export function TodoPanel({ todos, t }: TodoPanelProps) {
const [collapsed, setCollapsed] = useState(true)
if (todos.length === 0) return null
return (
<section className={css.root} data-testid="todo-panel" aria-label="To-dos">
<section className={css.root} data-testid="todo-panel" aria-label={t('todo.title')}>
<div className={css.body}>
<button
type="button"
@@ -95,8 +98,8 @@ export function TodoPanel({ todos }: TodoPanelProps) {
aria-expanded={!collapsed}
onClick={() => { setCollapsed(v => !v) }}
>
<span className={css.title}>To-dos</span>
<span className={css.progress}>{progressLabel(todos)}</span>
<span className={css.title}>{t('todo.title')}</span>
<span className={css.progress}>{progressLabel(todos, t)}</span>
<span className={css.chevron} aria-hidden>
{collapsed ? <IconChevronUpOutline14 /> : <IconChevronDownOutline14 />}
</span>
@@ -116,13 +119,13 @@ export function TodoPanel({ todos }: TodoPanelProps) {
)
}
/** Full props of a dock entry: InputZone owner share + session standard kit + global seat. */
export type TodoDockProps = PropsRuntime<'conversation.input.dock'>
/** Full props of a dock entry: InputZone owner share + session standard kit + global seat + the locale seat. */
export type TodoDockProps = PropsRuntime<'conversation.input.dock'> & PropsLocale<'conversation'>
/** Dock adapter: reads the host-computed 'todos' projection (whole list; absent or null renders nothing). */
export function TodoDock({ useProjection }: TodoDockProps) {
export function TodoDock({ useProjection, t }: TodoDockProps) {
const todos = useProjection('todos')
return <TodoPanel todos={todos ?? []} />
return <TodoPanel todos={todos ?? []} t={t} />
}
/**
@@ -139,6 +142,6 @@ export const todoDockEntry = {
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
*/
apply(ctx: Context): void {
ctx.slots.register({ name: 'conversation.input.dock', id: 'todo', order: 10 }, TodoDock)
ctx.slots.register({ name: 'conversation.input.dock', id: 'todo', order: 10, locale: NS }, TodoDock)
},
}

View File

@@ -8,9 +8,11 @@
import { IconQuestionOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context } from 'cordis'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { ToolRowProps } from '../contract/slots.ts'
import { toolRowModel } from '../contract/tool-call-model.ts'
import { ToolRow } from '../chat/ToolRow.tsx'
import { NS } from '../locales.ts'
/** One parsed answer entry, shape-checked (result JSON crosses the wire). */
interface AnswerEntry { selected?: unknown; custom?: unknown }
@@ -19,9 +21,9 @@ function isAnswer(value: unknown): value is AnswerEntry {
return typeof value === 'object' && value !== null
}
/** `${answered}/${total} answered` off the result JSON (a skipped question has
/** Answered-count summary off the result JSON (a skipped question has
* empty `selected` and no `custom`); null on unexpected shape (generic fallback). */
function answeredSummary(text: string): string | null {
function answeredSummary(text: string, t: AskQuestionRowProps['t']): string | null {
let parsed: unknown
try {
parsed = JSON.parse(text)
@@ -34,11 +36,14 @@ function answeredSummary(text: string): string | null {
const answered = answers.filter(a =>
(Array.isArray(a.selected) && a.selected.length > 0)
|| (typeof a.custom === 'string' && a.custom !== '')).length
return `${answered}/${answers.length} answered`
return t('ask.answered', { answered, total: answers.length })
}
/** Full row props: the toolview runtime share plus the standard locale seat. */
type AskQuestionRowProps = ToolRowProps & PropsLocale<'conversation'>
/** One-line question-interaction row (leading toggle expands the raw args). */
export function AskQuestionRow({ toolName, block }: ToolRowProps) {
export function AskQuestionRow({ toolName, block, t }: AskQuestionRowProps) {
const model = toolRowModel(toolName, block)
// Composer verdicts settle the call as specific UserInteractionErrors
// (apiproxy ask_user_question handler): 'ASK_CANCELLED' is the user's own
@@ -50,22 +55,23 @@ export function AskQuestionRow({ toolName, block }: ToolRowProps) {
let summary = model.summary
let state = model.state
if (code === 'ASK_CANCELLED') {
summary = 'cancelled'
summary = t('ask.cancelled')
} else if (code === 'ASK_ABORTED') {
summary = 'interrupted'
summary = t('ask.interrupted')
state = 'stopped'
} else if (model.state === 'running') {
summary = 'waiting'
summary = t('ask.waiting')
} else if ('kind' in block && model.state === 'ok') {
const text = block.content.filter(b => b.type === 'text').map(b => b.text).join('')
summary = answeredSummary(text) ?? model.summary
summary = answeredSummary(text, t) ?? model.summary
}
return (
<ToolRow
t={t}
variant={model.variant}
toolName={toolName}
icon={<IconQuestionOutline14 />}
title="Ask question"
title={t('ask.rowTitle')}
summary={summary}
body={model.body}
state={state}
@@ -87,6 +93,6 @@ export const askQuestionToolview = {
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
*/
apply(ctx: Context): void {
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'ask_user_question' }, AskQuestionRow)
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'ask_user_question', locale: NS }, AskQuestionRow)
},
}

View File

@@ -15,11 +15,16 @@
import type { Context } from 'cordis'
import { IconApiOutline14, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { ToolRowProps } from '../contract/slots.ts'
import { CHAT_TERMINAL_MAX_LINES, terminalCardModel } from '../contract/terminal-card-model.ts'
import { CHAT_TERMINAL_MAX_LINES, terminalBlockLabels, terminalCardModel } from '../contract/terminal-card-model.ts'
import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts'
import { NS } from '../locales.ts'
import css from './bash-sample.module.css'
/** Bash row props: the toolview runtime share plus the standard locale seat. */
type BashRowProps = ToolRowProps & PropsLocale<'conversation'>
function leadingFor(state: ToolRowState) {
switch (state) {
case 'error': return <StateDot state="error" />
@@ -30,11 +35,11 @@ function leadingFor(state: ToolRowState) {
}
/** Visually hidden status — StateDot is aria-hidden; AT needs a text label. */
function stateStatus(state: ToolRowState): string | null {
function stateStatus(state: ToolRowState, t: BashRowProps['t']): string | null {
switch (state) {
case 'running': return '运行中'
case 'error': return '失败'
case 'stopped': return '已停止'
case 'running': return t('bash.running')
case 'error': return t('bash.failed')
case 'stopped': return t('bash.stopped')
default: return null
}
}
@@ -45,14 +50,14 @@ function stateStatus(state: ToolRowState): string | null {
* details-panel control (tool rows stopped being one), so the card's copy and
* expand controls are the row's only interactions.
*/
export function BashRow({ toolName, block, sessionId, useSessions }: ToolRowProps) {
export function BashRow({ toolName, block, sessionId, useSessions, t }: BashRowProps) {
const model = toolRowModel(toolName, block)
// Session workspace root: the terminal view's cwd resolves against it (an
// omitted workdir IS the workspace), which the pure presenter cannot do.
const cwd = useSessions(list => list.byId[sessionId]?.cwd)
const terminal = terminalCardModel(block, cwd)
const isChild = useSessions(list => list.byId[sessionId]?.parentId !== undefined)
const status = stateStatus(model.state)
const status = stateStatus(model.state, t)
return (
<div className={css.card}>
<div
@@ -71,7 +76,12 @@ export function BashRow({ toolName, block, sessionId, useSessions }: ToolRowProp
<span className={css.summary}>{terminal?.description ?? model.summary}</span>
</div>
{terminal !== null && (
<TerminalBlock {...terminal.card} maxLines={CHAT_TERMINAL_MAX_LINES} className={css.terminal} />
<TerminalBlock
{...terminal.card}
maxLines={CHAT_TERMINAL_MAX_LINES}
labels={terminalBlockLabels(t)}
className={css.terminal}
/>
)}
</div>
)
@@ -91,6 +101,6 @@ export const bashToolviewSample = {
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
*/
apply(ctx: Context): void {
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'bash' }, BashRow)
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'bash', locale: NS }, BashRow)
},
}

View File

@@ -8,9 +8,14 @@
import { IconChecklistOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context } from 'cordis'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { ToolRowProps } from '../contract/slots.ts'
import { toolRowModel } from '../contract/tool-call-model.ts'
import { ToolRow } from '../chat/ToolRow.tsx'
import { NS } from '../locales.ts'
/** Todo row props: the toolview runtime share plus the standard locale seat. */
type TodoRowProps = ToolRowProps & PropsLocale<'conversation'>
/** One parsed args item, shape-checked (model JSON: any field may be missing or mistyped). */
interface TodoWriteItem { content?: unknown; status?: unknown }
@@ -19,7 +24,7 @@ function isItem(value: unknown): value is TodoWriteItem {
return typeof value === 'object' && value !== null
}
function summarize(argsRaw: string): string | null {
function summarize(argsRaw: string, t: TodoRowProps['t']): string | null {
let parsed: unknown
try {
parsed = JSON.parse(argsRaw)
@@ -32,9 +37,9 @@ function summarize(argsRaw: string): string | null {
if (typeof parsed !== 'object' || parsed === null) return null
const todos = (parsed as { todos?: unknown }).todos
if (!Array.isArray(todos) || !todos.every(isItem)) return null
const done = todos.filter(t => t.status === 'completed').length
const active = todos.find(t => t.status === 'in_progress')
const head = `${done}/${todos.length} 已完成`
const done = todos.filter(item => item.status === 'completed').length
const active = todos.find(item => item.status === 'in_progress')
const head = t('todo.completed', { done, total: todos.length })
return typeof active?.content === 'string' && active.content !== ''
? `${head} · ${active.content}`
: head
@@ -43,16 +48,17 @@ function summarize(argsRaw: string): string | null {
/** One-line plan update row (leading toggle expands the raw args). Non-ok
* execution states keep the shared row's dot semantics — a cancelled call
* wrote no todo/write, so it must not read as a completed update. */
export function TodoRow({ toolName, block }: ToolRowProps) {
export function TodoRow({ toolName, block, t }: TodoRowProps) {
const model = toolRowModel(toolName, block)
const argsRaw = ('kind' in block ? block.call?.argsRaw : block.argsRaw) ?? ''
const summary = summarize(argsRaw) ?? model.summary
const summary = summarize(argsRaw, t) ?? model.summary
return (
<ToolRow
t={t}
variant={model.variant}
toolName={toolName}
icon={<IconChecklistOutline14 />}
title="更新任务清单"
title={t('todo.rowTitle')}
summary={summary}
body={model.body}
state={model.state}
@@ -73,6 +79,6 @@ export const todoToolview = {
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
*/
apply(ctx: Context): void {
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'todo_write' }, TodoRow)
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'todo_write', locale: NS }, TodoRow)
},
}

View File

@@ -50,7 +50,9 @@ async function bench() {
})
const layoutFake = { openDetails: vi.fn(), closeDetails: vi.fn() }
runtime.provide('layout', layoutFake)
runtime.provide('locale', new LocaleService(runtime.ctx))
const locale = new LocaleService(runtime.ctx)
runtime.provide('locale', locale)
runtime.slots.installLocale(locale)
// The AppFrame role: the conversation-package slots must be declared by a
// live entry before apply can contribute into them.
@@ -310,7 +312,7 @@ describe('conversation slot inject surface', () => {
// Label falls back to the id when a rider declares none.
const off2 = b.slots.register(
{ name: 'conversation.view', id: 'bare', order: 6 } as never, (() => null) as never)
expect(injected.views.list().map(v => v.label)).toEqual(['Chat', 'X', 'bare'])
expect(injected.views.list().map(v => v.label)).toEqual(['对话', 'X', 'bare'])
off()
off2()
unsub()

View File

@@ -10,9 +10,11 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
// Export discipline: packages/client/AGENTS.md.
import { AskQuestionRow, askQuestionToolview } from '../src/client/toolviews/ask-question-row.tsx'
import { zh } from '../src/client/locales.ts'
afterEach(cleanup)
@@ -28,13 +30,16 @@ const resultNode = (argsRaw: string, resultText: string | null, over?: Partial<T
const runningCall = (argsRaw: string) =>
({ callId: 'c1', name: 'ask_user_question', argsRaw, turn: 1, step: 1, time: 1_000, callView: null })
function rowProps(block: unknown): ToolRowProps {
// Standard locale seat stub mirroring the real ns → common → key chain.
const t = makeTranslate(zh, commonZh)
function rowProps(block: unknown): Parameters<typeof AskQuestionRow>[0] {
return {
callId: 'c1', toolName: 'ask_user_question', block,
callId: 'c1', toolName: 'ask_user_question', block, t,
openFile: vi.fn(),
sessionId: 's1',
useSessions: () => undefined,
} as unknown as ToolRowProps
} as unknown as Parameters<typeof AskQuestionRow>[0]
}
const answers = (entries: unknown[]): string => JSON.stringify({ answers: entries })
@@ -42,8 +47,8 @@ const answers = (entries: unknown[]): string => JSON.stringify({ answers: entrie
describe('AskQuestionRow', () => {
it('running call reads waiting (args-independent: the composer takeover shows the questions)', () => {
const view = render(<AskQuestionRow {...rowProps(runningCall(ARGS))} />)
expect(screen.getByText('Ask question')).toBeTruthy()
expect(screen.getByText('waiting')).toBeTruthy()
expect(screen.getByText('提问')).toBeTruthy()
expect(screen.getByText('等待回答')).toBeTruthy()
expect(view.container.querySelector('[data-state="running"]')).not.toBeNull()
})
@@ -53,7 +58,7 @@ describe('AskQuestionRow', () => {
{ id: 'b', selected: [], custom: 'freeform' },
{ id: 'c', selected: ['y', 'z'], custom: '' },
])))} />)
expect(screen.getByText('3/3 answered')).toBeTruthy()
expect(screen.getByText('3/3 已回答')).toBeTruthy()
})
it('skipped questions (no selection, no custom) stay out of the answered count', () => {
@@ -62,7 +67,7 @@ describe('AskQuestionRow', () => {
{ id: 'b', selected: [], custom: '' },
{ id: 'c' },
])))} />)
expect(screen.getByText('1/3 answered')).toBeTruthy()
expect(screen.getByText('1/3 已回答')).toBeTruthy()
expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull()
})
@@ -82,7 +87,7 @@ describe('AskQuestionRow', () => {
// ASK_CANCELLED: the apiproxy ask_user_question handler's cancel error.
const view = render(<AskQuestionRow {...rowProps(resultNode(ARGS, null,
{ isError: true, error: { name: 'UserInteractionError', code: 'ASK_CANCELLED' } }))} />)
expect(screen.getByText('cancelled')).toBeTruthy()
expect(screen.getByText('已取消')).toBeTruthy()
expect(view.container.querySelector('[data-state="error"]')).not.toBeNull()
})
@@ -90,7 +95,7 @@ describe('AskQuestionRow', () => {
// ASK_ABORTED: the apiproxy ask handler's turn-abort settlement.
const view = render(<AskQuestionRow {...rowProps(resultNode(ARGS, null,
{ isError: true, error: { name: 'UserInteractionError', code: 'ASK_ABORTED' } }))} />)
expect(screen.getByText('interrupted')).toBeTruthy()
expect(screen.getByText('已中断')).toBeTruthy()
expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull()
})
@@ -98,7 +103,7 @@ describe('AskQuestionRow', () => {
const view = render(<AskQuestionRow {...rowProps(resultNode(ARGS, null,
{ isError: true, error: { name: 'Interrupted', code: 'interrupted' } }))} />)
expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull()
expect(screen.queryByText('cancelled')).toBeNull()
expect(screen.queryByText('已取消')).toBeNull()
expect(screen.getByText(`ask_user_question · ${ARGS}`)).toBeTruthy()
})
@@ -124,6 +129,9 @@ describe('AskQuestionRow', () => {
expect(askQuestionToolview.inject).toEqual(['slots', 'conversation'])
const register = vi.fn()
askQuestionToolview.apply({ slots: { register } } as never)
expect(register).toHaveBeenCalledWith({ name: 'conversation.chat.toolview', key: 'ask_user_question' }, AskQuestionRow)
expect(register).toHaveBeenCalledWith(
{ name: 'conversation.chat.toolview', key: 'ask_user_question', locale: 'conversation' },
AskQuestionRow,
)
})
})

View File

@@ -82,7 +82,9 @@ const LAYOUT_CHILDREN = {
async function bench(nodes: ToolResultNode[], opts?: { blank?: boolean }) {
const runtime = await SlotTestRuntime.create()
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
runtime.provide('locale', new LocaleService(runtime.ctx))
const locale = new LocaleService(runtime.ctx)
runtime.provide('locale', locale)
runtime.slots.installLocale(locale)
await runtime.sessions.add({
id: SID,
summary: { title: 'S', displayTitle: 'S', cwd: '/proj' },
@@ -116,7 +118,7 @@ describe('todo_write assembly (product registrations, no outlet twins)', () => {
// (default-collapsed: the header summary shows; rows appear on expand).
const panel = view.container.querySelector('[data-testid="todo-panel"]')
expect(panel).not.toBeNull()
expect(panel!.textContent).toContain('1/3 tasks · 1 in progress')
expect(panel!.textContent).toContain('1/3 项任务 · 1 项进行中')
fireEvent.click(panel!.querySelector('button')!)
expect([...panel!.querySelectorAll('li')].map(li => li.getAttribute('data-status')))
.toEqual(['completed', 'in_progress', 'pending'])
@@ -162,7 +164,9 @@ describe('resident composer', () => {
it('renders the locked view state while no session exists at all', async () => {
const runtime = await SlotTestRuntime.create()
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
runtime.provide('locale', new LocaleService(runtime.ctx))
const locale = new LocaleService(runtime.ctx)
runtime.provide('locale', locale)
runtime.slots.installLocale(locale)
await runtime.root.declare(LAYOUT_CHILDREN, AppRoot)
await runtime.mount({ inject: [...inject], apply })
const view = runtime.renderRoot()
@@ -171,7 +175,7 @@ describe('resident composer', () => {
const textarea = view.container.querySelector('textarea')
expect(textarea).not.toBeNull()
expect(textarea!.disabled).toBe(true)
expect(view.getByRole('button', { name: 'Choose workspace' })).toBeTruthy()
expect(view.getByRole('button', { name: '选择工作区' })).toBeTruthy()
await runtime.dispose()
})
@@ -204,7 +208,9 @@ describe('prompt rejection through the assembled composer', () => {
it('renders the promptError alert strip and keeps the draft in the machine', async () => {
const runtime = await SlotTestRuntime.create()
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
runtime.provide('locale', new LocaleService(runtime.ctx))
const locale = new LocaleService(runtime.ctx)
runtime.provide('locale', locale)
runtime.slots.installLocale(locale)
const prompt = vi.fn<ISession['prompt']>(async () => ({
ok: false, error: { code: 'agent-busy', message: 'prompt rejected before acceptance', details: { reason: 'busy' } },
}))
@@ -245,7 +251,7 @@ describe('title projection across assembled surfaces', () => {
const runtime = await bench([])
const view = runtime.renderRoot()
// The strict session header breadcrumb reads useSessions ancestry.
const crumb = within(view.container.querySelector('[aria-label="Session hierarchy"]') as HTMLElement)
const crumb = within(view.container.querySelector('[aria-label="会话层级"]') as HTMLElement)
expect(crumb.getByText('S')).toBeTruthy()
await runtime.sessions.updateSummary(SID, { displayTitle: '修订标题', title: '修订标题' })

View File

@@ -10,6 +10,7 @@
import { describe, expect, it, vi } from 'vitest'
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
@@ -23,7 +24,9 @@ async function bench() {
await runtime.sessions.add(
{ id: CHILD, summary: { title: 'C', displayTitle: 'C', parentId: ROOT } }, { current: false })
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
runtime.provide('locale', new LocaleService(runtime.ctx))
const locale = new LocaleService(runtime.ctx)
runtime.provide('locale', locale)
runtime.slots.installLocale(locale)
// Declared by ui-layout's root entry in production; the test root declares
// them here so the contributions land.
@@ -52,7 +55,8 @@ describe('apply wiring', () => {
const b = await bench()
const entries = b.slots.entries('conversation.view')
expect(entries.map(e => e.options.id)).toEqual(['chat'])
expect(entries[0]?.options.label).toBe('Chat')
// Label is a locale thunk resolving through the zh dictionary.
expect(resolveSlotLabel(entries[0]?.options.label)).toBe('对话')
expect(entries[0]?.options.order).toBe(0)
// Declaring is claiming: the chat entry's registration put the hole on
// the ledger with the contract's kind/scope.

View File

@@ -8,15 +8,21 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import {
formatMessageClock, msUntilNextLocalMidnight, startOfLocalDay,
} from '../src/client/chat/message-chrome.ts'
import { MessageItem } from '../src/client/chat/MessageItem.tsx'
import { MessageItem, type MessageItemProps } from '../src/client/chat/MessageItem.tsx'
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
import { StatsLine, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
import { zh } from '../src/client/locales.ts'
afterEach(cleanup)
// Mirrors the real lookup chain (conversation namespace, then common).
const t: MessageItemProps['t'] = makeTranslate(zh, commonZh)
describe('MessageItem arms', () => {
it('user bubbles expose clock / copy / branch / edit; copy writes the text', () => {
const writeText = vi.fn().mockResolvedValue(undefined)
@@ -28,7 +34,7 @@ describe('MessageItem arms', () => {
const now = new Date()
const time = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 14, 24).getTime()
render(
<MessageItem node={{
<MessageItem t={t} node={{
kind: 'user', seq: 1, time,
content: [{ type: 'text', text: 'hello bubble' }] as never,
source: null,
@@ -54,7 +60,7 @@ describe('MessageItem arms', () => {
value: exec,
})
render(
<MessageItem node={{
<MessageItem t={t} node={{
kind: 'user', seq: 1, time: 1_000,
content: [{ type: 'text', text: 'fallback body' }] as never,
source: null,
@@ -77,7 +83,7 @@ describe('MessageItem arms', () => {
},
})
render(
<MessageItem node={{
<MessageItem t={t} node={{
kind: 'user', seq: 1, time: 1_000,
content: [{ type: 'text', text: 'quiet' }] as never,
source: null,
@@ -95,7 +101,7 @@ describe('MessageItem arms', () => {
it('steering bubbles carry the interjection badge and non-text rest blocks, without user actions', () => {
const view = render(
<MessageItem node={{
<MessageItem t={t} node={{
kind: 'steering', seq: 2, turn: 1, source: null,
content: [{ type: 'text', text: 'steer!' }, { type: 'image', data: 'x' }] as never,
} as never}
@@ -109,7 +115,7 @@ describe('MessageItem arms', () => {
it('context uses the Tool calls disclosure chrome and keeps its JSON collapsed by default', () => {
const ctxView = render(
<MessageItem node={{
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'x\n"y":,[{}]' }],
@@ -135,7 +141,7 @@ describe('MessageItem arms', () => {
it('context preserves the bounded JSON truncation contract', () => {
const view = render(
<MessageItem node={{
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'x'.repeat(21_000) }],
@@ -150,7 +156,7 @@ describe('MessageItem arms', () => {
it('unknown nodes retain the generic JSON row', () => {
const unknownView = render(
<MessageItem node={{ kind: 'unknown', seq: 4, type: 'surface/next', data: { x: 1 } } as never} />,
<MessageItem t={t} node={{ kind: 'unknown', seq: 4, type: 'surface/next', data: { x: 1 } } as never} />,
)
expect(unknownView.getByText(/未知 surface 事件:surface\/next/)).toBeTruthy()
})
@@ -160,15 +166,15 @@ describe('formatMessageClock', () => {
const now = new Date(2026, 6, 29, 10, 0).getTime()
it('keeps HH:mm on the same calendar day', () => {
expect(formatMessageClock(new Date(2026, 6, 29, 14, 24).getTime(), now)).toBe('14:24')
expect(formatMessageClock(new Date(2026, 6, 29, 14, 24).getTime(), t, now)).toBe('14:24')
})
it('prefixes month and day across days in the same year', () => {
expect(formatMessageClock(new Date(2026, 0, 1, 14, 24).getTime(), now)).toBe('1月1日 14:24')
expect(formatMessageClock(new Date(2026, 0, 1, 14, 24).getTime(), t, now)).toBe('1月1日 14:24')
})
it('prefixes year, month, and day across years', () => {
expect(formatMessageClock(new Date(2025, 11, 31, 9, 5).getTime(), now)).toBe('2025年12月31日 09:05')
expect(formatMessageClock(new Date(2025, 11, 31, 9, 5).getTime(), t, now)).toBe('2025年12月31日 09:05')
})
it('arms the next local midnight from an in-day instant', () => {
@@ -191,7 +197,7 @@ describe('useCalendarDay boundary refresh', () => {
vi.setSystemTime(dayStart)
const time = new Date(2026, 6, 29, 14, 24).getTime()
render(
<MessageItem node={{
<MessageItem t={t} node={{
kind: 'user', seq: 1, time,
content: [{ type: 'text', text: 'night bubble' }] as never,
source: null,
@@ -209,7 +215,7 @@ describe('useCalendarDay boundary refresh', () => {
describe('small branch tails', () => {
it('AssistantMarkdown single-line reasoning summary skips the newline cut', () => {
const view = render(
<AssistantMarkdown blocks={[{ kind: 'reasoning', text: 'one-liner' }]} streaming={false} />,
<AssistantMarkdown t={t} blocks={[{ kind: 'reasoning', text: 'one-liner' }]} streaming={false} />,
)
expect(view.getByText('one-liner')).toBeTruthy()
})
@@ -224,6 +230,7 @@ describe('small branch tails', () => {
const time = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 14, 24).getTime()
const settled = render(
<AssistantMarkdown
t={t}
blocks={[{ kind: 'text', text: 'answer body' }, { kind: 'reasoning', text: 'hidden' }]}
streaming={false}
time={time}
@@ -238,6 +245,7 @@ describe('small branch tails', () => {
const thinkOnly = render(
<AssistantMarkdown
t={t}
blocks={[{ kind: 'reasoning', text: 'only thinking' }]}
streaming={false}
time={time}
@@ -248,7 +256,7 @@ describe('small branch tails', () => {
thinkOnly.unmount()
const streaming = render(
<AssistantMarkdown blocks={[{ kind: 'text', text: 'partial' }]} streaming time={time} />,
<AssistantMarkdown t={t} blocks={[{ kind: 'text', text: 'partial' }]} streaming time={time} />,
)
expect(streaming.queryByRole('button', { name: '复制' })).toBeNull()
expect(streaming.queryByText('14:24')).toBeNull()

View File

@@ -137,7 +137,9 @@ async function bench(snapshot: ConversationSnapshot) {
}
ctx.provide('workspaces', workspaces)
ctx.provide('layout', layout)
ctx.provide('locale', new LocaleService(ctx))
const locale = new LocaleService(ctx)
ctx.provide('locale', locale)
slots.installLocale(locale)
slots.install(createSlotRenderer())
slots.register({

View File

@@ -11,9 +11,16 @@ import type {
} from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { StatsLine, deriveStats, formatDuration, formatTokens, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
import { BashRow } from '../src/client/toolviews/bash-sample.tsx'
import { zh } from '../src/client/locales.ts'
type BashRowProps = Parameters<typeof BashRow>[0]
// Mirrors the real lookup chain (conversation namespace, then common).
const t: BashRowProps['t'] = makeTranslate(zh, commonZh)
afterEach(cleanup)
@@ -168,12 +175,13 @@ describe('bash sample row', () => {
const rowProps = (sessionId: SessionId, over?: {
store?: ReturnType<typeof listStore>
}): ToolRowProps => ({
}): BashRowProps => ({
callId: 'c1', toolName: 'bash', block: result('c1'),
openFile: vi.fn(),
sessionId,
useSessions: bindSnapshotSelector(over?.store ?? listStore()),
} as unknown as ToolRowProps)
t,
} as unknown as BashRowProps)
it('differential rendering: the scoped variant in sub-sessions, global at roots', () => {
const scoped = render(<BashRow {...rowProps(CHILD)} />)

View File

@@ -4,11 +4,16 @@ import { cleanup, fireEvent, render } from '@testing-library/react'
afterEach(cleanup)
import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { classifyTool, resolveToolPath, toolRowModel } from '../src/client/contract/tool-call-model.ts'
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
import { ToolRow } from '../src/client/chat/ToolRow.tsx'
import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
import type { ToolRowOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { GenericToolCard, type GenericToolCardProps } from '../src/client/chat/GenericToolCard.tsx'
import { zh } from '../src/client/locales.ts'
// Mirrors the real lookup chain (conversation namespace, then common).
const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh)
const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({
callId: 'c1', name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}',
@@ -132,6 +137,7 @@ describe('tool-call-model', () => {
describe('ToolRow', () => {
const rowProps = {
t,
variant: 'bash' as const, icon: <i data-testid="tool-icon" />, title: 'Bash',
summary: 'List files', body: '{\n "a": 1\n}', state: 'ok' as const,
}
@@ -221,6 +227,7 @@ describe('ThinkRow', () => {
it('expands from either Think or the reasoning summary', () => {
const view = render(
<AssistantMarkdown
t={t}
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nCheck persistence' }]}
streaming={false}
/>,
@@ -237,8 +244,8 @@ describe('ThinkRow', () => {
})
describe('GenericToolCard', () => {
const props = (toolName: string, block: RunningToolCall | ToolResultNode): ToolRowOwnerProps => ({
callId: 'c1', toolName, block, openFile: vi.fn(),
const props = (toolName: string, block: RunningToolCall | ToolResultNode): GenericToolCardProps => ({
callId: 'c1', toolName, block, openFile: vi.fn(), t,
})
it('renders the classified variant row from the frozen slice', () => {

View File

@@ -65,7 +65,9 @@ async function bench(nodes: ToolResultNode[]) {
const runtime = await SlotTestRuntime.create()
const layout = { openDetails: vi.fn(), closeDetails: vi.fn() }
runtime.provide('layout', layout)
runtime.provide('locale', new LocaleService(runtime.ctx))
const locale = new LocaleService(runtime.ctx)
runtime.provide('locale', locale)
runtime.slots.installLocale(locale)
await runtime.sessions.add({
id: SID,
summary: { title: 'S', displayTitle: 'S' },
@@ -193,7 +195,9 @@ describe('registrant load-order seam', () => {
it("suspends a registrant on inject: ['slots', 'conversation'] until the service (and the hole) exists", async () => {
const runtime = await SlotTestRuntime.create()
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
runtime.provide('locale', new LocaleService(runtime.ctx))
const locale = new LocaleService(runtime.ctx)
runtime.provide('locale', locale)
runtime.slots.installLocale(locale)
await runtime.root.declare(LAYOUT_CHILDREN, AppRoot)
// Third-party posture, mounted BEFORE ui-conversation: real fiber inject

View File

@@ -14,8 +14,11 @@ import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore, PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import type { ChatViewSlotProps, SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { createChatStore } from '../src/client/stores.ts'
import { ChatView } from '../src/client/chat/ChatView.tsx'
import { zh } from '../src/client/locales.ts'
import { assistantActionsSeqs, deriveChatFlow, flowKeys } from '../src/client/chat/chat-flow.ts'
afterEach(cleanup)
@@ -122,6 +125,8 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
openFile,
loadOlder,
forkAt,
// Mirrors the real lookup chain (conversation namespace, then common).
t: makeTranslate(zh, commonZh),
}
const setSelection = (next: SelectionTarget | null): void => { chat.actions.select(next) }
return { set, ChatView, props, openDetails, openFile, loadOlder, forkAt, setSelection }

View File

@@ -8,12 +8,19 @@ import { cleanup, render } from '@testing-library/react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { RunningToolCall, SessionId, SessionListState, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { ToolRowOwnerProps, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { apply as nodeApply } from '../src/index.ts'
import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
import { GenericToolCard, type GenericToolCardProps } from '../src/client/chat/GenericToolCard.tsx'
import { ToolRow } from '../src/client/chat/ToolRow.tsx'
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
import { BashRow } from '../src/client/toolviews/bash-sample.tsx'
import { zh } from '../src/client/locales.ts'
type BashRowProps = Parameters<typeof BashRow>[0]
// Mirrors the real lookup chain (conversation namespace, then common).
const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh)
afterEach(cleanup)
@@ -24,7 +31,7 @@ describe('tails', () => {
it('ToolRow stopped state renders the warning dot in the leading slot', () => {
const view = render(
<ToolRow variant="bash" icon={<i data-testid="icon" />} title="Bash" summary="s" body={null} state="stopped" />,
<ToolRow t={t} variant="bash" icon={<i data-testid="icon" />} title="Bash" summary="s" body={null} state="stopped" />,
)
expect(view.queryByTestId('icon')).toBeNull()
expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull()
@@ -33,6 +40,7 @@ describe('tails', () => {
it('AssistantMarkdown renders reasoning as a Think row and unknown blocks as JSON fallback', () => {
const view = render(
<AssistantMarkdown
t={t}
blocks={[
{ kind: 'reasoning', text: 'thinking hard\nsecond line' },
{ kind: 'tool-call', callId: 'c', name: 'bash', argsRaw: '{}' },
@@ -45,7 +53,7 @@ describe('tails', () => {
expect(view.getByText('thinking hard')).toBeTruthy()
expect(view.getByText(/未知内容块/)).toBeTruthy()
const stopped = render(
<AssistantMarkdown blocks={[{ kind: 'text', text: 'partial words' }]} streaming={false} interrupted />,
<AssistantMarkdown t={t} blocks={[{ kind: 'text', text: 'partial words' }]} streaming={false} interrupted />,
)
expect(stopped.getByText('已停止')).toBeTruthy()
})
@@ -55,12 +63,13 @@ describe('tails', () => {
// groups is layout noise (no text, no pulse, no interrupted marker).
const empty = render(
<AssistantMarkdown
t={t}
blocks={[{ kind: 'tool-call', callId: 'c', name: 'todo_write', argsRaw: '{}' }]}
streaming={false}
/>,
)
expect(empty.container.firstChild).toBeNull()
const blank = render(<AssistantMarkdown blocks={[]} streaming={false} />)
const blank = render(<AssistantMarkdown t={t} blocks={[]} streaming={false} />)
expect(blank.container.firstChild).toBeNull()
})
@@ -71,8 +80,8 @@ describe('tails', () => {
callTime: 1_000,
content: [], isError: false, callView: null, resultView: null,
}
const props: ToolRowOwnerProps = {
callId: 'c5', toolName: 'todo_write', block: settled, openFile: vi.fn(),
const props: GenericToolCardProps = {
callId: 'c5', toolName: 'todo_write', block: settled, openFile: vi.fn(), t,
}
const view = render(<GenericToolCard {...props} />)
// Settled ok state keeps the variant icon (sparkle) instead of a StateDot.
@@ -91,7 +100,8 @@ describe('tails', () => {
const props = (block: RunningToolCall | ToolResultNode) => ({
callId: 'c1', toolName: 'bash', block, openFile: vi.fn(),
sessionId: sid, useSessions: bindSnapshotSelector(list),
} as unknown as ToolRowProps)
t,
} as unknown as BashRowProps)
const running: RunningToolCall = {
callId: 'c1', name: 'bash', argsRaw: '{"command":"ls","description":"List"}',

View File

@@ -7,10 +7,16 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import type { ConversationSnapshot, SessionId, SessionListState, WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client'
import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { createChatStore } from '../src/client/stores.ts'
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx'
import { StatsLine } from '../src/client/chat/StatsLine.tsx'
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
import { zh } from '../src/client/locales.ts'
// Mirrors the real lookup chain (conversation namespace, then common).
const t: AssistantMarkdownProps['t'] = makeTranslate(zh, commonZh)
afterEach(cleanup)
@@ -28,6 +34,7 @@ describe('render branch tails', () => {
it('AssistantMarkdown reasoning row is ok-state when not the streaming tail', () => {
const view = render(
<AssistantMarkdown
t={t}
blocks={[{ kind: 'reasoning', text: 'done thinking' }, { kind: 'text', text: 'answer' }]}
streaming
/>,
@@ -54,7 +61,7 @@ describe('render branch tails', () => {
it('AssistantMarkdown reasoning as the streaming tail renders the running ring', () => {
const view = render(
<AssistantMarkdown blocks={[{ kind: 'reasoning', text: 'still thinking' }]} streaming />,
<AssistantMarkdown t={t} blocks={[{ kind: 'reasoning', text: 'still thinking' }]} streaming />,
)
expect(view.container.querySelector('[data-state="running"]')).not.toBeNull()
})
@@ -82,6 +89,7 @@ describe('render branch tails', () => {
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
closeDetails={vi.fn()}
t={t}
/>,
)
expect(view.getByText('详情')).toBeTruthy()
@@ -118,6 +126,7 @@ describe('render branch tails', () => {
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
closeDetails={vi.fn()}
t={t}
/>,
)
// Sub-call material: the sub-tool name titles the panel, args pretty-print,

View File

@@ -8,10 +8,13 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import type { ClientContext, ConversationSnapshot, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { SessionInputShell } from '../src/client/input/facade.ts'
import { InputBar } from '../src/client/skeleton/InputBar.tsx'
import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
import { zh } from '../src/client/locales.ts'
afterEach(cleanup)
@@ -42,7 +45,7 @@ interface BenchOptions {
promptError?: ConversationSnapshot['promptError']
variant?: 'hero' | 'composer'
placeholder?: string
translateHint?: (key: string) => string
t?: InputBarProps['t']
accessory?: React.ReactNode
overlay?: React.ReactNode
leftItems?: React.ReactNode
@@ -101,11 +104,8 @@ function bench(over?: BenchOptions) {
useLexicon: bindSnapshotSelector(shell.lexicon),
stop,
command: () => Promise.resolve(true),
// Mirrors the en 'command.hint' locale entries the production apply wires in.
translateHint: over?.translateHint ?? ((key: string) => ({
'placeholder.default': 'Message the agent',
'placeholder.plan': 'describe your task to generate plan',
} as Record<string, string>)[key] ?? key),
// Mirrors the real lookup chain (conversation namespace, then common).
t: over?.t ?? makeTranslate(zh, commonZh),
renderSlot,
variant: over?.variant ?? 'composer',
...(over?.placeholder !== undefined ? { placeholder: over.placeholder } : {}),
@@ -118,7 +118,7 @@ function bench(over?: BenchOptions) {
const textarea = view.container.querySelector('textarea')!
// aria-label (not role name): title carries the same label and would double-match.
const button = view.container.querySelector<HTMLButtonElement>(
`button[aria-label="${over?.running === true ? 'Stop generating' : 'Send message'}"]`,
`button[aria-label="${over?.running === true ? '停止生成' : '发送消息'}"]`,
)!
return { view, textarea, button, props, sink, shell, wiring: shell, session, stop, slotCalls }
}
@@ -196,7 +196,7 @@ describe('running and lock semantics (queue cut 1)', () => {
fireEvent.change(textarea, { target: { value: '排队消息2' } })
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(sink).toHaveBeenCalledWith('排队消息2', 'queue')
expect(button.getAttribute('aria-label')).toBe('Stop generating')
expect(button.getAttribute('aria-label')).toBe('停止生成')
fireEvent.click(button)
expect(stop).toHaveBeenCalledTimes(1)
})
@@ -204,8 +204,8 @@ describe('running and lock semantics (queue cut 1)', () => {
it('disabled (session removed) locks the textarea and chrome', () => {
const { textarea, view } = bench({ disabled: true })
expect(textarea.disabled).toBe(true)
expect(textarea.placeholder).toBe('Session unavailable')
expect((view.getByLabelText('Add attachment') as HTMLButtonElement).disabled).toBe(true)
expect(textarea.placeholder).toBe('会话不可用')
expect((view.getByLabelText('添加附件') as HTMLButtonElement).disabled).toBe(true)
})
it('idle primary sends and disables on empty draft', () => {
@@ -222,7 +222,7 @@ describe('running and lock semantics (queue cut 1)', () => {
const textarea = first.view.container.querySelector('textarea')!
expect(document.activeElement).toBe(textarea)
textarea.blur()
fireEvent.mouseDown(first.view.container.querySelector('button[aria-label="Send message"]')!)
fireEvent.mouseDown(first.view.container.querySelector('button[aria-label="发送消息"]')!)
expect(document.activeElement).toBe(textarea)
})
@@ -285,22 +285,22 @@ describe('running and lock semantics (queue cut 1)', () => {
it('disabled state shows the unavailable placeholder; custom placeholder wins', () => {
const { textarea } = bench({ disabled: true })
expect(textarea.placeholder).toBe('Session unavailable')
expect(textarea.placeholder).toBe('会话不可用')
const live = bench()
expect(live.textarea.placeholder).toBe('Message the agent')
expect(live.textarea.placeholder).toBe('给智能体发消息')
const custom = bench({ placeholder: 'Custom placeholder' })
expect(custom.textarea.placeholder).toBe('Custom placeholder')
})
it('the plan projection swaps the placeholder while its effective target is plan mode', () => {
const active = bench({ plan: { active: true, pending: false } })
expect(active.textarea.placeholder).toBe('describe your task to generate plan')
expect(active.textarea.placeholder).toBe('描述你的任务以生成计划')
// /plan just ran: pending entry already reads as the plan target.
const entering = bench({ plan: { active: false, pending: true } })
expect(entering.textarea.placeholder).toBe('describe your task to generate plan')
expect(entering.textarea.placeholder).toBe('描述你的任务以生成计划')
// Pending exit: target is default again.
const leaving = bench({ plan: { active: true, pending: true } })
expect(leaving.textarea.placeholder).toBe('Message the agent')
expect(leaving.textarea.placeholder).toBe('给智能体发消息')
// Owner placeholder outranks the plan swap.
const custom = bench({ plan: { active: true, pending: false }, placeholder: 'Custom placeholder' })
expect(custom.textarea.placeholder).toBe('Custom placeholder')
@@ -325,13 +325,14 @@ describe('machine pending lock', () => {
expect(shell.snapshot.phase).toBe('submitting')
const textarea = view.container.querySelector('textarea')!
expect(textarea.readOnly).toBe(true)
expect(view.container.querySelector<HTMLButtonElement>('button[aria-label="Send message"]')!.disabled).toBe(true)
expect(view.container.querySelector<HTMLButtonElement>('button[aria-label="发送消息"]')!.disabled).toBe(true)
})
})
describe('decorations', () => {
it('claimed token renders the mirror highlight and the blank-args hint', () => {
const { view, shell } = bench()
// Dictionary-less stub: an unmatched hint key keeps the machine's raw hint.
const { view, shell } = bench({ t: makeTranslate({}) })
act(() => {
shell.setDraft('/goal ')
shell.beginCommand(
@@ -349,8 +350,7 @@ describe('decorations', () => {
})
it('a locale entry for the claimed command overrides the raw claim hint (trailing-space token)', () => {
const dict: Record<string, string> = { goal: '输入目标,智能体将持续执行' }
const { view, shell } = bench({ translateHint: key => dict[key] ?? key })
const { view, shell } = bench()
act(() => {
shell.setDraft('/goal ')
shell.beginCommand(
@@ -441,9 +441,9 @@ describe('strips and variants', () => {
describe('placeholder chrome and control seats', () => {
it('renders attach; the Access chip is absent without the permissions projection; plan/model seats render EMPTY without entries (B ruling)', () => {
const { view, slotCalls } = bench()
expect(view.getByLabelText('Add attachment')).toBeTruthy()
expect(view.getByLabelText('添加附件')).toBeTruthy()
// Capability absent (no projection value): the chip renders nothing.
expect(view.queryByLabelText(/^Access mode/)).toBeNull()
expect(view.queryByLabelText(/^访问模式/)).toBeNull()
// Both seats dispatched, nothing rendered.
expect(slotCalls.map(c => c.key)).toEqual(['conversation.input.plan', 'conversation.input.model'])
expect(view.queryByLabelText('Plan mode')).toBeNull()
@@ -459,7 +459,7 @@ describe('placeholder chrome and control seats', () => {
currentValue: 'workspace-write',
}
const { view } = bench({ permissions })
const trigger = view.getByLabelText(/^Access mode/) as HTMLButtonElement
const trigger = view.getByLabelText(/^访问模式/) as HTMLButtonElement
// Title-case display is presentation only; the menu ids stay machine names.
expect(trigger.textContent).toBe('Workspace Write')
fireEvent.click(trigger)
@@ -467,11 +467,11 @@ describe('placeholder chrome and control seats', () => {
expect(items.map(o => o.textContent)).toEqual(['Workspace Write', 'Danger Full Access'])
fireEvent.click(items[1]!)
// Optimistic pick + disable until admission resolves (command stub resolves true).
const busy = view.getByLabelText(/^Access mode/) as HTMLButtonElement
const busy = view.getByLabelText(/^访问模式/) as HTMLButtonElement
expect(busy.textContent).toBe('Danger Full Access')
expect(busy.disabled).toBe(true)
await act(async () => {})
expect((view.getByLabelText(/^Access mode/) as HTMLButtonElement).disabled).toBe(false)
expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).disabled).toBe(false)
})
it('a registered entry fills its seat and receives the locked owner prop', () => {
@@ -492,10 +492,10 @@ describe('placeholder chrome and control seats', () => {
it('disabled locks the Access chip and attach control (running does not)', () => {
const permissions = { options: [{ value: 'workspace-write', name: 'workspace-write' }], currentValue: 'workspace-write' }
const { view } = bench({ disabled: true, permissions })
expect((view.getByLabelText('Add attachment') as HTMLButtonElement).disabled).toBe(true)
expect((view.getByLabelText(/^Access mode/) as HTMLButtonElement).disabled).toBe(true)
expect((view.getByLabelText('添加附件') as HTMLButtonElement).disabled).toBe(true)
expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).disabled).toBe(true)
cleanup()
const live = bench({ running: true, permissions })
expect((live.view.getByLabelText(/^Access mode/) as HTMLButtonElement).disabled).toBe(false)
expect((live.view.getByLabelText(/^访问模式/) as HTMLButtonElement).disabled).toBe(false)
})
})

View File

@@ -11,9 +11,12 @@ import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { ClientContext, ConversationSnapshot, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { SubmitOutcome } from '@deepseek-ai/dsh-client-ui-slash/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { SessionInputShell } from '../src/client/input/facade.ts'
import { InputBar } from '../src/client/skeleton/InputBar.tsx'
import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
import { zh } from '../src/client/locales.ts'
afterEach(cleanup)
@@ -48,7 +51,8 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
renderSlot: (() => null) as InputBarProps['renderSlot'],
stop: vi.fn(),
command: () => Promise.resolve(true),
translateHint: (key: string) => key,
// Mirrors the real lookup chain (conversation namespace, then common).
t: makeTranslate(zh, commonZh),
variant: 'composer',
}
return render(<InputBar {...props} />)
@@ -92,7 +96,8 @@ describe('matrix row: claimed', () => {
claim()
expect(shell.snapshot.claim).toEqual({ token: '/goal ', hint: '目标' })
expect(view.container.querySelector('[data-decoration="token"]')?.textContent).toBe('/goal ')
expect(view.container.querySelector('[data-decoration="hint"]')?.textContent).toBe('目标')
// The zh dictionary owns a hint.goal entry, which overrides the raw claim hint (production behavior).
expect(view.container.querySelector('[data-decoration="hint"]')?.textContent).toBe('输入目标,智能体将持续执行')
expect((textarea).readOnly).toBe(false)
// Free editing beyond the token: hint drops, claim holds.
fireEvent.change(textarea, { target: { value: '/goal 发布版本' } })
@@ -170,7 +175,7 @@ describe('matrix row: locked (session disabled)', () => {
it('disables the textarea and chrome; the machine currency is untouched', () => {
const { view, textarea, shell } = bench({ disabled: true })
expect((textarea).disabled).toBe(true)
expect((view.getByLabelText('Add attachment') as HTMLButtonElement).disabled).toBe(true)
expect((view.getByLabelText('添加附件') as HTMLButtonElement).disabled).toBe(true)
expect(shell.snapshot.phase).toBe('plain')
})

View File

@@ -15,9 +15,12 @@ import { SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client'
import type { ClientSessionContext, CommandClaim, PickOutcome, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-slash/client'
import { FakeApiClient, ok } from '../../runtime/tests/fake-api.ts'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { SessionInputShell } from '../src/client/input/facade.ts'
import { InputBar } from '../src/client/skeleton/InputBar.tsx'
import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
import { zh } from '../src/client/locales.ts'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
@@ -134,7 +137,8 @@ async function scopedBench(register?: (slash: SlashService) => void) {
renderSlot: (() => null) as InputBarProps['renderSlot'],
stop: vi.fn(),
command: () => Promise.resolve(true),
translateHint: (key: string) => key,
// Mirrors the real lookup chain (conversation namespace, then common).
t: makeTranslate(zh, commonZh),
variant: 'composer',
}
const view = render(<InputBar {...barProps} />)
@@ -168,7 +172,8 @@ describe('scenario A: menu-pick /goal, type args, enter submits', () => {
expect(b.shell.snapshot.phase).toBe('claimed')
expect(b.textarea.value).toBe('/goal ')
expect(b.view.container.querySelector('[data-decoration="token"]')?.textContent).toBe('/goal ')
expect(b.view.container.querySelector('[data-decoration="hint"]')?.textContent).toBe('目标内容')
// The zh dictionary owns a hint.goal entry, which overrides the machine's raw hint (production behavior).
expect(b.view.container.querySelector('[data-decoration="hint"]')?.textContent).toBe('输入目标,智能体将持续执行')
// Continue typing args; hint drops; claim holds.
b.type('/goal 发布 v1')
expect(b.shell.snapshot.phase).toBe('claimed')

View File

@@ -10,9 +10,12 @@ import type {
ConversationSnapshot, QueuedMessage, SessionId, SessionListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import type { QueueItemId } from '../src/client/contract/queue.ts'
import type { InputState } from '../src/client/input/contract.ts'
import { QueueDock, queueDockEntry, type QueueDockInjected } from '../src/client/queue/QueueDock.tsx'
import { zh } from '../src/client/locales.ts'
import { QueueDock, queueDockEntry, type QueueDockInjected, type QueueDockProps } from '../src/client/queue/QueueDock.tsx'
afterEach(cleanup)
@@ -54,9 +57,13 @@ function liveSession(initial: ConversationSnapshot) {
const INPUT_STATE: InputState = { draft: '', draftRev: 0, phase: 'plain', occurrences: [], queue: [] }
// Standard locale seat stub mirroring the real ns → common → key chain.
const t: QueueDockProps['t'] = makeTranslate(zh, commonZh)
function kitFor(snapshot: ConversationSnapshot, injected: Partial<QueueDockInjected> = {}) {
return {
sessionId: SID,
t,
useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<SessionListState>,
useWorkspaces: (() => { throw new Error('unused') }) as never,
useProjection: (() => undefined) as never,

View File

@@ -11,8 +11,11 @@ import type {
} from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationRootProps } from '../src/client/skeleton/ConversationRoot.tsx'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { createChatStore } from '../src/client/stores.ts'
import { SessionInputShell } from '../src/client/input/facade.ts'
import { zh } from '../src/client/locales.ts'
import { ConversationRoot } from '../src/client/skeleton/ConversationRoot.tsx'
import { ConversationSession } from '../src/client/skeleton/ConversationSession.tsx'
import { InputBar } from '../src/client/skeleton/InputBar.tsx'
@@ -44,6 +47,9 @@ beforeEach(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
})
// Mirrors the real lookup chain (conversation namespace, then common).
const t: ConversationRootProps['t'] = makeTranslate(zh, commonZh)
const sid = (id: string) => id as SessionId
const wid = (id: string) => id as WorkspaceId
const SID = sid('s1')
@@ -126,6 +132,7 @@ function mount(
}}
bindDraftMirror={write => wiring.bindMirror(write)}
open={open}
t={t}
{...owner}
/>
)
@@ -149,7 +156,7 @@ function mount(
useLexicon={bindSnapshotSelector(wiring.lexicon)}
stop={stop}
command={() => Promise.resolve(true)}
translateHint={(key: string) => key}
t={t}
renderSlot={(() => null) as InputBarProps['renderSlot']}
{...bar}
/>
@@ -181,6 +188,7 @@ function mount(
renderSlot,
renderSlotChain,
selectWorkspace: retargetWorkspace,
t,
}
const view = render(<ConversationRoot {...props} />)
return {
@@ -241,7 +249,7 @@ describe('ConversationRoot resident composer', () => {
const header = b.view.container.querySelector('header')
expect(host).not.toBeNull()
expect(header?.getAttribute('aria-hidden')).toBe('true')
expect(b.view.getByText("Let's start building")).toBeTruthy()
expect(b.view.getByText('开始构建吧')).toBeTruthy()
expect(b.view.queryByTestId('view-chat')).toBeNull()
// The same machine-backed textarea is live in the hero, and the
// persistence mirror stays bound (ConversationSession mounts chrome-hidden
@@ -252,7 +260,7 @@ describe('ConversationRoot resident composer', () => {
expect(b.chat.store.getSnapshot().draft).toBe('draft in hero')
// Picker: open through the chip; a pick switches to the other
// workspace's blank session (draft carry is apply-layer wiring).
fireEvent.click(b.view.getByRole('button', { name: 'Choose workspace' }))
fireEvent.click(b.view.getByRole('button', { name: '选择工作区' }))
const owner = b.pickerOwner() as { open: boolean; onPick(id: WorkspaceId): void }
expect(owner.open).toBe(true)
act(() => { owner.onPick(wid('second')) })
@@ -274,7 +282,7 @@ describe('ConversationRoot resident composer', () => {
expect(after.value).toBe('kept across flip')
expect(b.chat.store.getSnapshot().draft).toBe('kept across flip')
expect(b.view.container.querySelector('[data-conversation-scroll]')?.contains(after)).toBe(true)
expect(b.view.queryByText("Let's start building")).toBeNull()
expect(b.view.queryByText('开始构建吧')).toBeNull()
expect(b.view.getByTestId('view-chat')).toBeTruthy()
})
@@ -295,7 +303,7 @@ describe('ConversationRoot resident composer', () => {
],
selectWorkspace,
)
fireEvent.click(b.view.getByRole('button', { name: 'Choose workspace' }))
fireEvent.click(b.view.getByRole('button', { name: '选择工作区' }))
const owner = b.pickerOwner() as { onPick(id: WorkspaceId): void }
await act(async () => { owner.onPick(wid('second')); await Promise.resolve() })
expect(selectWorkspace).toHaveBeenCalledWith(wid('second'))
@@ -305,7 +313,7 @@ describe('ConversationRoot resident composer', () => {
it('blank session keeps the interactive picker chip (workspace switchable until the first message)', () => {
const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true }))
const chip = b.view.getByRole('button', { name: 'Choose workspace' })
const chip = b.view.getByRole('button', { name: '选择工作区' })
expect((chip as HTMLButtonElement).disabled).toBe(false)
expect(b.slotCalls).toContain('conversation.hero.workspace')
})

View File

@@ -12,12 +12,20 @@ import type {
ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
import type { SelectionTarget, ToolRowOwnerProps, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { CHAT_TERMINAL_MAX_LINES, terminalCardModel } from '../src/client/contract/terminal-card-model.ts'
import { createChatStore } from '../src/client/stores.ts'
import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
import { GenericToolCard, type GenericToolCardProps } from '../src/client/chat/GenericToolCard.tsx'
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
import { BashRow } from '../src/client/toolviews/bash-sample.tsx'
import { zh } from '../src/client/locales.ts'
type BashRowProps = Parameters<typeof BashRow>[0]
// Mirrors the real lookup chain (conversation namespace, then common).
const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh)
afterEach(cleanup)
@@ -217,8 +225,8 @@ describe('terminalCardModel', () => {
})
describe('chat row terminal body', () => {
const ownerProps = (block: RunningToolCall | ToolResultNode): ToolRowOwnerProps => ({
callId: 'c1', toolName: 'bash', block, openFile: vi.fn(),
const ownerProps = (block: RunningToolCall | ToolResultNode): GenericToolCardProps => ({
callId: 'c1', toolName: 'bash', block, openFile: vi.fn(), t,
})
it('the expanded body is the command output, capped tighter than the panel', () => {
@@ -316,10 +324,11 @@ describe('BashRow terminal card', () => {
phase: 'ready',
})
const rowProps = (block: RunningToolCall | ToolResultNode): ToolRowProps => ({
const rowProps = (block: RunningToolCall | ToolResultNode): BashRowProps => ({
callId: 'c1', toolName: 'bash', block, openFile: vi.fn(),
sessionId: SID, useSessions: bindSnapshotSelector(list()),
} as unknown as ToolRowProps)
t,
} as unknown as BashRowProps)
it('renders the command output under the summary row, without an expand gesture', () => {
const view = render(<BashRow {...rowProps(settled())} />)
@@ -400,6 +409,7 @@ describe('DetailsPanel Output section', () => {
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
closeDetails={vi.fn()}
t={t}
/>,
)
}
@@ -507,7 +517,7 @@ describe('DetailsPanel Output section', () => {
// No terminal card: the generic path renders the result text in the Output
// section's <pre> (the Input section has its own, hence the scoping).
expect(view.container.querySelector('[data-terminal]')).toBeNull()
const output = view.getByText('Output').closest('section')
const output = view.getByText('输出').closest('section')
expect(output?.querySelector('pre')?.textContent).toContain('a.ts b.ts')
})
@@ -524,8 +534,8 @@ describe('DetailsPanel Output section', () => {
nodes: [settled({ call: null, callView: null, resultView: resultTerminal({ title: 'ls -la' }) })],
}), target)
expect(view.getByText('c1')).toBeTruthy()
expect(view.queryByText('Input')).toBeNull()
expect(view.getByText('Output')).toBeTruthy()
expect(view.queryByText('输入')).toBeNull()
expect(view.getByText('输出')).toBeTruthy()
})
it('scans past other nodes and other calls before reporting the call out of window', () => {
@@ -571,6 +581,7 @@ describe('DetailsPanel Output section', () => {
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
closeDetails={closeDetails}
t={t}
/>,
)
fireEvent.click(view.getByRole('button', { name: '关闭详情' }))
@@ -586,7 +597,7 @@ describe('DetailsPanel Output section', () => {
}), target)
// Scope to the Output section: the Input section's CodeBlock renders a
// <pre> of its own, and it comes first in document order.
expect(nonText.getByText('Output').closest('section')?.querySelector('pre')?.textContent)
expect(nonText.getByText('输出').closest('section')?.querySelector('pre')?.textContent)
.toBe('{\n "type": "reasoning",\n "text": "why"\n}')
cleanup()
const empty = mount(snapshot({

View File

@@ -11,11 +11,18 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { TodoItem, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
// Export discipline: packages/client/AGENTS.md.
import { TodoRow, todoToolview } from '../src/client/toolviews/todo-row.tsx'
import type { TodoDockProps } from '../src/client/skeleton/TodoPanel.tsx'
import { TodoDock, TodoPanel, todoDockEntry } from '../src/client/skeleton/TodoPanel.tsx'
import { NS, zh } from '../src/client/locales.ts'
type TodoRowProps = Parameters<typeof TodoRow>[0]
// Mirrors the real lookup chain (conversation namespace, then common).
const t: TodoDockProps['t'] = makeTranslate(zh, commonZh)
afterEach(cleanup)
@@ -27,21 +34,21 @@ const LIST: TodoItem[] = [
describe('TodoPanel', () => {
it('renders nothing while the list is empty', () => {
const { container } = render(<TodoPanel todos={[]} />)
const { container } = render(<TodoPanel todos={[]} t={t} />)
expect(container.innerHTML).toBe('')
})
it('starts collapsed with the progress summary visible', () => {
render(<TodoPanel todos={LIST} />)
render(<TodoPanel todos={LIST} t={t} />)
expect(screen.getByTestId('todo-panel')).toBeTruthy()
expect(screen.getByText('To-dos')).toBeTruthy()
expect(screen.getByText('1/3 tasks · 1 in progress')).toBeTruthy()
expect(screen.getByText('任务清单')).toBeTruthy()
expect(screen.getByText('1/3 项任务 · 1 项进行中')).toBeTruthy()
expect(screen.getByRole('button', { expanded: false })).toBeTruthy()
expect(screen.queryByRole('list')).toBeNull()
})
it('expands to show one row per item with its status glyph', () => {
render(<TodoPanel todos={LIST} />)
render(<TodoPanel todos={LIST} t={t} />)
fireEvent.click(screen.getByRole('button', { expanded: false }))
const items = screen.getAllByRole('listitem')
expect(items.map(li => li.getAttribute('data-status'))).toEqual(['completed', 'in_progress', 'pending'])
@@ -52,23 +59,23 @@ describe('TodoPanel', () => {
})
it('collapse hides an expanded list; expand restores; header keeps the count summary', () => {
render(<TodoPanel todos={LIST} />)
render(<TodoPanel todos={LIST} t={t} />)
fireEvent.click(screen.getByRole('button', { expanded: false }))
const header = screen.getByRole('button', { expanded: true })
fireEvent.click(header)
expect(screen.queryByRole('list')).toBeNull()
// Collapsed header is title + progress only (no in-progress content hint).
expect(screen.getByText('1/3 tasks · 1 in progress')).toBeTruthy()
expect(screen.getByText('1/3 项任务 · 1 项进行中')).toBeTruthy()
expect(screen.queryByText('写组件')).toBeNull()
fireEvent.click(screen.getByRole('button', { expanded: false }))
expect(screen.getAllByRole('listitem')).toHaveLength(3)
})
it('collapsed header still shows zero in-progress when nothing is active', () => {
render(<TodoPanel todos={[{ content: '都完了', status: 'completed' }]} />)
render(<TodoPanel todos={[{ content: '都完了', status: 'completed' }]} t={t} />)
expect(screen.getByRole('button', { expanded: false })).toBeTruthy()
expect(screen.queryByText('都完了')).toBeNull()
expect(screen.getByText('1/1 tasks · 0 in progress')).toBeTruthy()
expect(screen.getByText('1/1 项任务 · 0 项进行中')).toBeTruthy()
})
})
@@ -76,7 +83,7 @@ describe('TodoPanel', () => {
function dockProps(store: ReturnType<typeof createSnapshotStore<{ value: readonly TodoItem[] | null | undefined }>>): TodoDockProps {
const useProjection = (_key: string, selector?: (v: unknown) => unknown) =>
bindSnapshotSelector(store)(s => (selector ?? (v => v))(s.value))
return { useProjection } as unknown as TodoDockProps
return { useProjection, t } as unknown as TodoDockProps
}
describe('TodoDock', () => {
@@ -86,7 +93,7 @@ describe('TodoDock', () => {
// Capability absent (no baseline/frame yet) renders nothing.
expect(screen.queryByTestId('todo-panel')).toBeNull()
act(() => { store.set({ value: LIST }) })
expect(screen.getByText('1/3 tasks · 1 in progress')).toBeTruthy()
expect(screen.getByText('1/3 项任务 · 1 项进行中')).toBeTruthy()
// The pre-first-write whole value (null) retires the strip (the panel owns no data).
act(() => { store.set({ value: null }) })
expect(screen.queryByTestId('todo-panel')).toBeNull()
@@ -97,7 +104,7 @@ describe('TodoDock', () => {
expect(todoDockEntry.inject).toEqual(['slots', 'conversation'])
const register = vi.fn()
todoDockEntry.apply({ slots: { register } } as never)
expect(register).toHaveBeenCalledWith({ name: 'conversation.input.dock', id: 'todo', order: 10 }, TodoDock)
expect(register).toHaveBeenCalledWith({ name: 'conversation.input.dock', id: 'todo', order: 10, locale: NS }, TodoDock)
})
})
@@ -107,13 +114,14 @@ const resultNode = (argsRaw: string, over?: Partial<ToolResultNode>): ToolResult
content: [], isError: false, callView: null, resultView: null, ...over,
})
function rowProps(block: unknown): ToolRowProps {
function rowProps(block: unknown): TodoRowProps {
return {
callId: 'c1', toolName: 'todo_write', block,
openFile: vi.fn(),
sessionId: 's1',
useSessions: () => undefined,
} as unknown as ToolRowProps
t,
} as unknown as TodoRowProps
}
describe('TodoRow', () => {
@@ -183,6 +191,6 @@ describe('TodoRow', () => {
expect(todoToolview.inject).toEqual(['slots', 'conversation'])
const register = vi.fn()
todoToolview.apply({ slots: { register } } as never)
expect(register).toHaveBeenCalledWith({ name: 'conversation.chat.toolview', key: 'todo_write' }, TodoRow)
expect(register).toHaveBeenCalledWith({ name: 'conversation.chat.toolview', key: 'todo_write', locale: NS }, TodoRow)
})
})