Merge pinned master into status bar projection

This commit is contained in:
Hypatia May
2026-07-31 09:28:52 +08:00
831 changed files with 23503 additions and 4750 deletions

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' },
@@ -262,6 +252,13 @@ export function apply(ctx: Context): void {
})
},
loadOlder: () => { void scoped.loadOlder() },
forkAt: (seq) => {
sessions.fork({ sessionId, atSeq: seq, increaseTitle: true })
.then((childId) => { sessions.open(childId) })
.catch(() => {
// Fork or child-rename failure keeps the source view untouched.
})
},
}
},
}, ChatView)
@@ -296,6 +293,7 @@ export function apply(ctx: Context): void {
slots.register({
name: 'details',
locale: NS,
store: chatStore,
inject: (): DetailsInjected => ({
closeDetails: () => { layout.closeDetails() },

View File

@@ -4,14 +4,16 @@
// view groups them into tool rows through its keyed toolview slot (figma
// step-summary flow). Shared by finalized nodes and the streaming partial;
// the turn-level loading dots live in the chat view's tail, not here.
// Finalized content (text) nodes append IconActions once streaming ends;
// Think / tool-head-only nodes stay chrome-free.
// Finalized turn-tail content (text) nodes append IconActions once streaming
// 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'
@@ -19,10 +21,17 @@ 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 finalized IconActions clock; omitted while streaming. */
/** Unix epoch ms for the IconActions clock; omitted while streaming or when
* the parent withholds chrome (mid-turn content assistants). */
time?: number | undefined
/** Event sequence used as the fork boundary; omitted while streaming. */
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 {
@@ -45,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"
@@ -60,8 +70,11 @@ function ThinkRow({ text, running }: { text: string; running: boolean }) {
}
export const AssistantMarkdown = memo(function AssistantMarkdown({
blocks, streaming, interrupted, time,
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
@@ -77,21 +90,32 @@ 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
text={copyText(blocks)}
time={time}
clock="end"
onBranch={onFork === undefined || seq === undefined ? undefined : () => { onFork(seq) }}
className={css.actions}
t={t}
/>
)}
</div>

View File

@@ -30,7 +30,7 @@ import type {
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { deriveChatFlow, type ChatFlowItem } from './chat-flow.ts'
import { assistantActionsSeqs, deriveChatFlow, type ChatFlowItem } from './chat-flow.ts'
import { AssistantMarkdown } from './AssistantMarkdown.tsx'
import { GenericCommandCard } from './GenericCommandCard.tsx'
import { GenericToolCard } from './GenericToolCard.tsx'
@@ -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 }: 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,12 +245,15 @@ 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)
const items = useMemo(() => deriveChatFlow(nodes), [nodes])
// Only the last content assistant of each turn owns IconActions; mid-turn
// text (before tools) omits `time` so AssistantMarkdown stays chrome-free.
const actionSeqs = useMemo(() => assistantActionsSeqs(nodes), [nodes])
const listRef = useRef<HTMLDivElement | null>(null)
const atBottomRef = useRef(true)
@@ -365,6 +375,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
selectedCallId={inGroup ? selectedCallId : undefined}
codeDispatches={codeDispatches}
cwd={cwd}
t={t}
/>
)
}
@@ -376,33 +387,40 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
blocks={node.blocks}
streaming={false}
interrupted={node.interrupted}
time={node.time}
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} />
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 => (
@@ -417,6 +435,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
subCalls={codeDispatches.get(call.callId)}
selectedCallId={selectedCallId}
cwd={cwd}
t={t}
/>
))}
</div>
@@ -433,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

@@ -1,12 +1,12 @@
// GenericCommandCard: the default command row — a stripped-down
// GenericToolCard rendering the dispatched command line and the settlement
// text. Supplied by the chat view as the keyed commandview slot's render-site
// GenericToolCard rendering the command name and its settlement text.
// Supplied by the chat view as the keyed commandview slot's render-site
// fallback (an unregistered command name lands here); registrants may compose
// it as a base, feeding the same owner payload through.
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,17 +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' ? '命令失败' : '已完成')
// Display line rebuilt from the structured payload (args carries its own
// separator whitespace verbatim); a cross-window node whose run page fell
// out of the window has neither.
const title = node.name === null ? '命令' : `/${node.name}${node.args ?? ''}`
? 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 ?? 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

@@ -1,10 +1,12 @@
// Shared IconActions chrome for user and assistant messages: copy / branch
// live (branch still a stub), date-aware clock, optional edit stub.
// Shared IconActions chrome for user and assistant messages: copy live,
// branch wired through onBranch, date-aware clock,
// optional edit stub.
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'
@@ -18,17 +20,21 @@ export interface MessageIconActionsProps {
clock: 'start' | 'end'
/** When true, append the stub edit control (user bubble). */
edit?: boolean | undefined
/** Fork the session at this message. */
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']
}
/**
* Copy / branch (/ clock) IconActions row shared by user and assistant chrome.
* @param props - Copy text, event time, clock side, optional edit, className.
* @param props - Copy text, event time, clock side, optional edit, branch callback, className.
* @returns The actions row element.
*/
export function MessageIconActions({
text, time, clock, edit, className,
text, time, clock, edit, onBranch, className, t,
}: MessageIconActionsProps) {
const day = useCalendarDay()
const onCopy = useCallback(() => {
@@ -36,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="在新对话中分支">
<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,12 +10,17 @@ 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'
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[] } {
@@ -61,7 +66,8 @@ function projectUserText(text: string): ReactNode {
return <>{parts}</>
}
export const MessageItem = memo(function MessageItem({ node }: 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)
@@ -69,14 +75,16 @@ export const MessageItem = memo(function MessageItem({ node }: MessageItemProps)
<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}
time={node.time}
clock="start"
edit
onBranch={onFork === undefined ? undefined : () => { onFork(node.seq) }}
className={css.actions}
t={t}
/>
</div>
)
@@ -86,21 +94,21 @@ export const MessageItem = memo(function MessageItem({ node }: MessageItemProps)
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

@@ -3,15 +3,24 @@
* results group into consecutive-run tool groups (figma step-summary flow,
* VERTICAL gap10) alternating with narration; everything else passes through.
* Item identity keys are stable across snapshots so the list parent can
* subscribe to keys only while rows subscribe to content.
* subscribe to keys only while rows subscribe to content. IconActions ownership
* (last content assistant per turn) is derived here too so ChatView and the
* flow share one gate.
*/
import type { ConversationNode, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type {
AssistantBlock, ConversationNode, ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
/** One renderable flow item; key is the React key and the parent's identity unit. */
export type ChatFlowItem =
| { kind: 'node'; key: string; node: ConversationNode }
| { kind: 'tool-group'; key: string; results: readonly ToolResultNode[] }
/** True when the node has model-visible text content worth IconActions chrome. */
function hasContentText(blocks: readonly AssistantBlock[]): boolean {
return blocks.some(block => block.kind === 'text' && block.text.trim() !== '')
}
/** An assistant node that renders nothing: only tool-call heads (rows render
* via the grouping pass) and blank text/reasoning. Skipped by the flow so it
* neither costs column gaps nor splits a tool-row run. Interrupted nodes
@@ -22,6 +31,21 @@ function rendersNothing(node: ConversationNode): boolean {
|| ((b.kind === 'text' || b.kind === 'reasoning') && b.text.trim() === ''))
}
/**
* Seq set of assistants that own IconActions: the last content-text assistant
* in each turn. Mid-turn narration (text before tools) stays chrome-free.
* @param nodes - snapshot nodes (surface order).
* @returns Seq values ChatView may pass as `time` into AssistantMarkdown.
*/
export function assistantActionsSeqs(nodes: readonly ConversationNode[]): ReadonlySet<number> {
const lastByTurn = new Map<number, number>()
for (const node of nodes) {
if (node.kind !== 'assistant' || !hasContentText(node.blocks)) continue
lastByTurn.set(node.turn, node.seq)
}
return new Set(lastByTurn.values())
}
/**
* Group finalized nodes into the step-summary flow.
* @param nodes - snapshot nodes (surface order).

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
@@ -419,12 +423,14 @@ export interface ChatViewInjected {
*/
openFile: (path: string) => void
loadOlder: () => void
/** Fork the session through the turn containing the message at `seq`, then open the child. */
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
@@ -435,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

@@ -19,6 +19,13 @@
border-radius: 20px;
background: var(--dsw-specific-input-major);
box-shadow: var(--dsw-shadow-lv2);
/* Elevated surface in dark, same as the menus: `.body` inside scrolls once
the justification or command passes the cap, so the thumb takes the l2
pair. Declared on the card because the elevation belongs to the surface,
and the custom properties inherit down to the region that actually
scrolls (see ui-theme styles/scrollbar.css for the rebinding contract). */
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
}
/* Tinted full-width header band. */
@@ -40,11 +47,22 @@
background: var(--dsw-alias-state-warn-primary);
}
/* Scroll region: an agent's justification and its command are unbounded model
text (a one-line `cd` or a 40-line heredoc), and the seat sits in a
fixed-height column — uncapped, a long command pushed the action row past
the viewport and the approval could not be answered at all. The strip and
the action row stay outside, so the buttons are always on screen. */
.body {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 16px 14px;
/* border-box so the cap is the region's OUTER height: the composer's draft
area counts its padding inside the same number, and the two seats are
only interchangeable if they occupy the same box. */
box-sizing: border-box;
max-height: var(--dsh-composer-text-max-height);
overflow-y: auto;
padding: 12px 16px 0;
}
/* The model's justification is the panel's message, not a footnote. */
@@ -63,11 +81,15 @@
word-break: break-all;
}
/* Card-level row, not body content. Its padding reproduces the metrics the row
had inside the body: 14px above (the flex gap of 6 plus the row's 8px top
margin, neither of which reaches it out here) and the body's former 14px
bottom pad below, so the resting card is unchanged. */
.actionRow {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 8px;
padding: 14px 16px 14px;
}
.allow,

View File

@@ -4,7 +4,11 @@
// pending, this panel occupies the composer slot in place of the InputBar:
// an amber "Waiting for approval" strip on the card top, the model's
// justification as the headline, the paired command in muted code text, and
// a right-aligned refuse/allow action row. One-shot: the buttons disable
// a right-aligned refuse/allow action row. Justification and command are
// unbounded model text, so they scroll inside the card at the shared composer
// cap (`data-approval-scroll`) and the action row stays outside it — the
// buttons must be reachable no matter how long the command is.
// One-shot: the buttons disable
// after a click and the panel leaves (the InputBar returns) on the broadcast
// resolved frame. The draft's "Always allow this type" is deferred with
// grant storage.
@@ -37,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.
@@ -52,18 +60,21 @@ 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.body}>
<div className={css.headline}>{pending.reason ?? `工具 ${pending.toolName} 请求越权执行`}</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={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 className={css.actionRow}>
<button type="button" className={css.reject} disabled={answered} onClick={() => { answer('rejected') }}>
</button>
<button type="button" className={css.allow} disabled={answered} onClick={() => { answer('allowed-once') }}>
</button>
</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>
</div>

View File

@@ -143,6 +143,14 @@
display: flex;
flex: none;
flex-direction: column;
/* One cap for every scrolling text region a composer seat can hold: the
InputBar draft (figma Input 75:8208 max 14 lines × 24px line) and the
takeover panels' bodies top out at the same height, so electing a
takeover never grows the footer past the card it replaces. Declared on
the seat because it is the chain's only shared ancestor — fallback and
elected overlay are siblings — and custom properties inherit down to
whichever entry is mounted. */
--dsh-composer-text-max-height: 336px;
}
/* Active phase: header is ordinary column chrome above the scrollport (not

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

@@ -209,7 +209,9 @@
.mirror {
visibility: hidden;
pointer-events: none;
max-height: 336px;
/* 14-line cap, shared with the composer takeovers (declared on
ConversationRoot .composerSeat). */
max-height: var(--dsh-composer-text-max-height);
overflow: hidden;
}

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)
},
}