From f00a44fd449f221e5548c1b040d9bc9264bafa44 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 6 Aug 2026 06:21:19 -0700 Subject: [PATCH] refactor(web): move the produced-files row into its own plugin package ui-conversation now owns only the conversation.chat.turnTail hole; the row, its derivation, and its copy live in @deepseek-ai/dsh-client-ui-deliverables, composed in or out by one cordis.yml line. --- ...6-07-31-web-workspace-file-links.i18n.yaml | 4 +- .../2026-07-31-web-workspace-file-links.md | 2 +- .../2026-07-31-web-workspace-file-links.zh.md | 2 +- apps/cli/config/web.cordis.yml | 5 + apps/cli/package.json | 1 + docs/config-catalog.md | 1 + knip.json | 10 + .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../ui-conversation/src/client/apply.ts | 1 + .../src/client/chat/AssistantMarkdown.tsx | 17 +- .../src/client/chat/ChatView.tsx | 10 +- .../src/client/chat/Deliverables.tsx | 53 ----- .../src/client/chat/chat-flow.ts | 77 -------- .../src/client/contract/slots.ts | 31 ++- .../ui-conversation/src/client/index.ts | 2 +- .../ui-conversation/src/client/locales.ts | 6 - .../ui-conversation/tests/chat-view.spec.tsx | 93 +-------- .../client/ui-deliverables/README.i18n.yaml | 6 + packages/client/ui-deliverables/README.md | 21 ++ packages/client/ui-deliverables/README.zh.md | 21 ++ packages/client/ui-deliverables/package.json | 65 +++++++ .../src/client/ProducedFiles.module.css} | 0 .../src/client/ProducedFiles.tsx | 61 ++++++ .../ui-deliverables/src/client/index.ts | 42 ++++ .../ui-deliverables/src/client/locales.ts | 21 ++ .../src/client/turn-deliverables.ts | 78 ++++++++ .../ui-deliverables/src/css-modules.d.ts | 6 + packages/client/ui-deliverables/src/index.ts | 9 + .../client/ui-deliverables/src/invariant.ts | 32 +++ .../tests/produced-files.spec.tsx | 183 ++++++++++++++++++ packages/client/ui-deliverables/tsconfig.json | 30 +++ .../client/ui-deliverables/tsdown.config.ts | 3 + pnpm-lock.yaml | 34 ++++ .../verify-package-readme-model-experience.ts | 1 + tsconfig.base.json | 1 + tsconfig.client.json | 1 + 38 files changed, 685 insertions(+), 253 deletions(-) delete mode 100644 packages/client/ui-conversation/src/client/chat/Deliverables.tsx create mode 100644 packages/client/ui-deliverables/README.i18n.yaml create mode 100644 packages/client/ui-deliverables/README.md create mode 100644 packages/client/ui-deliverables/README.zh.md create mode 100644 packages/client/ui-deliverables/package.json rename packages/client/{ui-conversation/src/client/chat/Deliverables.module.css => ui-deliverables/src/client/ProducedFiles.module.css} (100%) create mode 100644 packages/client/ui-deliverables/src/client/ProducedFiles.tsx create mode 100644 packages/client/ui-deliverables/src/client/index.ts create mode 100644 packages/client/ui-deliverables/src/client/locales.ts create mode 100644 packages/client/ui-deliverables/src/client/turn-deliverables.ts create mode 100644 packages/client/ui-deliverables/src/css-modules.d.ts create mode 100644 packages/client/ui-deliverables/src/index.ts create mode 100644 packages/client/ui-deliverables/src/invariant.ts create mode 100644 packages/client/ui-deliverables/tests/produced-files.spec.tsx create mode 100644 packages/client/ui-deliverables/tsconfig.json create mode 100644 packages/client/ui-deliverables/tsdown.config.ts diff --git a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.i18n.yaml index 2b75bc2eff..e38447bf37 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md -2026-07-31-web-workspace-file-links.md: da99426ecb5ca81dcc110bbd4d5c1218390ae4bd -2026-07-31-web-workspace-file-links.zh.md: 91aa94c6fe253c64125eb31fd15973a5aaff1a8f +2026-07-31-web-workspace-file-links.md: 5bac48286c0d9066154a649f418b2f9c2df36539 +2026-07-31-web-workspace-file-links.zh.md: 2b2802ad00bbf4eb84d7ad81bca3b4a0838092c9 diff --git a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md index da99426ecb..5bac48286c 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md +++ b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md @@ -14,7 +14,7 @@ Two distinct defects sat behind that. The transcript never said what a turn had ## Decision -**A finished turn ends with the files it produced.** `turnDeliverables` reads them off the mutation tools' own follow-along `locations` — a diff card, or a generic card whose `kind` is `edit` (the shape `str_replace_editor`'s insert presents) — so a turn's output is listed whether or not the closing message named it, and a new mutation tool joins by declaring what it does rather than by being added to a list. Reads, deletes, and failed calls contribute nothing; a path appears once per turn in first-seen order; accumulation resets on the turn boundary, so a turn that mutates and then ends without content text cannot spill into the next turn's row. The row renders under the closing assistant's body and above its IconActions, keyed to the seq `assistantActionsSeqs` already elects. +**A finished turn ends with the files it produced.** The row is its own plugin, `@deepseek-ai/dsh-client-ui-deliverables`, registered into the `conversation.chat.turnTail` hole the chat view renders between a closing message's body and its IconActions — ui-conversation owns the hole and the owner currency (nodes, closing seq, `openFile`), the plugin owns every policy. `producedForClosing` reads the paths off the mutation tools' own follow-along `locations` — a diff card, or a generic card whose `kind` is `edit` (the shape `str_replace_editor`'s insert presents) — so a turn's output is listed whether or not the closing message named it, and a new mutation tool joins by declaring what it does rather than by being added to a list. Reads, deletes, and failed calls contribute nothing; a path appears once per turn in first-seen order; accumulation resets on the turn boundary, so a turn that mutates and then ends without content text cannot spill into the next turn's row. One cordis.yml line composes the surface in or out; the unregistered hole renders nothing. **The path link reads as a link.** Underlined at rest, not only on hover. This is the smaller half of the diff and the larger half of the fix. diff --git a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md index 91aa94c6fe..2b2802ad00 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md @@ -14,7 +14,7 @@ Status: implemented ## 决定 -**完成的一轮以它产出的文件收尾。** `turnDeliverables` 从改写工具自身的跟随文件 `locations` 中读出它们——diff 卡片,或 `kind` 为 `edit` 的 generic 卡片(即 `str_replace_editor` 的 insert 所呈现的形状)——因此无论收尾消息是否点名,这一轮的产出都会被列出;新的改写工具靠声明自己做了什么加入,而不是靠被加进某张名单。read、删除与失败的调用不贡献任何条目;同一路径在一轮内按首见顺序只出现一次;累积在 turn 边界重置,因此一轮若先改写文件、随后没有正文内容就结束,不会溢进下一轮的行里。该行渲染在收尾 assistant 正文之下、其 IconActions 之上,键控到 `assistantActionsSeqs` 早已选出的那个 seq。 +**完成的一轮以它产出的文件收尾。** 该行是独立插件 `@deepseek-ai/dsh-client-ui-deliverables`,注册进 chat 视图在收尾消息正文与其 IconActions 之间渲染的 `conversation.chat.turnTail` 空位——ui-conversation 拥有空位与 owner 通货(节点、收尾 seq、`openFile`),插件拥有全部策略。`producedForClosing` 从改写工具自身的跟随文件 `locations` 中读出路径——diff 卡片,或 `kind` 为 `edit` 的 generic 卡片(即 `str_replace_editor` 的 insert 所呈现的形状)——因此无论收尾消息是否点名,这一轮的产出都会被列出;新的改写工具靠声明自己做了什么加入,而不是靠被加进某张名单。read、删除与失败的调用不贡献任何条目;同一路径在一轮内按首见顺序只出现一次;累积在 turn 边界重置,因此一轮若先改写文件、随后没有正文内容就结束,不会溢进下一轮的行里。cordis.yml 中的一行即可把该交互面组合进来或去掉;未注册的空位什么也不渲染。 **路径链接读得出是链接。** 静止状态下就带下划线,而不只在悬停时。这是本次改动中更小的那一半,却是修复中更大的那一半。 diff --git a/apps/cli/config/web.cordis.yml b/apps/cli/config/web.cordis.yml index daf597916e..62731fd51e 100644 --- a/apps/cli/config/web.cordis.yml +++ b/apps/cli/config/web.cordis.yml @@ -141,6 +141,11 @@ - id: ui-conversation name: '@deepseek-ai/dsh-client-ui-conversation' + # Turn tail: the produced-files row under each closing assistant message. + # Remove this entry to turn the surface off; the tail hole renders empty. + - id: ui-deliverables + name: '@deepseek-ai/dsh-client-ui-deliverables' + - id: ui-workspace name: '@deepseek-ai/dsh-client-ui-workspace' diff --git a/apps/cli/package.json b/apps/cli/package.json index 4ba1da7e86..58b92bc41d 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -30,6 +30,7 @@ "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-command": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-deliverables": "workspace:^", "@deepseek-ai/dsh-client-ui-goal": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", "@deepseek-ai/dsh-client-ui-model": "workspace:^", diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 89f1529387..710022c11c 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2376,6 +2376,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-runtime` ([`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts)) - `@deepseek-ai/dsh-client-ui-command` ([`packages/client/ui-command/src/index.ts`](../packages/client/ui-command/src/index.ts)) - `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-deliverables` ([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts)) - `@deepseek-ai/dsh-client-ui-goal` ([`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts)) - `@deepseek-ai/dsh-client-ui-layout` ([`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts)) - `@deepseek-ai/dsh-client-ui-model` ([`packages/client/ui-model/src/index.ts`](../packages/client/ui-model/src/index.ts)) diff --git a/knip.json b/knip.json index dfb8058d7c..b16e085723 100644 --- a/knip.json +++ b/knip.json @@ -127,6 +127,16 @@ "tests/**/*.tsx" ] }, + "packages/client/ui-deliverables": { + "entry": [ + "tests/**/*.spec.tsx" + ], + "project": [ + "src/**/*.ts", + "src/**/*.tsx", + "tests/**/*.tsx" + ] + }, "packages/client/web-react": { "entry": [ "tests/**/*.spec.tsx" diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index f1e7c35db7..7c0fd0e42a 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 674cbe7ddf6f2ddc64c337cbebb4ee553e1e3f96 -README.zh.md: d3e8475c7670c57575aa8eeecbf29646c6672c2f +README.md: 8e31a41ad682dfa21d22c93673b17954a784dd4f +README.zh.md: 05b3eeb3185166a1f16596a206ef4111a50e789d diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 674cbe7ddf..8e31a41ad6 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -50,7 +50,7 @@ The chat stats line takes its token accounting from the generic token-meter `tok `src/client/` is organized by domain. `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations and composed props, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` directories import contract files and never each other. `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components and the store factory stay internal and reach the page through apply's slot registrations. -A finished turn ends with the files it produced. `chat-flow.ts`'s `turnDeliverables` reads them off the mutation tools' own follow-along `locations` — a diff card, or a generic card whose `kind` is `edit` (the shape `str_replace_editor`'s insert presents) — so a turn's output is listed whether or not the closing message named it, and a new mutation tool joins by declaring what it does rather than by being added to a list. Reads, deletes, and failed calls contribute nothing; a path appears once per turn in first-seen order; accumulation resets on the turn boundary, so a turn that mutates and then ends without content text cannot spill into the next turn's row. The row renders under the closing assistant's body and above its IconActions, keyed to the same seq `assistantActionsSeqs` elects. It shows six chips (basename, full path as the title) plus an explicit remainder count, and each chip opens through the same `openFile` the tool rows use. +A finished turn ends with a turn-tail hole: the chat view renders the `conversation.chat.turnTail` list slot between the closing assistant's body and its IconActions, once per turn at the seq `assistantActionsSeqs` elects, dispatching `TurnTailOwnerProps` (the snapshot nodes, the closing seq, and the tool rows' `openFile`). This package owns only the hole; the produced-files row that fills it — derivation from the mutation tools' `locations`, the chip cap, the copy — lives in `@deepseek-ai/dsh-client-ui-deliverables`, so composing that plugin out of cordis.yml turns the surface off while the hole renders empty at zero cost. ## Model Experience diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index d3e8475c76..05b3eeb318 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -50,7 +50,7 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu `src/client/` 按领域组织。`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明与组合后的 props、`views.ts` 共享原语、`tool-call-model.ts`);`skeleton/`、`chat/` 和 `toolviews/` 目录只导入 contract 文件,彼此之间从不互相导入。`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply`/`inject`、两个服务类和 `contract/` 类型家族;实现组件与 store factory 保持内部,经 apply 的 slot 注册抵达页面。 -完成的一轮以它产出的文件收尾。`chat-flow.ts` 的 `turnDeliverables` 从改写工具自身的跟随文件 `locations` 中读出它们——diff 卡片,或 `kind` 为 `edit` 的 generic 卡片(即 `str_replace_editor` 的 insert 所呈现的形状)——因此无论收尾消息是否点名,这一轮的产出都会被列出;新的改写工具靠声明自己做了什么加入,而不是靠被加进某张名单。read、删除与失败的调用不贡献任何条目;同一路径在一轮内按首见顺序只出现一次;累积在 turn 边界重置,因此一轮若先改写文件、随后没有正文内容就结束,不会溢进下一轮的行里。该行渲染在收尾 assistant 正文之下、其 IconActions 之上,键控到 `assistantActionsSeqs` 选出的同一个 seq。它展示六枚 chip(文本为文件名,完整路径作为 title),外加一个显式的剩余计数,每枚 chip 都经由工具行所用的同一个 `openFile` 打开。 +完成的一轮以一个 turn-tail 空位收尾:chat 视图在收尾 assistant 正文与其 IconActions 之间渲染 `conversation.chat.turnTail` list slot,每轮一次、位于 `assistantActionsSeqs` 选出的 seq,派发 `TurnTailOwnerProps`(快照节点、收尾 seq,以及工具行的 `openFile`)。本包只拥有空位;填充它的产物行——从改写工具 `locations` 的派生、chip 上限、文案——都在 `@deepseek-ai/dsh-client-ui-deliverables` 里,因此把那个插件从 cordis.yml 中组合掉即可关闭该交互面,空位以零成本渲染为空。 ## 模型体验 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 6bc9068cfc..8eb78139c4 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -303,6 +303,7 @@ export function apply(ctx: Context): void { children: { 'conversation.chat.toolview': { kind: 'keyed', scope: 'session' }, 'conversation.chat.commandview': { kind: 'keyed', scope: 'session' }, + 'conversation.chat.turnTail': { kind: 'list', scope: 'session' }, }, store: chatStore, inject: (sessionId: SessionId, actions: BoundActions): ChatViewInjected => { diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index 77cd3316d8..d6b6504bd0 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -9,13 +9,12 @@ // only when the node is also the completed turn's transcript tail. Think / // tool-head-only nodes stay chrome-free. -import { memo, useMemo } from 'react' +import { memo, useMemo, type ReactNode } 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 { Deliverables } from './Deliverables.tsx' import { MessageIconActions } from './MessageIconActions.tsx' import { ToolRow } from './ToolRow.tsx' import css from './AssistantMarkdown.module.css' @@ -39,11 +38,9 @@ export interface AssistantMarkdownProps { seq?: number | undefined /** Fork the session through this finalized message's completed turn when eligible. */ onFork?: ((seq: number) => void) | undefined - /** Files the closing turn produced, listed under the body; omitted for a - * mid-turn assistant and for a turn that wrote nothing. */ - produced?: readonly string[] | undefined - /** Opens one produced file; omitted wherever `produced` is. */ - openFile?: ((path: string) => void) | undefined + /** Turn-tail content (the chat view's turnTail hole, rendered by the + * owner); omitted for a mid-turn assistant. */ + tail?: ReactNode | undefined /** The message is not the transcript tail of a completed turn. */ forkUnavailable?: boolean | undefined /** The owning view's locale seat, passed down as a plain prop. */ @@ -92,7 +89,7 @@ function ThinkRow({ text, running, t }: { text: string; running: boolean; t: Ass } export const AssistantMarkdown = memo(function AssistantMarkdown({ - blocks, streaming, interrupted, time, runMs, ttftMs, tokensPerSecond, seq, onFork, forkUnavailable, produced, openFile, t, + blocks, streaming, interrupted, time, runMs, ttftMs, tokensPerSecond, seq, onFork, forkUnavailable, tail, t, }: AssistantMarkdownProps) { // Stable per locale revision (t identity changes on switch): a fresh object // per render would rebuild MarkdownText's component table every chunk. @@ -130,9 +127,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ })} {interrupted && {t('message.stopped')}} - {showActions && produced !== undefined && openFile !== undefined && ( - - )} + {showActions && tail} {showActions && ( assistantActionsSeqs(nodes), [nodes]) - // Produced files per closing assistant: derived from the mutation tools' - // locations, so a turn's output is listed whether or not the model named it. - const produced = useMemo(() => turnDeliverables(nodes), [nodes]) const branchSeqs = useMemo(() => messageBranchSeqs(nodes, turnEnds), [nodes, turnEnds]) const runningTurnStart = useMemo(() => runningTurnStartTime(turnTimings), [turnTimings]) const turnMetrics = useMemo(() => deriveTurnMetrics(nodes), [nodes]) @@ -621,8 +618,9 @@ export function ChatView({ seq={node.seq} onFork={forkAt} forkUnavailable={!branchSeqs.has(node.seq)} - produced={produced.get(node.seq)} - openFile={openFile} + tail={actionSeqs.has(node.seq) + ? renderSlot('conversation.chat.turnTail', { nodes, seq: node.seq, openFile }) + : undefined} t={t} /> ) diff --git a/packages/client/ui-conversation/src/client/chat/Deliverables.tsx b/packages/client/ui-conversation/src/client/chat/Deliverables.tsx deleted file mode 100644 index 7d62e23401..0000000000 --- a/packages/client/ui-conversation/src/client/chat/Deliverables.tsx +++ /dev/null @@ -1,53 +0,0 @@ -// Deliverables: the produced-file row a finished turn ends with. The paths come -// from the mutation tools' follow-along locations (see turnDeliverables), never -// from the closing prose, so the answer carries its own output whether or not -// the model remembered to name it. Clicking one goes through the same openFile -// the tool rows use — the Host's own opener, on the Host machine. - -import type { ChatViewSlotProps } from '../contract/slots.ts' -import css from './Deliverables.module.css' - -/** Files past this stay counted but unlisted: a refactor turn must not bury the answer. */ -const SHOWN = 6 - -/** Trailing path segment, the part that identifies the file at a glance. */ -function basename(path: string): string { - const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) - return at === -1 ? path : path.slice(at + 1) -} - -/** - * Render one turn's produced files as openable chips. - * @param props - the turn's paths (tool order, already deduped), the chat - * view's file opener, and the owning view's locale seat. - * @returns The row, or `null` when the turn produced nothing. - */ -export function Deliverables({ paths, openFile, t }: { - paths: readonly string[] - openFile: (path: string) => void - t: ChatViewSlotProps['t'] -}) { - if (paths.length === 0) return null - const shown = paths.slice(0, SHOWN) - const hidden = paths.length - shown.length - return ( -
- {t('produced.label')} - {shown.map(path => ( - - ))} - {hidden > 0 && {t('produced.more', { count: String(hidden) })}} -
- ) -} diff --git a/packages/client/ui-conversation/src/client/chat/chat-flow.ts b/packages/client/ui-conversation/src/client/chat/chat-flow.ts index 7d5e5100f6..57d2ac1bb0 100644 --- a/packages/client/ui-conversation/src/client/chat/chat-flow.ts +++ b/packages/client/ui-conversation/src/client/chat/chat-flow.ts @@ -32,21 +32,6 @@ function rendersNothing(node: ConversationNode): boolean { || ((b.kind === 'text' || b.kind === 'reasoning') && b.text.trim() === '')) } -/** - * Paths a call view reports having created or changed, by render intent rather - * than tool name: a diff card, or a generic card whose kind is `edit` (the - * shape `str_replace_editor`'s insert presents). Every other card produces - * nothing to open — a read looked, a delete removed, a terminal ran. - */ -function producedPaths(view: ToolResultNode['callView']): readonly string[] { - if (view === null) return [] - if (view.card === 'diff') return (view.locations ?? []).map(location => location.path) - if (view.card === 'generic' && view.kind === 'edit') { - return (view.locations ?? []).map(location => location.path) - } - return [] -} - /** * Seq set of assistants that own IconActions: the last content-text assistant * in each turn. Mid-turn narration (text before tools) stays chrome-free. @@ -62,68 +47,6 @@ export function assistantActionsSeqs(nodes: readonly ConversationNode[]): Readon return new Set(lastByTurn.values()) } -/** - * Files each turn produced, keyed by the assistant seq that closes it — the - * same anchor {@link assistantActionsSeqs} elects, so the row lands under the - * message that reports the work rather than after some mid-turn narration. - * - * The source is the mutation tools' own follow-along `locations`, not the - * closing prose: a produced file must be listed whether or not the model - * remembered to name it. A mutation is recognized by render intent, not by - * tool name — a diff card, or a generic card whose `kind` is `edit` (the shape - * `str_replace_editor`'s insert presents) — so a new mutation tool joins by - * declaring what it does. Reads contribute nothing (looking at a file does not - * produce it), and neither do deletes (there is nothing left to open) or - * failed calls. Paths keep first-seen order and appear once, so a file written - * and then edited in the same turn is one entry. - * - * Accumulation resets on the turn boundary, not merely at the closing - * assistant: a turn that mutates files and then ends without content text - * (interrupted mid-tool, or a turn whose last text precedes its last tool - * result) must not spill its paths into the next turn's row, nor leave `seen` - * suppressing a file the next turn legitimately rewrites. - * @param nodes - snapshot nodes (surface order). - * @returns Per-closing-seq produced paths; a turn that produced none is absent. - */ -export function turnDeliverables(nodes: readonly ConversationNode[]): ReadonlyMap { - const closing = assistantActionsSeqs(nodes) - const byClosingSeq = new Map() - let pending: string[] = [] - let seen = new Set() - let turn: number | undefined - for (const node of nodes) { - if (node.kind === 'tool-result') { - if (node.isError) continue - for (const path of producedPaths(node.callView)) { - if (seen.has(path)) continue - seen.add(path) - pending.push(path) - } - continue - } - // Tool results carry no turn of their own, so the boundary is read off the - // nodes that do. A user message opens a turn without reporting a number, - // which is why the tracked turn goes back to undefined there: the next - // node to report one is stating the current turn, not entering a new one. - if (node.kind === 'user') { - turn = undefined - pending = [] - seen = new Set() - } else if ('turn' in node) { - if (turn !== undefined && node.turn !== turn) { - pending = [] - seen = new Set() - } - turn = node.turn - } - if (node.kind !== 'assistant' || !closing.has(node.seq)) continue - if (pending.length > 0) byClosingSeq.set(node.seq, pending) - pending = [] - seen = new Set() - } - return byClosingSeq -} - /** * Exact start time of the latest in-window turn without a matching end time. * @param turnTimings - In-window turn timings in event order. diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index a84b4a3bf0..1246433a33 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -3,7 +3,7 @@ import type { ReactNode, RefObject } from 'react' import type { 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 { CommandNode, ConversationNode, 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, EditSelection, InputActions, InputNotice, InputState } from '../input/contract.ts' import type { createChatStore } from '../stores.ts' @@ -46,6 +46,15 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * registration, and a domain upgrades by registering one row component. */ 'conversation.chat.commandview': { kind: 'keyed'; scope: 'session'; owner: CommandRowOwnerProps } + /** + * The chat view's turn-tail hole: rendered between a closing assistant + * message's body and its IconActions footer, once per turn (the render + * site elects the closing seq). Declared by the chat view entry; feature + * plugins (ui-deliverables' produced-files row) derive what they show + * from the owner currency, and an unregistered hole renders nothing — + * composing such a plugin out of cordis.yml turns its surface off. + */ + 'conversation.chat.turnTail': { kind: 'list'; scope: 'session'; owner: TurnTailOwnerProps } /** * The composer takeover chain: entries are selector-routed replacements * of the default InputBar. Declared by this package's 'conversation' @@ -150,6 +159,24 @@ export interface ConvViewOwnerProps { onInspectDone?: () => void } +/** + * Owner currency of the chat view's turn-tail hole: the finalized snapshot + * and the closing assistant's anchor. Registrants derive their own facts + * from the nodes (the owner never pre-chews a feature's vocabulary), and + * open files through the same opener the tool rows use. + */ +export interface TurnTailOwnerProps { + /** Finalized snapshot nodes in surface order. */ + nodes: readonly ConversationNode[] + /** The closing assistant's seq — the anchor the tail renders under. */ + seq: number + /** + * Open a filesystem path through the Host (tool-row semantics; the chat + * view resolves relative paths against the session cwd). + */ + openFile: (path: string) => void +} + /** * Owner share of a per-view toolview slot: the call material the rendering * view supplies per row. Uniform across views — the trajectory/waterfall @@ -480,7 +507,7 @@ export interface ChatViewInjected { /** 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'> + PropsRuntime<'conversation.view'> & PropsRenderSlots<'conversation.chat.toolview' | 'conversation.chat.commandview' | 'conversation.chat.turnTail'> & PropsStore & ChatViewInjected & PropsLocale<'conversation'> /** diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index ac5f6574c8..725868d57a 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -17,7 +17,7 @@ export type { ComposerChainProps, ConversationInjected, ConversationSessionHeaderInjected, ConversationSessionInjected, ConversationSlotProps, ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps, - EmptyWorkspaceOwnerProps, ToolRowOwnerProps, ToolRowProps, + EmptyWorkspaceOwnerProps, ToolRowOwnerProps, ToolRowProps, TurnTailOwnerProps, } from './contract/slots.ts' // Export discipline: packages/client/AGENTS.md. diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index b489bb29a1..9ba5ed3876 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -85,9 +85,6 @@ export const zh = { 'message.unknownSurface': '未知 surface 事件:{type}', 'message.unknownBlock': '未知内容块', 'message.stopped': '已停止', - 'produced.label': '产物', - 'produced.more': '还有 {count} 个', - 'produced.open': '打开 {name}', 'message.branch': '在新对话中分支', 'message.branchUnavailable': '仅可从已完成轮次的最后一条消息分支', 'message.retry.active': '正在重试模型请求', @@ -228,9 +225,6 @@ export const en = { 'message.unknownSurface': 'Unknown surface event: {type}', 'message.unknownBlock': 'Unknown content block', 'message.stopped': 'Stopped', - 'produced.label': 'Produced', - 'produced.more': '{count} more', - 'produced.open': 'Open {name}', 'message.branch': 'Branch into a new conversation', 'message.branchUnavailable': 'Available only on the last message of a completed turn', 'message.retry.active': 'Retrying model request', diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index dc3e6f5241..4da138d1b5 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -20,7 +20,7 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts import { createChatStore } from '../src/client/stores.ts' import { ChatView } from '../src/client/chat/ChatView.tsx' import { zh } from '../src/client/locales.ts' -import { assistantActionsSeqs, deriveChatFlow, flowKeys, messageBranchSeqs, runningTurnStartTime, turnDeliverables } from '../src/client/chat/chat-flow.ts' +import { assistantActionsSeqs, deriveChatFlow, flowKeys, messageBranchSeqs, runningTurnStartTime } from '../src/client/chat/chat-flow.ts' import { formatRunDuration } from '../src/client/chat/message-chrome.ts' afterEach(() => { @@ -242,93 +242,6 @@ describe('chat-flow derivation', () => { expect([...seqs].sort((a, b) => a - b)).toEqual([5, 7]) }) - it('turnDeliverables attributes each turn’s written files to the assistant that closes it', () => { - const wrote = (seq: number, callId: string, ...paths: string[]): ToolResultNode => ({ - ...toolResult(seq, callId, 'write'), - callView: { - card: 'diff', title: `Write ${paths[0] ?? ''}`, - diffs: paths.map(path => ({ path, oldText: null, newText: 'x' })), - locations: paths.map(path => ({ path })), - }, - }) - const produced = turnDeliverables([ - user(1, 'build it'), - assistant(2, 'writing', 1), - wrote(3, 'a', 'out/index.html'), - // Same file touched twice in one turn is one deliverable, in first-seen order. - wrote(4, 'b', 'out/app.css', 'out/index.html'), - // A read is not a deliverable; a failed write has no file to open. - { ...toolResult(5, 'c', 'read'), callView: { card: 'generic', title: 'Read x', locations: [{ path: 'x.ts' }] } }, - { ...wrote(6, 'd', 'out/broken.html'), isError: true }, - assistant(7, 'done', 1), - user(8, 'again'), - assistant(9, 'second turn', 2), - ]) - expect(produced.get(7)).toEqual(['out/index.html', 'out/app.css']) - // A turn that produced nothing is absent, not an empty row. - expect(produced.has(9)).toBe(false) - // Nothing at all written: no entries. - expect(turnDeliverables([user(1, 'hi'), assistant(2, 'hello', 1)]).size).toBe(0) - }) - - it('turnDeliverables counts a generic edit and never spills across the turn boundary', () => { - const inserted = (seq: number, callId: string, path: string): ToolResultNode => ({ - ...toolResult(seq, callId, 'str_replace_editor'), - // str_replace_editor's insert mutates behind a generic card, so the - // discriminant is the render intent, not the card shape alone. - callView: { card: 'generic', title: `insert ${path}`, kind: 'edit', locations: [{ path }] }, - }) - const wrote = (seq: number, callId: string, path: string): ToolResultNode => ({ - ...toolResult(seq, callId, 'write'), - callView: { - card: 'diff', title: 'Write', diffs: [{ path, oldText: null, newText: 'x' }], locations: [{ path }], - }, - }) - const produced = turnDeliverables([ - user(1, 'insert a line'), - inserted(2, 'i', 'notes.md'), - assistant(3, 'inserted', 1), - // Turn 2 mutates and then ends with no content text (interrupted, or its - // last text preceded the tool): its paths must not ride into turn 3. - user(4, 'now rewrite it'), - wrote(5, 'w', 'leaked.txt'), - user(6, 'and again'), - wrote(7, 'w2', 'notes.md'), - assistant(8, 'done', 3), - ]) - expect(produced.get(3)).toEqual(['notes.md']) - // Turn 3 lists only its own file — and `seen` did not suppress the rewrite - // of a path an earlier turn already touched. - expect(produced.get(8)).toEqual(['notes.md']) - expect([...produced.values()].flat()).not.toContain('leaked.txt') - }) - - it('renders the produced files under the closing message and opens one on click', () => { - const wrote = (seq: number, callId: string, ...paths: string[]): ToolResultNode => ({ - ...toolResult(seq, callId, 'write'), - callView: { - card: 'diff', title: 'Write', - diffs: paths.map(path => ({ path, oldText: null, newText: 'x' })), - locations: paths.map(path => ({ path })), - }, - }) - // Seven files: six chips plus an explicit remainder — the row bounds what - // it shows and says so rather than dropping the rest silently. - const paths = ['deep/a.html', 'b.css', 'c.ts', 'd.ts', 'e.ts', 'f.ts', 'g.ts'] - const h = makeHarness({ - nodes: [user(1, 'build it'), wrote(2, 'w', ...paths), assistant(3, 'done', 1)], - }) - const view = render() - expect(view.getByText('产物')).toBeTruthy() - // Chips carry the basename; the full path stays reachable as the title. - const chip = view.getByRole('button', { name: '打开 deep/a.html' }) - expect(chip.textContent).toBe('a.html') - expect(view.queryByRole('button', { name: '打开 g.ts' })).toBeNull() - expect(view.getByText('还有 1 个')).toBeTruthy() - fireEvent.click(chip) - expect(h.openFile).toHaveBeenCalledWith('deep/a.html') - }) - it('runningTurnStartTime selects the latest turn/start without a turn/end', () => { expect(runningTurnStartTime(new Map([ [1, { startTime: 1_000, endTime: 5_000 }], @@ -790,7 +703,9 @@ describe('ChatView', () => { // Count renderSlot invocations: the memo boundary holds when CallRow does // not re-render, so the row's renderSlot call count freezes during chunks. let rowRenders = 0 - h.props.renderSlot = ((_key: string, _owner: object) => { + h.props.renderSlot = ((key: string, _owner: object) => { + // The turnTail hole renders through the same share; only tool rows count here. + if (key !== 'conversation.chat.toolview') return null rowRenders += 1 return
}) diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml new file mode 100644 index 0000000000..ee4c23c18c --- /dev/null +++ b/packages/client/ui-deliverables/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/client/ui-deliverables/README.md +README.md: b8b0ea2ef1cbc9b18b905fc08b41278f403ef043 +README.zh.md: a16535b8a8d3625ca1cf90e88c6d9dca742d916b diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md new file mode 100644 index 0000000000..b8b0ea2ef1 --- /dev/null +++ b/packages/client/ui-deliverables/README.md @@ -0,0 +1,21 @@ +# @deepseek-ai/dsh-client-ui-deliverables + +English | [中文](README.zh.md) + +Produced-files feature owner: registers the deliverables row a finished turn ends with into the chat view's `conversation.chat.turnTail` hole. All policy lives here; removing this plugin's line from cordis.yml removes the surface entirely, and the owning view renders an empty hole at zero cost. + +`producedForClosing` derives one turn's produced files from the tail hole's owner currency — the finalized snapshot nodes and the closing assistant's seq. The vocabulary is the mutation tools' own follow-along `locations`, never the closing prose: a produced file is listed whether or not the model remembered to name it. A mutation is recognized by render intent, not tool name — a diff card, or a generic card whose `kind` is `edit` (the shape `str_replace_editor`'s insert presents) — so a new mutation tool joins by declaring what it does. Reads, deletes, and failed calls contribute nothing; a path appears once per turn in first-seen order; accumulation resets on the turn boundary, so a turn that mutates and then ends without content text cannot spill into the next turn's row. + +`ProducedFiles` renders the row between the closing message's body and its IconActions footer: a quiet label, up to six chips (basename text, full path as the `title`), and an explicit remainder count past the cap. Each chip opens through the owner-supplied `openFile` — the same Host opener the tool rows use, with the chat view resolving relative paths against the session cwd. Design rationale: the [workspace file links Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md). + +## Model Experience + +None, as the row is a pure client derivation over already-logged tool metadata and nothing here reaches a model request. + +#### KV Cache effect + +None; this package neither assembles nor sends provider requests. + +## Known Limitations and Deferred Work + +- **Prose mentions stay inert.** An inline-code file name in the closing message does not open the file yet; linking it to the same `locations` vocabulary is the stacked follow-up. diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md new file mode 100644 index 0000000000..a16535b8a8 --- /dev/null +++ b/packages/client/ui-deliverables/README.zh.md @@ -0,0 +1,21 @@ +# @deepseek-ai/dsh-client-ui-deliverables + +[English](README.md) | 中文 + +产物文件的功能属主:把"完成的一轮以其产出文件收尾"的产物行注册进 chat 视图的 `conversation.chat.turnTail` 空位。全部策略都在本包内;从 cordis.yml 中删去本插件那一行即可整体移除该交互面,属主视图以零成本渲染一个空的空位。 + +`producedForClosing` 从 tail 空位的 owner 通货——定稿的快照节点与收尾 assistant 的 seq——推导一轮产出的文件。词表是改写工具自身的跟随 `locations`,绝不是收尾正文:无论模型是否记得点名,产出文件都会被列出。改写按渲染意图识别而非工具名——diff 卡片,或 `kind` 为 `edit` 的 generic 卡片(即 `str_replace_editor` 的 insert 所呈现的形状)——因此新的改写工具靠声明自己做了什么加入。read、删除与失败的调用不贡献任何条目;同一路径在一轮内按首见顺序只出现一次;累积在 turn 边界重置,因此一轮若先改写文件、随后没有正文内容就结束,不会溢进下一轮的行里。 + +`ProducedFiles` 在收尾消息正文与其 IconActions 之间渲染该行:一个安静的标签、至多六枚 chip(文本为文件名,完整路径作为 `title`),超出上限则显示一个明确的剩余计数。每枚 chip 经由 owner 提供的 `openFile` 打开——与工具行相同的 Host 打开器,chat 视图会把相对路径按会话 cwd 解析。设计原理:[workspace 文件链接 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md)。 + +## 模型体验 + +无。该行是对已记录工具元数据的纯客户端派生,这里没有任何内容进入模型请求。 + +#### KV Cache 影响 + +无;该包既不组装也不发送提供方请求。 + +## 已知限制与暂缓事项 + +- **正文提及仍是死文本。**收尾消息里以行内代码写出的文件名尚不能点击打开;把它接到同一份 `locations` 词表是 stacked 的后续工作。 diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json new file mode 100644 index 0000000000..0b5318499b --- /dev/null +++ b/packages/client/ui-deliverables/package.json @@ -0,0 +1,65 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-deliverables", + "description": "Produced-files turn tail: the deliverables row a finished turn ends with", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dshClient": { + "inject": [ + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-conversation" + ], + "platform": "web" + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "BSD-3-Clause", + "dependencies": { + "react": "^18.2.0" + }, + "peerDependencies": { + "@deepseek-ai/dsh-client-locale": "^0.0.1", + "@deepseek-ai/dsh-client-runtime": "^0.0.1", + "@deepseek-ai/dsh-client-ui-conversation": "^0.0.1", + "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@types/react": "~18.3.1", + "cordis": "^4.0.0-rc.7" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ] +} diff --git a/packages/client/ui-conversation/src/client/chat/Deliverables.module.css b/packages/client/ui-deliverables/src/client/ProducedFiles.module.css similarity index 100% rename from packages/client/ui-conversation/src/client/chat/Deliverables.module.css rename to packages/client/ui-deliverables/src/client/ProducedFiles.module.css diff --git a/packages/client/ui-deliverables/src/client/ProducedFiles.tsx b/packages/client/ui-deliverables/src/client/ProducedFiles.tsx new file mode 100644 index 0000000000..609a688586 --- /dev/null +++ b/packages/client/ui-deliverables/src/client/ProducedFiles.tsx @@ -0,0 +1,61 @@ +// ProducedFiles: the produced-file row a finished turn ends with. The paths +// come from the mutation tools' follow-along locations (see +// producedForClosing), never from the closing prose, so the answer carries +// its own output whether or not the model remembered to name it. Clicking one +// goes through the same openFile the tool rows use — the Host's own opener, +// on the Host machine. + +import { useMemo } from 'react' +import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { producedForClosing } from './turn-deliverables.ts' +import type { NS } from './locales.ts' +import css from './ProducedFiles.module.css' + +/** Files past this stay counted but unlisted: a refactor turn must not bury the answer. */ +const SHOWN = 6 + +/** Trailing path segment, the part that identifies the file at a glance. */ +function basename(path: string): string { + const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + return at === -1 ? path : path.slice(at + 1) +} + +/** Full props: the turn-tail owner currency plus this plugin's locale seat. */ +export type ProducedFilesProps = TurnTailOwnerProps & PropsLocale + +/** + * Render one turn's produced files as openable chips. + * @param props - the tail hole's owner currency (snapshot nodes, the closing + * assistant's seq, the chat view's file opener) and the locale seat. + * @returns The row, or `null` when the turn produced nothing. + */ +export function ProducedFiles({ nodes, seq, openFile, t }: ProducedFilesProps) { + // Per-closing-message derivation over the windowed snapshot: O(nodes) on + // node-identity change only, which is the same cadence the owning view + // re-derives its own flow at. + const paths = useMemo(() => producedForClosing(nodes, seq), [nodes, seq]) + if (paths.length === 0) return null + const shown = paths.slice(0, SHOWN) + const hidden = paths.length - shown.length + return ( +
+ {t('produced.label')} + {shown.map(path => ( + + ))} + {hidden > 0 && {t('produced.more', { count: String(hidden) })}} +
+ ) +} diff --git a/packages/client/ui-deliverables/src/client/index.ts b/packages/client/ui-deliverables/src/client/index.ts new file mode 100644 index 0000000000..536c019b01 --- /dev/null +++ b/packages/client/ui-deliverables/src/client/index.ts @@ -0,0 +1,42 @@ +/** + * Deliverables plugin, browser half: registers the produced-files row into + * the chat view's turn-tail hole. All policy lives here — the derivation + * from the mutation tools' `locations`, the chip cap, and the copy — so + * composing this plugin out of cordis.yml removes the surface entirely; the + * owning view renders an empty hole at zero cost. + */ +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type {} from '@deepseek-ai/dsh-client-locale/client' +import { ProducedFiles } from './ProducedFiles.tsx' +import { en, NS, zh, type DeliverablesKey } from './locales.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Produced-files row copy. */ + 'deliverables': DeliverablesKey + } +} + +export { ProducedFiles, type ProducedFilesProps } from './ProducedFiles.tsx' +export { producedForClosing } from './turn-deliverables.ts' + +/** Required services for the tail-slot registration and its dictionaries. */ +export const inject = ['slots', 'locale'] + +/** + * Client plugin body: register the dictionaries and the turn-tail entry. + * @param ctx - client root context. + */ +export function apply(ctx: ClientContext): void { + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-deliverables: dictionaries') + ctx.slots.inject( + 'conversation.chat.turnTail', + () => ctx.slots.register({ + name: 'conversation.chat.turnTail', + id: 'produced-files', + order: 0, + locale: NS, + }, ProducedFiles), + ) +} diff --git a/packages/client/ui-deliverables/src/client/locales.ts b/packages/client/ui-deliverables/src/client/locales.ts new file mode 100644 index 0000000000..aa51aa75a7 --- /dev/null +++ b/packages/client/ui-deliverables/src/client/locales.ts @@ -0,0 +1,21 @@ +/** `deliverables` namespace dictionaries. */ + +/** Dictionary namespace owned by this plugin. */ +export const NS = 'deliverables' + +/** Simplified Chinese dictionary (the key-set source of truth). */ +export const zh = { + 'produced.label': '产物', + 'produced.more': '还有 {count} 个', + 'produced.open': '打开 {name}', +} + +/** English dictionary (same key set). */ +export const en: Record = { + 'produced.label': 'Produced', + 'produced.more': '{count} more', + 'produced.open': 'Open {name}', +} + +/** Union of this namespace's dictionary keys. */ +export type DeliverablesKey = keyof typeof zh diff --git a/packages/client/ui-deliverables/src/client/turn-deliverables.ts b/packages/client/ui-deliverables/src/client/turn-deliverables.ts new file mode 100644 index 0000000000..faa0455b37 --- /dev/null +++ b/packages/client/ui-deliverables/src/client/turn-deliverables.ts @@ -0,0 +1,78 @@ +/** + * Pure derivation of one turn's produced files from finalized snapshot + * nodes. Client-only and model-free: the vocabulary is the mutation tools' + * own follow-along `locations`, never the closing prose. + */ +import type { ConversationNode, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' + +/** + * Paths a call view reports having created or changed, by render intent rather + * than tool name: a diff card, or a generic card whose kind is `edit` (the + * shape `str_replace_editor`'s insert presents). Every other card produces + * nothing to open — a read looked, a delete removed, a terminal ran. + */ +function producedPaths(view: ToolResultNode['callView']): readonly string[] { + if (view === null) return [] + if (view.card === 'diff') return (view.locations ?? []).map(location => location.path) + if (view.card === 'generic' && view.kind === 'edit') { + return (view.locations ?? []).map(location => location.path) + } + return [] +} + +/** + * Files produced by the turn the assistant at `seq` closes — the anchor the + * render site elects, so the row lands under the message that reports the + * work rather than after some mid-turn narration. + * + * The source is the mutation tools' own follow-along `locations`, not the + * closing prose: a produced file must be listed whether or not the model + * remembered to name it. A mutation is recognized by render intent, not by + * tool name — a diff card, or a generic card whose `kind` is `edit` (the shape + * `str_replace_editor`'s insert presents) — so a new mutation tool joins by + * declaring what it does. Reads contribute nothing (looking at a file does not + * produce it), and neither do deletes (there is nothing left to open) or + * failed calls. Paths keep first-seen order and appear once, so a file written + * and then edited in the same turn is one entry. + * + * Accumulation resets on the turn boundary — a user message, or a node + * reporting a different turn number — so a turn that mutates files and then + * ends without content text cannot spill its paths into the next turn's row, + * nor leave the dedup set suppressing a file the next turn legitimately + * rewrites. Tool results carry no turn of their own; the boundary is read off + * the nodes that do, and a user message resets the tracked turn to undefined + * because the next node to report one is stating the current turn, not + * entering a new one. + * @param nodes - snapshot nodes (surface order). + * @param seq - the closing assistant's seq (the render site's anchor). + * @returns Produced paths in first-seen order; empty when the turn wrote nothing. + */ +export function producedForClosing(nodes: readonly ConversationNode[], seq: number): readonly string[] { + let pending: string[] = [] + let seen = new Set() + let turn: number | undefined + for (const node of nodes) { + if (node.kind === 'tool-result') { + if (node.isError) continue + for (const path of producedPaths(node.callView)) { + if (seen.has(path)) continue + seen.add(path) + pending.push(path) + } + continue + } + if (node.kind === 'user') { + turn = undefined + pending = [] + seen = new Set() + } else if ('turn' in node) { + if (turn !== undefined && node.turn !== turn) { + pending = [] + seen = new Set() + } + turn = node.turn + } + if (node.kind === 'assistant' && node.seq === seq) return pending + } + return [] +} diff --git a/packages/client/ui-deliverables/src/css-modules.d.ts b/packages/client/ui-deliverables/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/client/ui-deliverables/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} + +declare module '*.css' diff --git a/packages/client/ui-deliverables/src/index.ts b/packages/client/ui-deliverables/src/index.ts new file mode 100644 index 0000000000..012876cc2d --- /dev/null +++ b/packages/client/ui-deliverables/src/index.ts @@ -0,0 +1,9 @@ +/** + * Deliverables plugin, node half. Pure UI plugin: the empty apply exists so + * the plugin appears in the host cordis.yml / Loader; the browser half ships + * via exports["./client"], discovered through the package.json dshClient + * declaration. + */ + +/** Host plugin body — no host-side behavior for this surface plugin. */ +export function apply(): void {} diff --git a/packages/client/ui-deliverables/src/invariant.ts b/packages/client/ui-deliverables/src/invariant.ts new file mode 100644 index 0000000000..39c39591cf --- /dev/null +++ b/packages/client/ui-deliverables/src/invariant.ts @@ -0,0 +1,32 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-deliverables`. + * @module @deepseek-ai/dsh-client-ui-deliverables/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-deliverables' + +/** Cordis companion plugin name. */ +export const name = 'client-ui-deliverables-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: one slot registration and one dictionary + * registration, both effect-owned with disposal proven by the HMR-safety + * spec — the plugin emits no cordis events and owns no cross-plugin mutable + * state. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/client/ui-deliverables/tests/produced-files.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.spec.tsx new file mode 100644 index 0000000000..e5d92424a3 --- /dev/null +++ b/packages/client/ui-deliverables/tests/produced-files.spec.tsx @@ -0,0 +1,183 @@ +// @vitest-environment jsdom +/** + * ui-deliverables browser half: the derivation contract of + * `producedForClosing` over finalized snapshot nodes, the row's rendering + * and opener wiring, and the plugin registrations' fiber-teardown removal + * (HMR safety) against the real SlotsService. + */ +import { Context } from 'cordis' +import { cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import type { + AssistantMessageNode, ConversationNode, ToolResultNode, UserMessageNode, +} from '@deepseek-ai/dsh-client-runtime/client' +import { apply as applyLocale } from '@deepseek-ai/dsh-client-locale/client' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { ProducedFiles } from '../src/client/ProducedFiles.tsx' +import { producedForClosing } from '../src/client/turn-deliverables.ts' +import { apply, inject } from '../src/client/index.ts' +import { apply as applyNode } from '../src/index.ts' +import { apply as applyInvariant } from '../src/invariant.ts' +import { zh } from '../src/client/locales.ts' + +afterEach(cleanup) + +const user = (seq: number, text: string): UserMessageNode => ({ + kind: 'user', + seq, + time: seq * 1000, + content: [{ type: 'text', text }] as never, + source: null, +}) +const assistant = (seq: number, text: string, turn = 1): AssistantMessageNode => ({ + kind: 'assistant', seq, time: seq * 1_000, turn, step: 1, blocks: [{ kind: 'text', text }], +}) +const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode => ({ + kind: 'tool-result', seq, time: seq * 1_000, callId, + call: { name, argsRaw: `{"command":"cmd-${callId}","description":"run ${callId}"}` }, + callTime: seq * 1_000 - 500, + content: [], isError: false, callView: null, resultView: null, +}) +const wrote = (seq: number, callId: string, ...paths: string[]): ToolResultNode => ({ + ...toolResult(seq, callId, 'write'), + callView: { + card: 'diff', title: `Write ${paths[0] ?? ''}`, + diffs: paths.map(path => ({ path, oldText: null, newText: 'x' })), + locations: paths.map(path => ({ path })), + }, +}) + +describe('producedForClosing derivation', () => { + it('attributes each turn’s written files to the assistant that closes it', () => { + const nodes: ConversationNode[] = [ + user(1, 'build it'), + assistant(2, 'writing', 1), + wrote(3, 'a', 'out/index.html'), + // Same file touched twice in one turn is one deliverable, in first-seen order. + wrote(4, 'b', 'out/app.css', 'out/index.html'), + // A read is not a deliverable; a failed write has no file to open. + { ...toolResult(5, 'c', 'read'), callView: { card: 'generic', title: 'Read x', locations: [{ path: 'x.ts' }] } }, + { ...wrote(6, 'd', 'out/broken.html'), isError: true }, + assistant(7, 'done', 1), + user(8, 'again'), + assistant(9, 'second turn', 2), + ] + expect(producedForClosing(nodes, 7)).toEqual(['out/index.html', 'out/app.css']) + // A turn that produced nothing yields the empty list, and so does an + // anchor the window does not contain. + expect(producedForClosing(nodes, 9)).toEqual([]) + expect(producedForClosing([user(1, 'hi'), assistant(2, 'hello', 1)], 2)).toEqual([]) + expect(producedForClosing(nodes, 999)).toEqual([]) + }) + + it('counts a generic edit and never spills across the turn boundary', () => { + const inserted = (seq: number, callId: string, path: string): ToolResultNode => ({ + ...toolResult(seq, callId, 'str_replace_editor'), + // str_replace_editor's insert mutates behind a generic card, so the + // discriminant is the render intent, not the card shape alone. + callView: { card: 'generic', title: `insert ${path}`, kind: 'edit', locations: [{ path }] }, + }) + const nodes: ConversationNode[] = [ + user(1, 'insert a line'), + inserted(2, 'i', 'notes.md'), + assistant(3, 'inserted', 1), + // Turn 2 mutates and then ends with no content text (interrupted, or its + // last text preceded the tool): its paths must not ride into turn 3. + user(4, 'now rewrite it'), + wrote(5, 'w', 'leaked.txt'), + user(6, 'and again'), + wrote(7, 'w2', 'notes.md'), + assistant(8, 'done', 3), + ] + expect(producedForClosing(nodes, 3)).toEqual(['notes.md']) + // Turn 3 lists only its own file — and the dedup set did not suppress the + // rewrite of a path an earlier turn already touched. + expect(producedForClosing(nodes, 8)).toEqual(['notes.md']) + expect(producedForClosing(nodes, 8)).not.toContain('leaked.txt') + }) + + it('resets on a turn-number change and skips turnless, viewless, and locationless nodes', () => { + const nodes: ConversationNode[] = [ + user(1, 'go'), + // A turnless surface node neither tracks nor resets the boundary. + { kind: 'unknown', seq: 1.5, time: 1_500, type: 'x', data: null }, + wrote(2, 'w', 'turn-one.txt'), + // A view-less result (window truncation) and cards without locations + // contribute nothing rather than crashing the walk. + toolResult(3, 'plain'), + { ...toolResult(4, 'nl', 'write'), callView: { card: 'diff', title: 'Write', diffs: [] } }, + { ...toolResult(5, 'ge', 'str_replace_editor'), callView: { card: 'generic', title: 'insert', kind: 'edit' } }, + assistant(6, 'mid narration', 1), + // Turn number advances with no user message in the window (truncated + // history): the accumulator must reset all the same. + assistant(7, 'closing', 2), + ] + expect(producedForClosing(nodes, 6)).toEqual(['turn-one.txt']) + expect(producedForClosing(nodes, 7)).toEqual([]) + }) +}) + +describe('ProducedFiles row', () => { + const t = makeTranslate(zh) + + it('renders capped chips with the full path reachable and opens one on click', () => { + // Seven files: six chips plus an explicit remainder — the row bounds what + // it shows and says so rather than dropping the rest silently. + const paths = ['deep/a.html', 'b.css', 'c.ts', 'd.ts', 'e.ts', 'f.ts', 'g.ts'] + const openFile = vi.fn<(path: string) => void>() + const nodes: ConversationNode[] = [user(1, 'build it'), wrote(2, 'w', ...paths), assistant(3, 'done', 1)] + const view = render() + expect(view.getByText('产物')).toBeTruthy() + // Chips carry the basename; the full path stays reachable as the title. + const chip = view.getByRole('button', { name: '打开 deep/a.html' }) + expect(chip.textContent).toBe('a.html') + expect(chip.getAttribute('title')).toBe('deep/a.html') + expect(view.queryByRole('button', { name: '打开 g.ts' })).toBeNull() + expect(view.getByText('还有 1 个')).toBeTruthy() + fireEvent.click(chip) + expect(openFile).toHaveBeenCalledWith('deep/a.html') + }) + + it('a turn that produced nothing renders no row at all', () => { + const nodes: ConversationNode[] = [user(1, 'hi'), assistant(2, 'hello', 1)] + const view = render( {}} t={t} />) + expect(view.container.firstChild).toBeNull() + }) +}) + +describe('package shells', () => { + it('the node half mounts inert and the invariant companion registers ownership', async () => { + // The node half is deliberately inert; mounting it must simply not throw. + applyNode() + const registered: string[] = [] + const ctx = new Context() + ctx.provide('invariants') + ctx.set('invariants', { + register: (pkg: string) => { registered.push(pkg); return () => {} }, + } as never) + const dispose = await applyInvariant(ctx) + expect(registered).toEqual(['@deepseek-ai/dsh-client-ui-deliverables']) + expect(dispose).toBeTypeOf('function') + }) +}) + +describe('plugin registration', () => { + it('registers the tail entry and fiber disposal removes it', async () => { + const ctx = new Context() + await ctx.plugin(SlotsService).await() + // The owning view's child declaration, stood up by a bench root entry. + ctx.slots.register({ + name: 'root', + children: { 'conversation.chat.turnTail': { kind: 'list', scope: 'session' } }, + } as never, () => null) + await ctx.plugin({ inject: ['slots'], apply: applyLocale }).await() + + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(ctx.slots.entries('conversation.chat.turnTail')).toHaveLength(1) + + await fiber.dispose() + expect(ctx.slots.entries('conversation.chat.turnTail')).toHaveLength(0) + }) +}) diff --git a/packages/client/ui-deliverables/tsconfig.json b/packages/client/ui-deliverables/tsconfig.json new file mode 100644 index 0000000000..3fa938986f --- /dev/null +++ b/packages/client/ui-deliverables/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../locale" + }, + { + "path": "../runtime" + }, + { + "path": "../ui-conversation" + }, + { + "path": "../ui-slots" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/client/ui-deliverables/tsdown.config.ts b/packages/client/ui-deliverables/tsdown.config.ts new file mode 100644 index 0000000000..ce1a8cefcc --- /dev/null +++ b/packages/client/ui-deliverables/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-deliverables', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 208e43aa5f..e50fa70b2a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -179,6 +179,9 @@ importers: '@deepseek-ai/dsh-client-ui-conversation': specifier: workspace:^ version: link:../../packages/client/ui-conversation + '@deepseek-ai/dsh-client-ui-deliverables': + specifier: workspace:^ + version: link:../../packages/client/ui-deliverables '@deepseek-ai/dsh-client-ui-goal': specifier: workspace:^ version: link:../../packages/client/ui-goal @@ -1440,6 +1443,37 @@ importers: specifier: ^18.2.0 version: 18.3.1 + packages/client/ui-deliverables: + dependencies: + react: + specifier: ^18.2.0 + version: 18.3.1 + devDependencies: + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../test-runtime + '@deepseek-ai/dsh-client-ui-conversation': + specifier: workspace:^ + version: link:../ui-conversation + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/client/ui-goal: devDependencies: '@deepseek-ai/dsh-client-connection': diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 041972cb9f..cb7ffb7460 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -61,6 +61,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/ui-layout': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-sidebar': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-conversation': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, + 'packages/client/ui-deliverables': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-slash': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-command': { kind: 'indirect', reason: 'The dispatch paths trigger the host command.execute RPC; each command handler\'s host package owns any model-visible effect.' }, 'packages/client/ui-model': { kind: 'indirect', reason: 'Selection routes session.selectModel; the host snapshots the target at the next prompt-assembly boundary and owns the model-visible effect.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 9ba9ba5d84..94e08da920 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -151,6 +151,7 @@ "@deepseek-ai/dsh-client-ui-layout": ["./packages/client/ui-layout/src"], "@deepseek-ai/dsh-client-ui-sidebar": ["./packages/client/ui-sidebar/src"], "@deepseek-ai/dsh-client-ui-conversation": ["./packages/client/ui-conversation/src"], + "@deepseek-ai/dsh-client-ui-deliverables": ["./packages/client/ui-deliverables/src"], "@deepseek-ai/dsh-client-ui-slash": ["./packages/client/ui-slash/src"], "@deepseek-ai/dsh-client-ui-command": ["./packages/client/ui-command/src"], "@deepseek-ai/dsh-client-ui-model": ["./packages/client/ui-model/src"], diff --git a/tsconfig.client.json b/tsconfig.client.json index e1d4088061..03a2b8bb59 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -56,6 +56,7 @@ { "path": "./packages/client/ui-layout" }, { "path": "./packages/client/ui-sidebar" }, { "path": "./packages/client/ui-conversation" }, + { "path": "./packages/client/ui-deliverables" }, { "path": "./packages/client/ui-workspace" }, { "path": "./packages/client/ui-slash" }, { "path": "./packages/client/ui-command" },