Merge remote-tracking branch 'origin/feat/read-presenter' into feat/web-read-card
# Conflicts: # apps/web/tests/snapshots/code-mode-round/ui.expected.md # packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx # packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx # packages/client/ui-primitives/src/index.ts
This commit is contained in:
@@ -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).
|
||||
@@ -29,6 +29,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']
|
||||
@@ -45,6 +53,10 @@ const ABSENT_LEXICON = {
|
||||
getSnapshot: () => EMPTY_LEXICON,
|
||||
subscribe: () => () => {},
|
||||
}
|
||||
const ABSENT_MENU_LAUNCHER = {
|
||||
getSnapshot: (): string | null => null,
|
||||
subscribe: () => () => {},
|
||||
}
|
||||
|
||||
/** Resolve the session-scoped conversation face (scope-addressed send/cancel), failing loud. */
|
||||
function scopedConversation(sessions: ISessions, id: SessionId): IConversation {
|
||||
@@ -69,42 +81,27 @@ 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()
|
||||
|
||||
// Chat scroll offsets by session, surviving view switches (the chat view
|
||||
// unmounts under the tab ring). Deliberately not persisted: a fresh page
|
||||
// load should keep the open-jump-to-bottom default.
|
||||
const chatScrollTops = new Map<SessionId, number>()
|
||||
|
||||
const viewTabs = (): ViewTab[] => {
|
||||
const tabs: ViewTab[] = []
|
||||
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
|
||||
}
|
||||
@@ -133,6 +130,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' },
|
||||
@@ -164,6 +162,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 => ({
|
||||
@@ -186,6 +185,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).
|
||||
@@ -197,15 +197,28 @@ export function apply(ctx: Context): void {
|
||||
if (sessionId === undefined) {
|
||||
return {
|
||||
keyboard: undefined,
|
||||
toggleCommandMenu: undefined,
|
||||
stop: undefined,
|
||||
command: undefined,
|
||||
translateHint,
|
||||
hooks: { notices: ABSENT_NOTICES, lexicon: ABSENT_LEXICON },
|
||||
hooks: { notices: ABSENT_NOTICES, lexicon: ABSENT_LEXICON, menuLauncher: ABSENT_MENU_LAUNCHER },
|
||||
}
|
||||
}
|
||||
const shell = inputHub.shell(sessionId)
|
||||
const slash = inputHub.slash(sessionId)
|
||||
return {
|
||||
keyboard: shell,
|
||||
toggleCommandMenu: slash === undefined
|
||||
? undefined
|
||||
: (selection) => {
|
||||
shell.dismissPopup()
|
||||
const snapshot = shell.snapshot
|
||||
slash.toggleSource('command', {
|
||||
trigger: '/',
|
||||
query: '',
|
||||
position: snapshot.draft.slice(0, selection.start).trim() === '' ? 'leading' : 'inline',
|
||||
span: { ...selection, draftRev: snapshot.draftRev },
|
||||
})
|
||||
},
|
||||
stop: () => {
|
||||
scopedConversation(sessions, sessionId).cancel().catch(() => {
|
||||
// Stop failure surfaces via snapshot.promptError; nothing to restore.
|
||||
@@ -217,8 +230,11 @@ 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 },
|
||||
hooks: {
|
||||
notices: shell.notices,
|
||||
lexicon: shell.lexicon,
|
||||
menuLauncher: slash?.launcher ?? ABSENT_MENU_LAUNCHER,
|
||||
},
|
||||
}
|
||||
},
|
||||
}, InputBar)
|
||||
@@ -231,7 +247,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
|
||||
@@ -242,7 +258,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' },
|
||||
@@ -263,6 +280,26 @@ export function apply(ctx: Context): void {
|
||||
})
|
||||
},
|
||||
loadOlder: () => { void scoped.loadOlder() },
|
||||
// Unregistered 'trajectory' id is safe: the tab ring falls back to
|
||||
// the first view, and the untouched inspect target stays inert.
|
||||
inspectCall: (callId) => {
|
||||
actions.setInspect({ callId })
|
||||
actions.setView('trajectory')
|
||||
},
|
||||
chatScroll: {
|
||||
save: (top) => {
|
||||
if (top === null) chatScrollTops.delete(sessionId)
|
||||
else chatScrollTops.set(sessionId, top)
|
||||
},
|
||||
read: () => chatScrollTops.get(sessionId) ?? null,
|
||||
},
|
||||
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)
|
||||
@@ -301,6 +338,7 @@ export function apply(ctx: Context): void {
|
||||
|
||||
slots.register({
|
||||
name: 'details',
|
||||
locale: NS,
|
||||
store: chatStore,
|
||||
inject: (): DetailsInjected => ({
|
||||
closeDetails: () => { layout.closeDetails() },
|
||||
|
||||
@@ -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,23 +54,26 @@ 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"
|
||||
summary={firstLine(text)}
|
||||
body={text}
|
||||
state={running ? 'running' : 'ok'}
|
||||
expandOnRowClick
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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 +89,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>
|
||||
|
||||
@@ -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'
|
||||
@@ -46,6 +46,8 @@ function scrollerOf(from: HTMLElement): HTMLElement {
|
||||
|
||||
type OpenFile = (path: string) => void
|
||||
|
||||
type InspectCall = (callId: string) => void
|
||||
|
||||
/** The declared toolview hole's render share (stable framework binding, passed through memoized rows). */
|
||||
type RenderToolRow = ChatViewSlotProps['renderSlot']
|
||||
|
||||
@@ -57,23 +59,26 @@ 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, inspectCall, t }: {
|
||||
renderSlot: RenderToolRow
|
||||
node: CodeSubCall
|
||||
openFile: OpenFile
|
||||
selected: boolean
|
||||
cwd: string | undefined
|
||||
inspectCall: InspectCall
|
||||
t: ChatViewSlotProps['t']
|
||||
}) {
|
||||
const settled = 'kind' in node
|
||||
const toolName = settled ? node.call?.name ?? '' : node.name
|
||||
const owner = useMemo(() => ({
|
||||
callId: node.callId, toolName, block: node, openFile, cwd,
|
||||
}), [node, toolName, openFile, cwd])
|
||||
inspect: () => { inspectCall(node.callId) },
|
||||
}), [node, toolName, openFile, cwd, inspectCall])
|
||||
return (
|
||||
<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 +90,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, inspectCall, t,
|
||||
}: {
|
||||
renderSlot: RenderToolRow
|
||||
callId: string
|
||||
@@ -100,15 +105,18 @@ const CallRow = memo(function CallRow({
|
||||
selectedCallId?: string | undefined
|
||||
/** Session workspace root for path-relative summaries. */
|
||||
cwd: string | undefined
|
||||
inspectCall: InspectCall
|
||||
t: ChatViewSlotProps['t']
|
||||
}) {
|
||||
const owner = useMemo(() => ({
|
||||
callId, toolName, block, openFile, cwd,
|
||||
}), [callId, toolName, block, openFile, cwd])
|
||||
inspect: () => { inspectCall(callId) },
|
||||
}), [callId, toolName, block, openFile, cwd, inspectCall])
|
||||
return (
|
||||
<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 +128,8 @@ const CallRow = memo(function CallRow({
|
||||
openFile={openFile}
|
||||
selected={node.callId === selectedCallId}
|
||||
cwd={cwd}
|
||||
inspectCall={inspectCall}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -129,7 +139,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, inspectCall, t }: {
|
||||
renderSlot: RenderToolRow
|
||||
results: readonly ToolResultNode[]
|
||||
openFile: OpenFile
|
||||
@@ -139,6 +149,8 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selec
|
||||
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
|
||||
/** Session workspace root for path-relative summaries. */
|
||||
cwd: string | undefined
|
||||
inspectCall: InspectCall
|
||||
t: ChatViewSlotProps['t']
|
||||
}) {
|
||||
return (
|
||||
<div className={css.toolGroup}>
|
||||
@@ -154,6 +166,8 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selec
|
||||
subCalls={codeDispatches.get(node.callId)}
|
||||
selectedCallId={selectedCallId}
|
||||
cwd={cwd}
|
||||
inspectCall={inspectCall}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -163,16 +177,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 +229,26 @@ 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, inspectCall, chatScroll, 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 +256,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)
|
||||
@@ -274,10 +295,20 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
|
||||
/* v8 ignore next -- ref-null guard: React attaches the ref before layout effects run. */
|
||||
if (local === null) return
|
||||
const el = scrollerOf(local)
|
||||
// Open completed: jump to the bottom once.
|
||||
// Open completed: jump to the bottom once — unless a scroll position
|
||||
// survives from a previous mount (view-tab switch away and back), which
|
||||
// is restored instead of snapping the reader back to the floor.
|
||||
if (openState === 'open' && !openedRef.current) {
|
||||
openedRef.current = true
|
||||
toBottom(el)
|
||||
const saved = chatScroll.read()
|
||||
if (saved === null) {
|
||||
toBottom(el)
|
||||
} else {
|
||||
el.scrollTop = saved
|
||||
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1
|
||||
atBottomRef.current = isAtBottom
|
||||
setAtBottom(isAtBottom)
|
||||
}
|
||||
firstSeqRef.current = firstSeq
|
||||
lastKeyRef.current = lastKey
|
||||
followSigRef.current = followSig
|
||||
@@ -315,6 +346,9 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
|
||||
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1
|
||||
atBottomRef.current = isAtBottom
|
||||
setAtBottom(isAtBottom)
|
||||
// Continuous save (unmount happens after ref detach, so saving there is
|
||||
// too late); pinned-to-bottom clears so a remount keeps following.
|
||||
chatScroll.save(isAtBottom ? null : el.scrollTop)
|
||||
}
|
||||
|
||||
// Bind scroll to the resolved scrollport (host or local) once per mount.
|
||||
@@ -365,6 +399,8 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
|
||||
selectedCallId={inGroup ? selectedCallId : undefined}
|
||||
codeDispatches={codeDispatches}
|
||||
cwd={cwd}
|
||||
inspectCall={inspectCall}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -376,33 +412,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 +460,8 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
|
||||
subCalls={codeDispatches.get(call.callId)}
|
||||
selectedCallId={selectedCallId}
|
||||
cwd={cwd}
|
||||
inspectCall={inspectCall}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -433,7 +478,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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface DisclosureRowProps {
|
||||
expandOnRowClick?: boolean | undefined
|
||||
/** Replaces the collapsed icon with a chevron while the row is hovered. */
|
||||
previewChevron?: boolean | undefined
|
||||
/** Keeps `collapsedContent` inline while open (ToolRow's summary stays readable next to the expanded card). */
|
||||
keepContentWhenOpen?: boolean | undefined
|
||||
collapsedContent?: ReactNode
|
||||
children?: ReactNode
|
||||
className?: string | undefined
|
||||
@@ -36,6 +38,7 @@ export function DisclosureRow({
|
||||
onToggle,
|
||||
expandOnRowClick = false,
|
||||
previewChevron = expandable,
|
||||
keepContentWhenOpen = false,
|
||||
collapsedContent,
|
||||
children,
|
||||
className,
|
||||
@@ -93,7 +96,7 @@ export function DisclosureRow({
|
||||
</span>
|
||||
)}
|
||||
<span className={clsx(css.title, titleClassName)}>{title}</span>
|
||||
{!open && collapsedContent}
|
||||
{(keepContentWhenOpen || !open) && collapsedContent}
|
||||
</div>
|
||||
{open && children}
|
||||
</div>
|
||||
|
||||
@@ -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,19 +15,26 @@ 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} />}
|
||||
icon={<IconApiOutline14 size={14} />}
|
||||
title={title}
|
||||
summary={summary}
|
||||
// Expandable only when the outcome text overflows a one-line summary.
|
||||
|
||||
@@ -9,9 +9,9 @@ import {
|
||||
IconApiOutline14, IconBrowseOutline16, IconCodeOutline16, IconEditOutline16, IconSearchOutline16, IconSparkle16,
|
||||
IconThinkOutline14, ReadBlock,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ToolRowOwnerProps } from '../contract/slots.ts'
|
||||
import type { ChatViewSlotProps, ToolRowOwnerProps } from '../contract/slots.ts'
|
||||
import { CHAT_READ_MAX_LINES, readCardModel } from '../contract/read-card-model.ts'
|
||||
import { terminalCardModel } from '../contract/terminal-card-model.ts'
|
||||
import { terminalCardModel, terminalFailed } from '../contract/terminal-card-model.ts'
|
||||
import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.ts'
|
||||
import { ToolRow } from './ToolRow.tsx'
|
||||
import css from './GenericToolCard.module.css'
|
||||
@@ -28,13 +28,24 @@ 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, inspect, t }: GenericToolCardProps) {
|
||||
const model = toolRowModel(toolName, block, cwd)
|
||||
const terminal = terminalCardModel(block, cwd)
|
||||
const read = readCardModel(block, cwd)
|
||||
// A failing exit status is the terminal card's own error signal (the call
|
||||
// itself settles isError:false), surfaced as the row's red state dot.
|
||||
const state = model.state === 'ok' && terminal !== null && terminalFailed(terminal)
|
||||
? 'error'
|
||||
: model.state
|
||||
const singleFile = model.filePath !== undefined
|
||||
const row = (
|
||||
<ToolRow
|
||||
t={t}
|
||||
variant={model.variant}
|
||||
toolName={toolName}
|
||||
icon={VARIANT_ICONS[model.variant]}
|
||||
@@ -42,12 +53,14 @@ export function GenericToolCard({ toolName, block, cwd, openFile }: ToolRowOwner
|
||||
// A terminal presenter's description is the contract's above-card text, so
|
||||
// it outranks the args-derived summary here exactly as it does in BashRow.
|
||||
summary={terminal?.description ?? model.summary}
|
||||
// Single-file tools never expose an args body — the path link is the only action.
|
||||
body={singleFile ? null : model.body}
|
||||
body={model.body}
|
||||
output={model.output}
|
||||
errorSummary={model.errorSummary}
|
||||
terminal={terminal}
|
||||
state={model.state}
|
||||
state={state}
|
||||
filePath={model.filePath}
|
||||
onOpenFile={singleFile ? openFile : undefined}
|
||||
inspect={inspect}
|
||||
/>
|
||||
)
|
||||
// A read-declaring tool without its own keyed row lands here (e.g. web_fetch),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -56,6 +56,10 @@
|
||||
background: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.chevron {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 400;
|
||||
}
|
||||
@@ -103,8 +107,65 @@
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Expanded body: pad-left 22 indented gray text, no border, no fill. */
|
||||
.body {
|
||||
/* Error row's collapsed summary: the failure's first line in the error color. */
|
||||
.errorSummary {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
/* Expanded body + Inspect pill wrapper (sibling of .row: clicks never toggle). */
|
||||
.bodyWrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Hover-revealed jump to the trajectory record: a small pill in real flow
|
||||
under the expanded body's bottom-left corner (it reserves its line, so
|
||||
revealing never shifts layout); revealed by hovering anywhere on the tool
|
||||
call — title row included — or by keyboard focus. */
|
||||
.inspectButton {
|
||||
display: inline-flex;
|
||||
align-self: flex-start;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin: 4px 0 2px 4px;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 999px;
|
||||
/* Base background, not bg-overlay: the overlay token is a raised dark
|
||||
surface and reads too heavy for a quiet in-flow affordance. */
|
||||
background: var(--dsw-alias-bg-base);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 100ms ease;
|
||||
}
|
||||
|
||||
.root:hover .inspectButton,
|
||||
.inspectButton:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Solid hover fill (a translucent token would let content bleed through). */
|
||||
.inspectButton:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover-solid);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* Expanded-body scroll wrapper for the run_code CodeBlock; the IN/OUT card
|
||||
and the terminal card scroll INSIDE their own surface instead, so the
|
||||
scrollbar sits within the rounded card. */
|
||||
.bodyScroll {
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Think expanded body: plain indented gray reasoning prose — no IN/OUT card
|
||||
(the reasoning is not an input payload), pre-wrapped at the row's indent.
|
||||
Uncapped: reasoning reads as message prose, so it flows with the page
|
||||
instead of scrolling in a box. */
|
||||
.thinkBody {
|
||||
padding: 4px 0 4px 22px;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
@@ -113,6 +174,78 @@
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* Expanded input/output card (figma 1249:35657): the code-block surface and
|
||||
radius from the TerminalBlock/CodeBlock family. The card itself is a plain
|
||||
column — the padding and the IN/OUT gutter-label grid live on each section
|
||||
so the divider spans the full card width and each section scrolls alone. */
|
||||
.ioCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 4px 0 4px 4px;
|
||||
border: 1px solid var(--dsw-alias-border-l1);
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-alias-markdown-code-block);
|
||||
font: var(--dsw-font-markdown-code-block-small);
|
||||
}
|
||||
|
||||
/* One card section (IN or OUT): the gutter-label grid, capped and scrolling
|
||||
independently so a long input never buries a short output (and vice versa). */
|
||||
.ioSection {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
column-gap: 14px;
|
||||
align-items: baseline;
|
||||
padding: 12px 16px;
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Card-internal scrollbar: a 2px transparent border clips the thumb inward so
|
||||
it floats off the rounded card edge instead of hugging it (the terminal
|
||||
card's own output scroller carries the same treatment in TerminalBlock). */
|
||||
.ioSection::-webkit-scrollbar-thumb {
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* Track end-margins keep the thumb's travel out of the rounded corners. */
|
||||
.ioSection::-webkit-scrollbar-track {
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
/* Caption (not tertiary): one step dimmer than the payload text so the
|
||||
gutter labels read as labels, not as part of the content. Sticky against
|
||||
the section's own scroll so the label stays readable while its payload
|
||||
scrolls underneath (top 0 = the section's padding edge inside the
|
||||
scrollport; start-aligned because sticky needs a block-start anchor). */
|
||||
.ioLabel {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
align-self: start;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
/* l2 hairline between the IN and OUT sections, spanning the full card width
|
||||
(it sits between the padded sections, not inside their grid). */
|
||||
.ioDivider {
|
||||
flex: none;
|
||||
height: 1px;
|
||||
background: var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.ioText {
|
||||
min-width: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* A failed call's OUT text shares the collapsed summary's error color. */
|
||||
.ioText[data-error] {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
/* The two block-shaped expanded bodies: the code variant's run_code program
|
||||
through CodeBlock (shiki-highlighted TypeScript) and a terminal card's
|
||||
command output through TerminalBlock. Both are drawn by the shared
|
||||
@@ -121,15 +254,21 @@
|
||||
flow's row rhythm. */
|
||||
.codeBody,
|
||||
.terminalBody {
|
||||
margin: 4px 0 4px 22px;
|
||||
margin: 4px 0 4px 4px;
|
||||
}
|
||||
|
||||
/* Indented to the body's own column so the description reads as the card's
|
||||
heading rather than as another summary row, and sits tight against the card
|
||||
below it. Its own rule: grouping it with a body would put description
|
||||
typography on a `CodeBlock` wrapper and change that body's spacing. */
|
||||
.terminalDescription {
|
||||
margin: 4px 0 0 22px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font: var(--dsw-font-xs-13);
|
||||
/* In-row code renders at the smaller code size (12/18) via each primitive's
|
||||
rebindable content-font seam; standalone markdown code blocks keep 13/22. */
|
||||
.codeBody {
|
||||
--dsl-code-block-content-font: var(--dsw-font-markdown-code-block-small);
|
||||
}
|
||||
|
||||
/* The terminal card scrolls its OUTPUT inside its own surface (same l1
|
||||
hairline as the IN/OUT card): the banner stays pinned and the scrollbar
|
||||
never rides over it. 224px = the 260px card cap minus the ~36px banner. */
|
||||
.terminalBody {
|
||||
--dsl-terminal-font: var(--dsw-font-markdown-code-block-small);
|
||||
--dsl-terminal-line-height: 18px;
|
||||
--dsl-terminal-output-max-height: 224px;
|
||||
border: 1px solid var(--dsw-alias-border-l1);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,33 @@
|
||||
// ToolRow: the single-line tool summary row (figma component set 122:9479) —
|
||||
// 16px leading slot (state dot / tool icon, chevron on hover or expanded) + title +
|
||||
// separator dot + FILL-truncated summary. The collapsed row is always one
|
||||
// line; the expanded body is indented gray text, the run_code program through
|
||||
// CodeBlock, or — for a call whose render intent is a terminal card — the
|
||||
// command's own output through TerminalBlock, capped at
|
||||
// CHAT_TERMINAL_MAX_LINES so the message flow stays scannable. Expand state is
|
||||
// component-local view state. File-tool summaries are path links that open
|
||||
// through the host; the row itself is not a details-panel control.
|
||||
// separator dot + FILL-truncated summary, drawn through the shared
|
||||
// DisclosureRow chrome with the whole row as the expand toggle (click /
|
||||
// Enter / Space, icon→chevron hover preview). The collapsed row is always
|
||||
// one line; every row with body, output, or terminal material is expandable;
|
||||
// the summary stays inline while open, except Think, whose body opens with
|
||||
// the same first line and would repeat it.
|
||||
// The expanded body — an IN/OUT gutter-labeled card (figma 1249:35657) for
|
||||
// text input/output, the run_code program through CodeBlock, or a terminal
|
||||
// card's command output through TerminalBlock — lives in a max-height scroll
|
||||
// container so a long payload scrolls internally instead of taking over the
|
||||
// message flow; Think's prose is the exception and flows uncapped like
|
||||
// message text. Expand state is component-local view state. File-tool
|
||||
// summaries are path links that open through the host (stopPropagation keeps
|
||||
// the two gestures independent); an error row's collapsed summary is the
|
||||
// failure's first line in the error color.
|
||||
|
||||
import { useState, type MouseEvent, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
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 { 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
|
||||
@@ -23,18 +35,20 @@ export interface ToolRowProps {
|
||||
icon: ReactNode
|
||||
title: string
|
||||
summary: string
|
||||
/** Expanded-body text; null = no text body (`terminal` is the other body source). */
|
||||
/** Expanded-body input text; null = no input section. */
|
||||
body: string | null
|
||||
/** Flattened result text for the expanded Output section; null/absent = no output section. */
|
||||
output?: string | null | undefined
|
||||
/** Error first line shown as the collapsed summary on an error row; null/absent = keep `summary`. */
|
||||
errorSummary?: string | null | undefined
|
||||
/**
|
||||
* Terminal-card material for a call whose render intent is a terminal card
|
||||
* (derived by `terminalCardModel`); it replaces the text body when present.
|
||||
* Null or absent leaves the text body, and a row with neither is not
|
||||
* expandable (its leading slot never toggles).
|
||||
* (derived by `terminalCardModel`); it replaces the text sections when
|
||||
* present. A row with no body, no output, and no terminal material is not
|
||||
* expandable.
|
||||
*/
|
||||
terminal?: TerminalCardModel | null | undefined
|
||||
state: ToolRowState
|
||||
/** Makes the row itself the expand control instead of only its leading icon. */
|
||||
expandOnRowClick?: boolean | undefined
|
||||
/**
|
||||
* Filesystem path from tool args; when set with onOpenFile, the summary
|
||||
* renders as a hover-underline link that opens the host default app.
|
||||
@@ -42,6 +56,21 @@ export interface ToolRowProps {
|
||||
filePath?: string | undefined
|
||||
/** Open the path with the host OS default application (already cwd-resolved). */
|
||||
onOpenFile?: ((path: string) => void) | undefined
|
||||
/**
|
||||
* Jump to this call in the trajectory view: a hover-revealed Inspect pill
|
||||
* over the expanded body. Absent = no affordance (rows without a call
|
||||
* identity, like Think).
|
||||
*/
|
||||
inspect?: (() => void) | undefined
|
||||
}
|
||||
|
||||
/** The Inspect pill's code glyph (user-supplied 16×16), fill follows text color. */
|
||||
function IconInspect() {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden>
|
||||
<path d="M16 8L10.8571 12V10.552L14.1383 8L10.8571 5.448V4L16 8ZM5.14286 10.552L1.86171 8L5.14286 5.448V4L0 8L5.14286 12V10.552ZM9.02514 4L5.59657 12H6.84057L10.2691 4H9.02514Z" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** Leading-slot state substitution: the tool icon yields to the terminal state
|
||||
@@ -56,32 +85,32 @@ function leadingFor(state: ToolRowState, icon: ReactNode): ReactNode {
|
||||
}
|
||||
|
||||
export function ToolRow({
|
||||
t,
|
||||
variant,
|
||||
toolName,
|
||||
icon,
|
||||
title,
|
||||
summary,
|
||||
body,
|
||||
output,
|
||||
errorSummary,
|
||||
terminal,
|
||||
state,
|
||||
expandOnRowClick = false,
|
||||
filePath,
|
||||
onOpenFile,
|
||||
inspect,
|
||||
}: ToolRowProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const terminalBody = terminal ?? null
|
||||
// A row that names a single file keeps one interaction (open that path);
|
||||
// args expand is off whether or not the open callback is wired yet. Terminal
|
||||
// material still expands: only the file variants carry a path, so a terminal
|
||||
// card and a file link never land on the same row.
|
||||
const singleFile = filePath !== undefined
|
||||
const fileLink = singleFile && onOpenFile !== undefined
|
||||
const expandable = (body !== null && !singleFile) || terminalBody !== null
|
||||
// The text arms take the empty string for a null body: a row expandable
|
||||
// only through its terminal material renders the terminal body instead, so
|
||||
// this substitution never shows.
|
||||
const text = body ?? ''
|
||||
const outputText = output ?? null
|
||||
const expandable = body !== null || outputText !== null || terminalBody !== null
|
||||
const open = expanded && expandable
|
||||
// An error row's collapsed summary IS the failure: the first error line in
|
||||
// the error color outranks both the args summary and a terminal description.
|
||||
const failureLine = state === 'error' ? errorSummary ?? null : null
|
||||
const summaryText = failureLine ?? summary
|
||||
// The failure line is error prose, not the path: no open-file affordance.
|
||||
const fileLink = filePath !== undefined && onOpenFile !== undefined && failureLine === null
|
||||
const toggleExpand = () => {
|
||||
setExpanded(v => !v)
|
||||
}
|
||||
@@ -89,20 +118,33 @@ export function ToolRow({
|
||||
event.stopPropagation()
|
||||
if (filePath !== undefined) onOpenFile?.(filePath)
|
||||
}
|
||||
// Think reasoning is prose, not an input payload: expanded, it renders as
|
||||
// plain indented text (no IN/OUT card) and the inline summary — the body's
|
||||
// own first line — yields to avoid repeating itself.
|
||||
const isThink = variant === 'think'
|
||||
// The code variant's program renders through CodeBlock (shiki), so only its
|
||||
// output joins the IN/OUT card; every other variant's input does too.
|
||||
const cardBody = variant === 'code' ? null : body
|
||||
// The state substitution rides the idle icon slot, so an expandable error
|
||||
// row keeps DisclosureRow's icon→chevron hover preview (its default) instead
|
||||
// of losing it with the icon.
|
||||
return (
|
||||
<div className={css.root} data-variant={variant} data-tool={toolName} data-state={state}>
|
||||
<DisclosureRow
|
||||
rowClassName={css.row}
|
||||
leadingClassName={css.leading}
|
||||
titleClassName={css.title}
|
||||
chevronClassName={css.chevron}
|
||||
icon={leadingFor(state, icon)}
|
||||
title={title}
|
||||
open={open}
|
||||
expandable={expandable}
|
||||
expandOnRowClick={expandOnRowClick}
|
||||
previewChevron={expandable && state !== 'error' && state !== 'stopped'}
|
||||
expandOnRowClick
|
||||
keepContentWhenOpen={!isThink}
|
||||
onToggle={toggleExpand}
|
||||
collapsedContent={(
|
||||
collapsedContent={summaryText !== '' && (
|
||||
/* An empty summary drops the separator with it (a row that is only
|
||||
its title shows no trailing dot). */
|
||||
<>
|
||||
<span className={css.sep} aria-hidden />
|
||||
{fileLink ? (
|
||||
@@ -111,24 +153,71 @@ export function ToolRow({
|
||||
className={css.fileLink}
|
||||
onClick={openFile}
|
||||
>
|
||||
{summary}
|
||||
{summaryText}
|
||||
</button>
|
||||
) : (
|
||||
<span className={css.summary}>{summary}</span>
|
||||
<span className={clsx(css.summary, failureLine !== null && css.errorSummary)}>
|
||||
{summaryText}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{/* The terminal presenter's description belongs above the card per
|
||||
the render-intent contract. */}
|
||||
{terminalBody?.description !== undefined && (
|
||||
<div className={css.terminalDescription}>{terminalBody.description}</div>
|
||||
)}
|
||||
{terminalBody !== null
|
||||
? <TerminalBlock {...terminalBody.card} maxLines={CHAT_TERMINAL_MAX_LINES} className={css.terminalBody} />
|
||||
: variant === 'code'
|
||||
? <CodeBlock code={text} lang="typescript" className={css.codeBody} />
|
||||
: <div className={css.body}>{text}</div>}
|
||||
{/* The wrapper (sibling of the header row, so clicks inside never
|
||||
toggle it) carries the expanded body and the Inspect pill below. */}
|
||||
<div className={css.bodyWrap}>
|
||||
{terminalBody !== null
|
||||
? (
|
||||
<TerminalBlock
|
||||
{...terminalBody.card}
|
||||
maxLines={Infinity}
|
||||
labels={terminalBlockLabels(t)}
|
||||
className={css.terminalBody}
|
||||
/>
|
||||
)
|
||||
: isThink
|
||||
? <div className={css.thinkBody}>{body}</div>
|
||||
: (
|
||||
<>
|
||||
{variant === 'code' && body !== null && (
|
||||
<div className={css.bodyScroll}>
|
||||
<CodeBlock code={body} lang="typescript" copyLabel={t('copy')} copiedLabel={t('copied')} className={css.codeBody} />
|
||||
</div>
|
||||
)}
|
||||
{(cardBody !== null || outputText !== null) && (
|
||||
<div className={css.ioCard}>
|
||||
{cardBody !== null && (
|
||||
<div className={css.ioSection}>
|
||||
<span className={css.ioLabel}>IN</span>
|
||||
<span className={css.ioText}>{cardBody}</span>
|
||||
</div>
|
||||
)}
|
||||
{cardBody !== null && outputText !== null && (
|
||||
<span className={css.ioDivider} aria-hidden />
|
||||
)}
|
||||
{outputText !== null && (
|
||||
<div className={css.ioSection}>
|
||||
<span className={css.ioLabel}>OUT</span>
|
||||
<span className={css.ioText} data-error={state === 'error' || undefined}>
|
||||
{outputText}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{inspect !== undefined && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.inspectButton}
|
||||
onClick={inspect}
|
||||
>
|
||||
<IconInspect />
|
||||
Inspect
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</DisclosureRow>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/** 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'
|
||||
import type { ComposerKeyboard, InputActions, InputNotice, InputState } from '../input/contract.ts'
|
||||
import type { ComposerKeyboard, EditSelection, InputActions, InputNotice, InputState } from '../input/contract.ts'
|
||||
import type { createChatStore } from '../stores.ts'
|
||||
import type { CallId, SelectionTarget, ViewTab } from './views.ts'
|
||||
|
||||
@@ -147,13 +147,17 @@ export interface InputZone {
|
||||
}
|
||||
|
||||
/**
|
||||
* View-slot owner share: deliberately empty — ConversationRoot supplies
|
||||
* nothing at its renderSlot site (sessionId and the snapshot hook arrive as
|
||||
* View-slot owner share: the cross-view inspect handoff (otherwise views need
|
||||
* nothing from the render site — sessionId and the snapshot hook arrive as
|
||||
* framework-standard props; tool rows go through each view's own declared
|
||||
* toolview hole). Kept as the named owner seat so a future cross-view
|
||||
* payload has a home.
|
||||
* toolview hole).
|
||||
*/
|
||||
export interface ConvViewOwnerProps {}
|
||||
export interface ConvViewOwnerProps {
|
||||
/** One-shot inspect request from another view (chat's Inspect button); null when idle. */
|
||||
inspect?: { callId: CallId } | null
|
||||
/** Acknowledge the inspect request once applied (clears the store field). */
|
||||
onInspectDone?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner share of a per-view toolview slot: the call material the rendering
|
||||
@@ -176,6 +180,11 @@ export interface ToolRowOwnerProps {
|
||||
* The chat view resolves relative paths against the session cwd.
|
||||
*/
|
||||
openFile: (path: string) => void
|
||||
/**
|
||||
* Jump to this call's record in the trajectory view (the expanded row's
|
||||
* hover Inspect affordance). Undefined when no trajectory jump is wired.
|
||||
*/
|
||||
inspect?: (() => void) | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -265,14 +274,14 @@ export interface ComposerBarOwnerProps {
|
||||
rightItems?: ReactNode
|
||||
/** composer.dock entries (stats line), rendered under the card inside the bar's width column. */
|
||||
footer?: ReactNode
|
||||
onAdd?: () => void
|
||||
addLabel?: string
|
||||
}
|
||||
|
||||
/** Injected share of the composer-bar entry (package-internal faces). */
|
||||
export interface ComposerBarInjected {
|
||||
/** The InputBar-exclusive keyboard/DOM command face (decision 20 private plane); absent with the session. */
|
||||
keyboard: ComposerKeyboard | undefined
|
||||
/** Toggle the shared slash menu with only its command source; absent without ui-slash or a session. */
|
||||
toggleCommandMenu: ((selection: EditSelection) => void) | undefined
|
||||
/** Cancel the in-flight turn; absent with the session. */
|
||||
stop: (() => void) | undefined
|
||||
/**
|
||||
@@ -282,8 +291,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
|
||||
@@ -294,6 +301,8 @@ export interface ComposerBarInjected {
|
||||
notices: ObservableSnapshot<InputNotice | null>
|
||||
/** Hot plain-text reference lexicon for the decoration scan (decision 21). */
|
||||
lexicon: ObservableSnapshot<ReadonlyMap<'/' | '@', readonly string[]>>
|
||||
/** Source name opened by the programmatic menu launcher, or null. */
|
||||
menuLauncher: ObservableSnapshot<string | null>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,11 +315,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 +335,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 +347,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 +413,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 +434,27 @@ export interface ChatViewInjected {
|
||||
*/
|
||||
openFile: (path: string) => void
|
||||
loadOlder: () => void
|
||||
/** Hand a call off to the trajectory view: write the one-shot inspect target and switch tabs. */
|
||||
inspectCall: (callId: CallId) => void
|
||||
/**
|
||||
* Per-session scroll memory surviving view switches (in-memory, never
|
||||
* persisted): the view saves on every scroll and restores on remount; a
|
||||
* fresh page load starts empty and keeps the open-jump-to-bottom default.
|
||||
*/
|
||||
chatScroll: {
|
||||
/** Record the scroll offset; null clears it (pinned to bottom). */
|
||||
save: (top: number | null) => void
|
||||
/** Last recorded offset, or null when pinned or never recorded. */
|
||||
read: () => number | null
|
||||
}
|
||||
/** 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 +465,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 {
|
||||
|
||||
@@ -8,19 +8,34 @@
|
||||
* 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'
|
||||
|
||||
/**
|
||||
* Output lines the chat row's expanded terminal body shows before collapsing
|
||||
* the middle — half the primitive's own default, which the details panel
|
||||
* keeps. A chat row is a summary surface inside the message flow: the flow
|
||||
* must stay scannable across many calls, while the details panel is the
|
||||
* single-call reading surface. A design constant of this UI's row geometry,
|
||||
* not a deployment choice, so it is fixed here rather than a plugin Config
|
||||
* field.
|
||||
* 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 const CHAT_TERMINAL_MAX_LINES = 8
|
||||
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 }),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The {@link TerminalBlock} props this derivation owns. Picked off the
|
||||
@@ -44,6 +59,20 @@ export interface TerminalCardModel {
|
||||
description: string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a settled terminal card reports a failing exit — a non-zero code
|
||||
* or a terminating signal. The bash tool settles a failing command as a
|
||||
* completed call (`isError` stays false: the exit status is result data), so
|
||||
* this is the collapsed row's only failure signal; without it the red exit
|
||||
* pill would be visible only after expanding the card.
|
||||
* @param model - a derived terminal card.
|
||||
* @returns whether the card's exit status is a failure.
|
||||
*/
|
||||
export function terminalFailed(model: TerminalCardModel): boolean {
|
||||
const { exitCode, signal, running } = model.card
|
||||
return running !== true && ((exitCode !== undefined && exitCode !== 0) || signal !== undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a terminal view's working directory the way the render-intent
|
||||
* contract assigns to the UI bridge: an absolute path is used as-is, a relative
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
/**
|
||||
* Pure row-model derivation for tool summary rows: variant classification,
|
||||
* one-line summary and expanded-body text from the frozen call slice. This
|
||||
* derivation reads the call ARGUMENTS only; a call whose render intent is a
|
||||
* terminal card gets its expanded body from the views instead, through
|
||||
* one-line summary, expanded-body text, and flattened result output from the
|
||||
* frozen call slice. Input material comes from the call ARGUMENTS; output and
|
||||
* error material from the settled result node. A call whose render intent is
|
||||
* a terminal card gets its expanded body from the views instead, through
|
||||
* `terminalCardModel` in terminal-card-model.ts.
|
||||
*/
|
||||
// The block union's defining home is runtime (fold-product types); this
|
||||
// contract only forwards it (type-definition authority stays with the layer
|
||||
// that produces the values).
|
||||
import type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ToolCallBlock, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
export type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
@@ -70,11 +71,34 @@ export interface ToolRowModel {
|
||||
* relative values against the session cwd before opening.
|
||||
*/
|
||||
filePath: string | undefined
|
||||
/** Expanded-body text (pretty args); null = row not expandable. */
|
||||
/** Expanded-body input text (pretty args); null = no input section. */
|
||||
body: string | null
|
||||
/** Flattened result text ({@link resultText}); null while running or when the result carries no text. */
|
||||
output: string | null
|
||||
/** First line of the result text on an error row; null for every other state. */
|
||||
errorSummary: string | null
|
||||
state: ToolRowState
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten a settled result's content blocks to display text: text blocks
|
||||
* verbatim, other block shapes as pretty JSON. Empty content on a failed call
|
||||
* falls back to the structured error's `name: code` line.
|
||||
* @param node - the settled result node.
|
||||
* @returns the flattened result text (may be empty).
|
||||
*/
|
||||
export function resultText(node: ToolResultNode): string {
|
||||
const parts: string[] = []
|
||||
for (const block of node.content) {
|
||||
if (block.type === 'text') parts.push(block.text)
|
||||
else parts.push(JSON.stringify(block, null, 2))
|
||||
}
|
||||
if (parts.length === 0 && node.error !== undefined) {
|
||||
parts.push(`${node.error.name}: ${node.error.code}`)
|
||||
}
|
||||
return parts.join('\n')
|
||||
}
|
||||
|
||||
function parseArgs(argsRaw: string): unknown {
|
||||
try {
|
||||
return JSON.parse(argsRaw)
|
||||
@@ -197,12 +221,19 @@ export function toolRowModel(toolName: string, block: ToolCallBlock, cwd?: strin
|
||||
const summary = variant === 'others' && toolName !== '' && toolTitle === undefined
|
||||
? `${toolName} · ${base}`
|
||||
: base
|
||||
// The empty string is "no text" for both derived result fields: a settled
|
||||
// call with blank content has nothing to expand, and a blank first line
|
||||
// would erase the collapsed error row's summary slot.
|
||||
const output = done ? (resultText(block) || null) : null
|
||||
const errorSummary = state === 'error' && output !== null ? firstLine(output) : null
|
||||
return {
|
||||
variant,
|
||||
title: toolTitle ?? VARIANT_TITLES[variant],
|
||||
summary,
|
||||
filePath: deriveFilePath(variant, argsRaw),
|
||||
body: deriveBody(variant, argsRaw),
|
||||
output,
|
||||
errorSummary,
|
||||
state,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,4 +23,10 @@ export interface ChatStoreState {
|
||||
draft: string
|
||||
/** Active conversation view id ('conversation.view' entry id); null falls back to the first view. */
|
||||
view: string | null
|
||||
/**
|
||||
* One-shot inspect handoff: chat writes the call to reveal, the trajectory
|
||||
* view consumes it and acknowledges by clearing. Read with `?? null` —
|
||||
* persisted snapshots from before this field rehydrate without it.
|
||||
*/
|
||||
inspect: { callId: CallId } | null
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -106,6 +106,17 @@ export class InputHub implements InputService {
|
||||
return this.shell(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the optional slash controller for composer chrome that launches
|
||||
* the shared candidate menu without typing a trigger.
|
||||
* @param id - session id.
|
||||
* @returns the resident controller, or undefined when ui-slash is absent.
|
||||
*/
|
||||
slash(id: SessionId): SlashController | undefined {
|
||||
const actx = this.sessions().scope(id)
|
||||
return actx === undefined ? undefined : this.controller(actx)
|
||||
}
|
||||
|
||||
/**
|
||||
* Default sink: optimistic clear + prompt. The session is always a real
|
||||
* host entity (materialized when its workspace was picked), so there is
|
||||
|
||||
170
packages/client/ui-conversation/src/client/locales.ts
Normal file
170
packages/client/ui-conversation/src/client/locales.ts
Normal 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.commands': '命令',
|
||||
'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.commands': 'Commands',
|
||||
'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>
|
||||
@@ -20,6 +20,8 @@
|
||||
padding-top: 2px;
|
||||
border-radius: 14px 14px 0 0;
|
||||
background: var(--dsw-specific-tip);
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
||||
}
|
||||
|
||||
.panel::after {
|
||||
@@ -32,7 +34,52 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.header {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
padding: 4px 16px 4px 12px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.header:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-label-tertiary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.header:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.count {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
font-family: Inter, var(--dsw-font-family);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
display: grid;
|
||||
flex: none;
|
||||
place-items: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.list {
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
||||
@@ -4,13 +4,15 @@
|
||||
// The 'conversation.input.dock' SlotMap declaration lives in
|
||||
// ../contract/slots.ts beside the other input-region slots.
|
||||
import type { Context } from 'cordis'
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { useEffect, useId, useState } from 'react'
|
||||
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
IconCheckOutline16, IconCloseOutline16, IconEditOutline16, IconTrashOutline16,
|
||||
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. */
|
||||
@@ -19,21 +21,31 @@ 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 preview line per queued message; renders null when the queue is empty. */
|
||||
export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) {
|
||||
/**
|
||||
* 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, 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)
|
||||
const [collapsed, setCollapsed] = useState(true)
|
||||
const listId = useId()
|
||||
|
||||
useEffect(() => {
|
||||
if (queue.length === 0 && !collapsed) setCollapsed(true)
|
||||
if (editing !== null && !queue.some(row => row.id === editing.id)) setEditing(null)
|
||||
}, [editing, queue])
|
||||
}, [collapsed, editing, queue])
|
||||
|
||||
if (queue.length === 0) return null
|
||||
|
||||
const interactionActive = editing !== null || busy !== null
|
||||
const expanded = !collapsed || interactionActive
|
||||
const listVisible = queue.length === 1 || expanded
|
||||
|
||||
const applyAction = async (
|
||||
itemId: QueueItemId,
|
||||
action: QueueAction,
|
||||
@@ -56,22 +68,37 @@ 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)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.dock}>
|
||||
<div className={css.panel}>
|
||||
<ul className={css.list}>
|
||||
{queue.map(row => (
|
||||
{queue.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.header}
|
||||
aria-controls={listId}
|
||||
aria-expanded={expanded}
|
||||
disabled={interactionActive}
|
||||
onClick={() => { setCollapsed(value => !value) }}
|
||||
>
|
||||
<span className={css.count}>{t('queue.count', { n: queue.length })}</span>
|
||||
<span className={css.chevron} aria-hidden>
|
||||
{expanded ? <IconChevronDownOutline14 /> : <IconChevronUpOutline14 />}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<ul id={listId} className={css.list} hidden={!listVisible}>
|
||||
{listVisible && queue.map(row => (
|
||||
<li key={row.id} className={css.row}>
|
||||
{editing?.id === row.id
|
||||
? (
|
||||
<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) => {
|
||||
@@ -94,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() }}
|
||||
>
|
||||
@@ -104,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) }}
|
||||
>
|
||||
@@ -118,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 })
|
||||
@@ -130,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'),
|
||||
)
|
||||
}}
|
||||
>
|
||||
@@ -170,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`)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
@@ -35,6 +35,8 @@ export function ConversationSession({
|
||||
const blank = useSession(s => s.blank)
|
||||
const inputState = useInput(s => s)
|
||||
const storedDraft = useStore(s => s.draft)
|
||||
// `?? null`: persisted snapshots from before the inspect field rehydrate without it.
|
||||
const inspect = useStore(s => s.inspect ?? null)
|
||||
|
||||
useEffect(() => {
|
||||
if (inputState.draft === '' && storedDraft !== '') inputActions.setDraft(storedDraft)
|
||||
@@ -52,7 +54,10 @@ export function ConversationSession({
|
||||
|
||||
const view: ReactNode = hideChrome ? null : (
|
||||
<div className={css.viewArea}>
|
||||
{active !== undefined && renderSlot('conversation.view', {}, { only: active.id })}
|
||||
{active !== undefined && renderSlot('conversation.view', {
|
||||
inspect,
|
||||
onInspectDone: () => { actions.setInspect(null) },
|
||||
}, { only: active.id })}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -65,7 +70,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 (
|
||||
|
||||
@@ -12,8 +12,8 @@ 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 { readCardModel } from '../contract/read-card-model.ts'
|
||||
import { terminalCardModel } from '../contract/terminal-card-model.ts'
|
||||
import type { ToolCallBlock } from '../contract/tool-call-model.ts'
|
||||
import { terminalBlockLabels, terminalCardModel } from '../contract/terminal-card-model.ts'
|
||||
import { resultText, type ToolCallBlock } from '../contract/tool-call-model.ts'
|
||||
import css from './DetailsPanel.module.css'
|
||||
|
||||
/** Full props composed by reference from the contract (automatic shares & injected share). */
|
||||
@@ -69,7 +69,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.
|
||||
@@ -85,10 +85,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>
|
||||
@@ -98,24 +98,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>
|
||||
</>
|
||||
)}
|
||||
@@ -134,9 +134,10 @@ export function DetailsPanel({ useSession, useSessions, sessionId, useStore, clo
|
||||
* running call with no 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
|
||||
@@ -146,7 +147,7 @@ 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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -156,24 +157,11 @@ function OutputBody({ material, cwd }: { material: CallMaterial; cwd: string | u
|
||||
if (read !== null) return <ReadBlock {...read} className={css.read} />
|
||||
// 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}>
|
||||
{renderResult(result)}
|
||||
{resultText(result)}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
/** Flatten result content blocks to display text (text blocks verbatim, others as JSON). */
|
||||
function renderResult(node: ToolResultNode): string {
|
||||
const parts: string[] = []
|
||||
for (const block of node.content) {
|
||||
if (block.type === 'text') parts.push(block.text)
|
||||
else parts.push(JSON.stringify(block, null, 2))
|
||||
}
|
||||
if (parts.length === 0 && node.error !== undefined) {
|
||||
parts.push(`${node.error.name}: ${node.error.code}`)
|
||||
}
|
||||
return parts.join('\n')
|
||||
}
|
||||
|
||||
@@ -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's start building
|
||||
{t('hero.headline')}
|
||||
</div>
|
||||
<div className={css.body}>
|
||||
{/* The resident composer (ConversationRoot wrapActiveBody seat; the
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,13 +34,14 @@ export interface InputBarError {
|
||||
export type InputBarProps = ComposerBarProps
|
||||
|
||||
export function InputBar({
|
||||
useSession, useInput, inputActions, keyboard, stop, command, translateHint, renderSlot, useNotices, useLexicon,
|
||||
useSession, useInput, inputActions, keyboard, toggleCommandMenu, stop, command, t,
|
||||
renderSlot, useNotices, useLexicon, useMenuLauncher,
|
||||
useProjection, sessionId, variant, disabled: inert = false, placeholder, accessory, overlay, leftItems, rightItems, footer,
|
||||
onAdd, addLabel = 'Add attachment',
|
||||
}: InputBarProps) {
|
||||
const input = useInput(s => s)
|
||||
const notice = useNotices(s => s)
|
||||
const lexicon = useLexicon(s => s)
|
||||
const commandMenuOpen = useMenuLauncher(source => source === 'command')
|
||||
const promptError = useSession(s => s.promptError) ?? null
|
||||
const running = useSession(s => s.running) ?? false
|
||||
const removed = useSession(s => s.removed) ?? false
|
||||
@@ -256,7 +258,12 @@ export function InputBar({
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
const primaryLabel = running ? 'Stop generating' : 'Send message'
|
||||
const onToggleCommandMenu = (): void => {
|
||||
const el = inputRef.current
|
||||
if (el !== null) toggleCommandMenu?.(selectionOf(el))
|
||||
}
|
||||
|
||||
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 +279,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 +348,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 +385,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,11 +404,13 @@ export function InputBar({
|
||||
<button
|
||||
type="button"
|
||||
className={css.add}
|
||||
aria-label={addLabel}
|
||||
title={addLabel}
|
||||
disabled={locked}
|
||||
aria-label={t('input.commands')}
|
||||
title={t('input.commands')}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={commandMenuOpen}
|
||||
disabled={locked || toggleCommandMenu === undefined}
|
||||
onMouseDown={keepFocus}
|
||||
onClick={onAdd}
|
||||
onClick={onToggleCommandMenu}
|
||||
>
|
||||
<IconPlusOutline16 size={14} />
|
||||
</button>
|
||||
|
||||
@@ -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) }}
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* The plugin creates its handle at apply time so identity follows the fiber.
|
||||
*/
|
||||
import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ChatStoreState, SelectionTarget } from './contract/views.ts'
|
||||
import type { CallId, ChatStoreState, SelectionTarget } from './contract/views.ts'
|
||||
|
||||
/** Declared action shape used to give the exported factory a stable return type. */
|
||||
type ChatActions = {
|
||||
@@ -12,6 +12,7 @@ type ChatActions = {
|
||||
clearDraft: (draft: ChatStoreState) => void
|
||||
restoreDraft: (draft: ChatStoreState, text: string) => void
|
||||
setView: (draft: ChatStoreState, view: string) => void
|
||||
setInspect: (draft: ChatStoreState, target: { callId: CallId } | null) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -20,7 +21,7 @@ type ChatActions = {
|
||||
*/
|
||||
export function createChatStore(): EngineStoreHandle<ChatStoreState, ChatActions> {
|
||||
return defineStore({
|
||||
init: (): ChatStoreState => ({ selection: null, draft: '', view: null }),
|
||||
init: (): ChatStoreState => ({ selection: null, draft: '', view: null, inspect: null }),
|
||||
persist: 'dsh.conversation.chat',
|
||||
actions: {
|
||||
select: (d, target: SelectionTarget | null) => { d.selection = target },
|
||||
@@ -30,6 +31,7 @@ export function createChatStore(): EngineStoreHandle<ChatStoreState, ChatActions
|
||||
// since the clear (send choreography lives in the inject factory).
|
||||
restoreDraft: (d, text: string) => { if (d.draft === '') d.draft = text },
|
||||
setView: (d, view: string) => { d.view = view },
|
||||
setInspect: (d, target: { callId: CallId } | null) => { d.inspect = target },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
// ask_user_question toolview: question-flavored summary row replacing the
|
||||
// generic "Tool call" card, registered into the keyed
|
||||
// 'conversation.chat.toolview' hole like todo-row. The row composes ToolRow
|
||||
// (chrome, running sweep, leading expansion) and swaps in the interaction
|
||||
// (chrome, running sweep, whole-row expand) and swaps in the interaction
|
||||
// outcome — `waiting` while pending, answered-count once settled, `cancelled`
|
||||
// when the user dismissed the whole set — because the questions themselves
|
||||
// render in the composer takeover.
|
||||
|
||||
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,15 @@ 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 })
|
||||
}
|
||||
|
||||
/** One-line question-interaction row (leading toggle expands the raw args). */
|
||||
export function AskQuestionRow({ toolName, block }: ToolRowProps) {
|
||||
/** Full row props: the toolview runtime share plus the standard locale seat. */
|
||||
type AskQuestionRowProps = ToolRowProps & PropsLocale<'conversation'>
|
||||
|
||||
/** One-line question-interaction row (the whole row toggles the call's
|
||||
* Input/Output sections, ToolRow's unified expand). */
|
||||
export function AskQuestionRow({ toolName, block, inspect, 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,25 +56,28 @@ 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}
|
||||
output={model.output}
|
||||
state={state}
|
||||
inspect={inspect}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -87,6 +96,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)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* Bash toolview: same geometry/tokens as ToolRow (figma Bash · description),
|
||||
plus the terminal card the row stacks under its summary line. */
|
||||
plus the expand-gated terminal card under the summary line. */
|
||||
|
||||
/* Summary line over the terminal card; the summary row keeps its own 24px
|
||||
height, so the card is a column around it rather than a change to it. */
|
||||
@@ -8,10 +8,23 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap),
|
||||
and replaces the primitive's standalone vertical margin with the flow's. */
|
||||
/* Expanded terminal card, matching ToolRow's terminalBody: 4px indent, l1
|
||||
hairline, and the max-height scroll on the card's own OUTPUT (banner stays
|
||||
pinned; 224px = the 260px card cap minus the ~36px banner); the margin
|
||||
replaces the primitive's standalone vertical margin with the flow's. */
|
||||
.terminal {
|
||||
margin: 4px 0 4px 22px;
|
||||
--dsl-terminal-font: var(--dsw-font-markdown-code-block-small);
|
||||
--dsl-terminal-line-height: 18px;
|
||||
--dsl-terminal-output-max-height: 224px;
|
||||
margin: 4px 0 4px 4px;
|
||||
border: 1px solid var(--dsw-alias-border-l1);
|
||||
}
|
||||
|
||||
/* ToolRow's unified expand interaction, replicated per the registrant
|
||||
posture: pointer on the expandable row (the icon→chevron hover preview is
|
||||
the affordance, no row fill). */
|
||||
.root[data-expandable] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.root {
|
||||
@@ -47,6 +60,7 @@
|
||||
}
|
||||
|
||||
.leading {
|
||||
position: relative; /* .chevronHover overlay anchor */
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
@@ -57,6 +71,34 @@
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.chevron {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* Hover preview on the expandable row: the idle icon crossfades (100ms) into
|
||||
a down chevron before the row is opened — same overlay as ToolRow. */
|
||||
.iconIdle {
|
||||
display: inline-flex;
|
||||
opacity: 1;
|
||||
transition: opacity 100ms ease;
|
||||
}
|
||||
|
||||
.chevronHover {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
opacity: 0;
|
||||
transition: opacity 100ms ease;
|
||||
}
|
||||
|
||||
.root:hover .iconIdle {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.root:hover .chevronHover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.scopeBadge {
|
||||
flex: none;
|
||||
margin-right: 8px;
|
||||
@@ -95,6 +137,52 @@
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* Error row's collapsed summary: the failure's first line in the error color. */
|
||||
.errorSummary {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
/* Hover-revealed Inspect pill under the expanded terminal's bottom-left —
|
||||
ToolRow's .bodyWrap/.inspectButton treatment, replicated per the registrant
|
||||
posture: real flow (it reserves its line), revealed by hovering anywhere on
|
||||
the tool call — title row included — or by keyboard focus. */
|
||||
.bodyWrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.inspectButton {
|
||||
display: inline-flex;
|
||||
align-self: flex-start;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin: 4px 0 2px 4px;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 999px;
|
||||
/* Base background, not bg-overlay: the overlay token reads too heavy. */
|
||||
background: var(--dsw-alias-bg-base);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 100ms ease;
|
||||
}
|
||||
|
||||
.card:hover .inspectButton,
|
||||
.inspectButton:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Solid hover fill: the pill floats over terminal output, so a translucent
|
||||
hover token would let the text underneath bleed through. */
|
||||
.inspectButton:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover-solid);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
|
||||
.visuallyHidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
|
||||
@@ -4,22 +4,31 @@
|
||||
// Child sessions keep a scoped badge so session-dimension differentiation stays
|
||||
// observable inside the component (no parallel registry).
|
||||
//
|
||||
// A bash call declares the terminal render intent, so this row also renders
|
||||
// the command's own output through TerminalBlock. This row has no expand
|
||||
// control and is not a details-panel target either (tool rows stopped being
|
||||
// one), so its terminal body is resident rather than expand-gated as in
|
||||
// ToolRow, and the card's own copy and expand controls are the row's only
|
||||
// interactions. CHAT_TERMINAL_MAX_LINES is passed as `maxLines` — the chat
|
||||
// flow's tighter cap over the block's own default of 16 — and the block's
|
||||
// internal expander keeps a long output from taking over the message flow.
|
||||
// A bash call declares the terminal render intent, so this row renders the
|
||||
// command's own output through TerminalBlock — expand-gated exactly like
|
||||
// ToolRow's unified interaction: collapsed by default, the whole summary row
|
||||
// is the toggle (click / Enter / Space, icon→chevron hover preview; the
|
||||
// summary stays inline while open),
|
||||
// and the expanded card max-height-scrolls inside its own surface with the
|
||||
// full output (maxLines Infinity — no middle collapse). An error row's
|
||||
// collapsed summary is the failure's first line in the error color.
|
||||
|
||||
import { useState, type KeyboardEvent } from 'react'
|
||||
import type { Context } from 'cordis'
|
||||
import { IconApiOutline14, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
IconApiOutline14, IconChevronDownOutline14, 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 { terminalBlockLabels, terminalCardModel, terminalFailed } 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,48 +39,99 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bash row: icon + Bash · {description} in the shared ToolRow chrome, with the
|
||||
* command's terminal card resident below it. The summary row is not a
|
||||
* details-panel control (tool rows stopped being one), so the card's copy and
|
||||
* expand controls are the row's only interactions.
|
||||
* Bash row: icon + Bash · {description} in the shared ToolRow chrome, the
|
||||
* whole row toggling the command's terminal card (ToolRow's unified
|
||||
* expand interaction, replicated locally per the registrant posture).
|
||||
*/
|
||||
export function BashRow({ toolName, block, sessionId, useSessions }: ToolRowProps) {
|
||||
export function BashRow({ toolName, block, sessionId, useSessions, inspect, 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)
|
||||
// A failing exit status is the terminal card's own error signal (the call
|
||||
// itself settles isError:false), surfaced as the row's red state dot.
|
||||
const state = model.state === 'ok' && terminal !== null && terminalFailed(terminal)
|
||||
? 'error'
|
||||
: model.state
|
||||
const isChild = useSessions(list => list.byId[sessionId]?.parentId !== undefined)
|
||||
const status = stateStatus(model.state)
|
||||
const status = stateStatus(state, t)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const expandable = terminal !== null
|
||||
const open = expanded && expandable
|
||||
const failureLine = model.state === 'error' ? model.errorSummary : null
|
||||
const toggleExpand = () => {
|
||||
setExpanded(v => !v)
|
||||
}
|
||||
const toggleFromKeyboard = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (!expandable || (event.key !== 'Enter' && event.key !== ' ')) return
|
||||
event.preventDefault()
|
||||
toggleExpand()
|
||||
}
|
||||
const leading = open
|
||||
? <IconChevronDownOutline14 className={css.chevron} />
|
||||
: expandable
|
||||
? (
|
||||
<>
|
||||
<span className={css.iconIdle}>{leadingFor(state)}</span>
|
||||
<IconChevronDownOutline14 className={clsx(css.chevron, css.chevronHover)} />
|
||||
</>
|
||||
)
|
||||
: leadingFor(state)
|
||||
return (
|
||||
<div className={css.card}>
|
||||
<div
|
||||
className={css.root}
|
||||
data-sample={isChild ? 'bash-scoped' : 'bash-global'}
|
||||
data-variant="bash"
|
||||
data-state={model.state}
|
||||
data-state={state}
|
||||
data-expandable={expandable || undefined}
|
||||
role={expandable ? 'button' : undefined}
|
||||
tabIndex={expandable ? 0 : undefined}
|
||||
aria-expanded={expandable ? open : undefined}
|
||||
onClick={expandable ? toggleExpand : undefined}
|
||||
onKeyDown={expandable ? toggleFromKeyboard : undefined}
|
||||
>
|
||||
<span className={css.leading}>{leadingFor(model.state)}</span>
|
||||
<span className={css.leading}>{leading}</span>
|
||||
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
|
||||
{isChild && <span className={css.scopeBadge}>scoped</span>}
|
||||
<span className={css.title}>{model.title}</span>
|
||||
<span className={css.sep} aria-hidden />
|
||||
{/* The terminal presenter's description is the contractual
|
||||
above-card summary; it outranks the args-derived one. */}
|
||||
<span className={css.summary}>{terminal?.description ?? model.summary}</span>
|
||||
above-card summary; a failure's first line outranks both. */}
|
||||
<span className={clsx(css.summary, failureLine !== null && css.errorSummary)}>
|
||||
{failureLine ?? terminal?.description ?? model.summary}
|
||||
</span>
|
||||
</div>
|
||||
{terminal !== null && (
|
||||
<TerminalBlock {...terminal.card} maxLines={CHAT_TERMINAL_MAX_LINES} className={css.terminal} />
|
||||
{terminal !== null && open && (
|
||||
/* Same hover-Inspect posture as ToolRow's expanded body, replicated
|
||||
locally per the registrant posture. */
|
||||
<div className={css.bodyWrap}>
|
||||
<TerminalBlock
|
||||
{...terminal.card}
|
||||
maxLines={Infinity}
|
||||
labels={terminalBlockLabels(t)}
|
||||
className={css.terminal}
|
||||
/>
|
||||
{inspect !== undefined && (
|
||||
<button type="button" className={css.inspectButton} onClick={inspect}>
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden>
|
||||
<path d="M16 8L10.8571 12V10.552L14.1383 8L10.8571 5.448V4L16 8ZM5.14286 10.552L1.86171 8L5.14286 5.448V4L0 8L5.14286 12V10.552ZM9.02514 4L5.59657 12H6.84057L10.2691 4H9.02514Z" fill="currentColor" />
|
||||
</svg>
|
||||
Inspect
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@@ -91,6 +151,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)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
// todo_write toolview: plan-flavored summary row replacing the generic
|
||||
// "Tool call" card, registered into the keyed 'conversation.chat.toolview'
|
||||
// hole like the bash sample (a product registration, not a sample). The row
|
||||
// composes ToolRow (chrome, running sweep, leading expansion) and swaps in a
|
||||
// composes ToolRow (chrome, running sweep, whole-row expand) and swaps in a
|
||||
// summary of the written list (counts + active item) from the call args; the
|
||||
// durable list itself renders in the TodoPanel above the composer, so the
|
||||
// row stays one line.
|
||||
// row stays one line until expanded.
|
||||
|
||||
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,30 +37,35 @@ 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
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
/** One-line plan update row (the whole row toggles the call's Input/Output
|
||||
* sections, ToolRow's unified expand). 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, inspect, 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}
|
||||
output={model.output}
|
||||
errorSummary={model.errorSummary}
|
||||
state={model.state}
|
||||
inspect={inspect}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -73,6 +83,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)
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user