Merge remote-tracking branch 'origin/master' into worktree/persist-web-theme-settings
# Conflicts: # packages/client/ui-conversation/README.i18n.yaml # packages/client/ui-theme/README.i18n.yaml # packages/host/apiproxy/README.i18n.yaml
This commit is contained in:
@@ -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: e210d9b4c0ede2bf34c55a1ba37da16dcd6b26fa
|
||||
README.zh.md: b60d482eadd86922739efd573c333d8972d28c2a
|
||||
README.md: c806f9b3cc961c428bbe554b4231b4c0b4199908
|
||||
README.zh.md: 589a76aff912cbab376bbcdf599e2e9041ae3f77
|
||||
|
||||
@@ -18,7 +18,7 @@ Logged non-user messages render as a default-collapsed disclosure whose header n
|
||||
|
||||
A Think row stays collapsed by default and exposes live reasoning throughput without expanding the chain of thought: while its reasoning block is the streaming tail, the summary switches from the settled first line to the latest non-blank line and its one-line scrollport follows each delta to the inline end. Expanding the row removes the moving summary and leaves the full reasoning in ordinary page flow, so page reading never fights an internal follower; settlement restores the stable first-line summary at the left edge ([decision](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md)).
|
||||
|
||||
Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file with the host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering.
|
||||
Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is an underlined link — it reads as one at rest, not only on hover, because a path styled like the surrounding prose is an affordance nobody finds — and it opens the file through the Host (`host.openPath`, relative paths resolve against the session cwd). A document a browser renders prefers the default browser where the Host platform can name one; Windows and WSL use the Windows registered association. The Host opens it on the Host's own machine: a client reached over a network sees nothing, which is the deliberate scope of this surface. Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering.
|
||||
|
||||
A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card below its summary row; tool rows are summary surfaces, so the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which keeps the summary bounded; the panel stays the single-call reading surface. Inline output is licensed per render intent — the terminal and web cards, each with its own bound. A Bash execution failure that settles on the generic path instead exposes its original arguments and full error through the same bounded IN/OUT disclosure, while successful generic results such as a background-start acknowledgement remain summary-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)).
|
||||
|
||||
@@ -50,6 +50,8 @@ 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 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
|
||||
|
||||
None, as the conversation UI renders session history and streams in the browser; nothing here reaches a model request.
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
Think 行默认保持折叠,并在不展开思维链的情况下暴露实时推理(reasoning)吞吐:当推理块是流式输出尾部时,摘要从结算后的首行切换到最新的非空行,其单行滚动区会随每个 delta 追到行内末端。展开该行会移除移动摘要,让完整推理进入普通页面流,因此页面阅读不会与内部跟随器争夺滚动;结算后恢复左对齐的稳定首行摘要([决策](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md))。
|
||||
|
||||
通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击后通过宿主操作系统的默认应用打开文件(`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行)。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。
|
||||
通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是带下划线的链接——静止状态下就读得出是链接,而不只在悬停时,因为一条与周围正文同样样式的路径是没人会发现的交互——点击即经由 Host 打开文件(`host.openPath`,相对路径相对会话 cwd 解析)。浏览器能渲染的文档会在 Host 平台能够确定默认浏览器时优先使用它;Windows 与 WSL 则使用 Windows 注册的文件关联。Host 在它自己的机器上打开:经网络访问的客户端看不到任何东西,这是本交互面刻意划定的范围。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行)。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。
|
||||
|
||||
声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片放在摘要行下方;工具行是摘要 surface,因此卡片的复制与展开控件是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,因此摘要保持有界;面板仍是单次调用的阅读 surface。内联输出按渲染意图开放——终端卡片与 web 卡片各有自己的上限。若 Bash 执行失败时落在通用路径,则改用同样有界的 IN/OUT 展开区暴露原始参数和完整错误;后台启动确认等成功的通用结果仍只显示摘要([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。
|
||||
|
||||
@@ -50,6 +50,8 @@ 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 注册抵达页面。
|
||||
|
||||
完成的一轮以一个 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 中组合掉即可关闭该交互面,空位以零成本渲染为空。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无。会话 UI 在浏览器中渲染会话历史与流;这里没有任何内容进入模型请求。
|
||||
|
||||
@@ -313,6 +313,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: 'chain', scope: 'session' },
|
||||
},
|
||||
store: chatStore,
|
||||
inject: (sessionId: SessionId, actions: BoundActions<typeof chatStore>): ChatViewInjected => {
|
||||
|
||||
@@ -11,10 +11,11 @@
|
||||
|
||||
import { memo, useMemo } from 'react'
|
||||
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import {
|
||||
IconThinkOutline14, JsonBlock, MarkdownText,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import type { ChatViewSlotProps, TurnTailOwnerProps } from '../contract/slots.ts'
|
||||
import { hasContentText } from './chat-flow.ts'
|
||||
import { MessageIconActions } from './MessageIconActions.tsx'
|
||||
import { ToolRow } from './ToolRow.tsx'
|
||||
@@ -40,6 +41,8 @@ export interface AssistantMarkdownProps {
|
||||
seq?: number | undefined
|
||||
/** Fork the session through this finalized message's completed turn when eligible. */
|
||||
onFork?: ((seq: number) => void) | undefined
|
||||
/** Turn-tail slot dispatch share and owner currency; omitted for a mid-turn assistant. */
|
||||
turnTail?: (Pick<PropsRenderSlots<'conversation.chat.turnTail'>, 'renderSlotChain'> & { owner: TurnTailOwnerProps }) | 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. */
|
||||
@@ -83,7 +86,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, t,
|
||||
blocks, streaming, interrupted, time, runMs, ttftMs, tokensPerSecond, seq, onFork, forkUnavailable, turnTail, t,
|
||||
}: AssistantMarkdownProps) {
|
||||
// Stable per locale revision (t identity changes on switch): a fresh object
|
||||
// per render would rebuild MarkdownText's component table every chunk.
|
||||
@@ -121,6 +124,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||
})}
|
||||
{interrupted && <span className={css.stopped}>{t('message.stopped')}</span>}
|
||||
</div>
|
||||
{showActions && turnTail?.renderSlotChain('conversation.chat.turnTail', turnTail.owner)}
|
||||
{showActions && (
|
||||
<MessageIconActions
|
||||
text={copyText(blocks)}
|
||||
|
||||
@@ -335,7 +335,7 @@ function StreamingTail({ useSession, t }: {
|
||||
* render through the declared keyed hole's renderSlot share).
|
||||
*/
|
||||
export function ChatView({
|
||||
useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt, t,
|
||||
useSession, useSessions, useStore, renderSlot, renderSlotChain, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt, t,
|
||||
}: ChatViewSlotProps) {
|
||||
const nodes = useSession(s => s.nodes)
|
||||
const turnTimings = useSession(s => s.turnTimings)
|
||||
@@ -600,6 +600,9 @@ export function ChatView({
|
||||
seq={node.seq}
|
||||
onFork={forkAt}
|
||||
forkUnavailable={!branchSeqs.has(node.seq)}
|
||||
turnTail={actionSeqs.has(node.seq)
|
||||
? { renderSlotChain, owner: { nodes, seq: node.seq, openFile } }
|
||||
: undefined}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -103,7 +103,7 @@
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* File-tool path: same geometry as .summary; hover underline + pointer. */
|
||||
/* File-tool path: same geometry as .summary, with a persistent link affordance. */
|
||||
.fileLink {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
@@ -118,12 +118,16 @@
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--dsw-alias-label-quaternary);
|
||||
text-underline-offset: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fileLink:hover {
|
||||
text-decoration: underline;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
text-decoration-color: currentColor;
|
||||
}
|
||||
|
||||
/* Error row's collapsed summary: the failure's first line in the error color. */
|
||||
|
||||
@@ -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,14 @@ 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 chain: rendered between a closing assistant
|
||||
* message's body and its IconActions footer, once per turn (the render
|
||||
* site elects the closing seq). Entries derive a match from the owner
|
||||
* currency before mounting, so presentation components never mount only
|
||||
* to return null; an all-declined chain renders nothing.
|
||||
*/
|
||||
'conversation.chat.turnTail': { kind: 'chain'; 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 +158,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 +506,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<ChatStore> & ChatViewInjected & PropsLocale<'conversation'>
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -24,12 +24,12 @@
|
||||
}
|
||||
|
||||
/* figma 34:10411: fish + title, gap 10, centered; 26/32 wt500. The preview
|
||||
badge is a product addition outside that source and aligns to the title. */
|
||||
badge is a product addition outside that source: a mono superscript pill
|
||||
riding the title's top-right. */
|
||||
.headline {
|
||||
display: grid;
|
||||
grid-template-columns: 34px auto;
|
||||
grid-template-columns: 34px auto auto;
|
||||
column-gap: 10px;
|
||||
row-gap: 4px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 26px;
|
||||
@@ -44,13 +44,17 @@
|
||||
}
|
||||
|
||||
.previewBadge {
|
||||
grid-row: 2;
|
||||
grid-column: 2;
|
||||
justify-self: start;
|
||||
padding: 0 4px;
|
||||
border-radius: 4px;
|
||||
grid-row: 1;
|
||||
grid-column: 3;
|
||||
align-self: start;
|
||||
margin-top: 2px;
|
||||
margin-left: -3px;
|
||||
padding: 1px 7px 0;
|
||||
border: 1px solid var(--dsw-alias-interactive-bg-hover);
|
||||
border-radius: 24px;
|
||||
background: var(--dsw-alias-state-business-tertiary);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
color: var(--dsw-alias-label-primary-bluish);
|
||||
font-family: var(--ds-font-family-code);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
font-weight: 500;
|
||||
|
||||
@@ -130,6 +130,8 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
||||
const chat = createChatStore().create()
|
||||
const renderSlot = ((_key: string, _owner: object, opts?: { fallback?: React.ReactNode }) =>
|
||||
opts?.fallback ?? null) as unknown as ChatViewSlotProps['renderSlot']
|
||||
const renderSlotChain = ((_key: string, _owner: object, opts?: { fallback?: React.ReactNode }) =>
|
||||
opts?.fallback ?? null) as unknown as ChatViewSlotProps['renderSlotChain']
|
||||
// SessionProvider seat arrives with the session-scope child declaration;
|
||||
// ChatView never invokes it (render-prop pass-through stub).
|
||||
const SessionProviderStub: ChatViewSlotProps['SessionProvider'] = ({ children }) => <>{children(SID)}</>
|
||||
@@ -144,6 +146,7 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
||||
useStore: bindSnapshotSelector(chat),
|
||||
actions: chat.actions,
|
||||
renderSlot,
|
||||
renderSlotChain,
|
||||
SessionProvider: SessionProviderStub,
|
||||
openDetails,
|
||||
openFile,
|
||||
@@ -732,7 +735,8 @@ 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) => {
|
||||
if (key !== 'conversation.chat.toolview') return null
|
||||
rowRenders += 1
|
||||
return <div data-testid="counting-row" />
|
||||
})
|
||||
|
||||
6
packages/client/ui-deliverables/README.i18n.yaml
Normal file
6
packages/client/ui-deliverables/README.i18n.yaml
Normal file
@@ -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
|
||||
21
packages/client/ui-deliverables/README.md
Normal file
21
packages/client/ui-deliverables/README.md
Normal file
@@ -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.
|
||||
21
packages/client/ui-deliverables/README.zh.md
Normal file
21
packages/client/ui-deliverables/README.zh.md
Normal file
@@ -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 的后续工作。
|
||||
65
packages/client/ui-deliverables/package.json
Normal file
65
packages/client/ui-deliverables/package.json
Normal file
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/* Turn-tail produced-files row: a quiet label followed by wrapping file chips.
|
||||
Sits between the assistant body and its IconActions footer, so it reads as
|
||||
part of the answer rather than as another tool row. */
|
||||
|
||||
.root {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
font-size: 13px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* One produced file. A link by behavior (it opens the file), a chip by shape:
|
||||
full paths are long and several may wrap onto one row. */
|
||||
.file {
|
||||
max-width: 320px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin: 0;
|
||||
padding: 0 8px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Overflow count: the row never silently drops files it did not show. */
|
||||
.more {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
54
packages/client/ui-deliverables/src/client/ProducedFiles.tsx
Normal file
54
packages/client/ui-deliverables/src/client/ProducedFiles.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
// ProducedFiles: the produced-file row a finished turn ends with. The paths
|
||||
// come pre-matched by the turn-tail chain from the mutation tools'
|
||||
// follow-along locations, never from the closing prose. Clicking one goes
|
||||
// through the same openFile the tool rows use — the Host's own opener, on the
|
||||
// Host machine.
|
||||
|
||||
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
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)
|
||||
}
|
||||
|
||||
/** Matched paths plus the opener and locale seats needed to present them. */
|
||||
export type ProducedFilesProps = Pick<TurnTailOwnerProps, 'openFile'> & {
|
||||
matched: readonly string[]
|
||||
} & PropsLocale<typeof NS>
|
||||
|
||||
/**
|
||||
* Render one turn's produced files as openable chips.
|
||||
* @param props - selector-matched paths, the chat view's file opener, and the locale seat.
|
||||
* @returns The produced-files row.
|
||||
*/
|
||||
export function ProducedFiles({ matched: paths, openFile, t }: ProducedFilesProps) {
|
||||
const shown = paths.slice(0, SHOWN)
|
||||
const hidden = paths.length - shown.length
|
||||
return (
|
||||
<div className={css.root}>
|
||||
<span className={css.label}>{t('produced.label')}</span>
|
||||
{shown.map(path => (
|
||||
<button
|
||||
key={path}
|
||||
type="button"
|
||||
className={css.file}
|
||||
// The full path is the disambiguator when two turns produce files
|
||||
// that share a basename; the chip itself stays short.
|
||||
title={path}
|
||||
aria-label={t('produced.open', { name: path })}
|
||||
onClick={() => { openFile(path) }}
|
||||
>
|
||||
{basename(path)}
|
||||
</button>
|
||||
))}
|
||||
{hidden > 0 && <span className={css.more}>{t('produced.more', { count: String(hidden) })}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
42
packages/client/ui-deliverables/src/client/index.ts
Normal file
42
packages/client/ui-deliverables/src/client/index.ts
Normal file
@@ -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'
|
||||
import { selectProducedFiles } from './turn-deliverables.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',
|
||||
select: selectProducedFiles,
|
||||
locale: NS,
|
||||
}, ProducedFiles),
|
||||
)
|
||||
}
|
||||
21
packages/client/ui-deliverables/src/client/locales.ts
Normal file
21
packages/client/ui-deliverables/src/client/locales.ts
Normal file
@@ -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<DeliverablesKey, string> = {
|
||||
'produced.label': 'Produced',
|
||||
'produced.more': '{count} more',
|
||||
'produced.open': 'Open {name}',
|
||||
}
|
||||
|
||||
/** Union of this namespace's dictionary keys. */
|
||||
export type DeliverablesKey = keyof typeof zh
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* 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'
|
||||
import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/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<string>()
|
||||
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 []
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim the turn-tail chain only when its closing turn produced files.
|
||||
* @param owner - Turn-tail owner currency for the closing assistant.
|
||||
* @returns Produced paths as the component's match, or null to decline before mount.
|
||||
*/
|
||||
export function selectProducedFiles(owner: TurnTailOwnerProps): readonly string[] | null {
|
||||
const { nodes, seq } = owner
|
||||
const paths = producedForClosing(nodes, seq)
|
||||
return paths.length === 0 ? null : paths
|
||||
}
|
||||
6
packages/client/ui-deliverables/src/css-modules.d.ts
vendored
Normal file
6
packages/client/ui-deliverables/src/css-modules.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
|
||||
declare module '*.css'
|
||||
9
packages/client/ui-deliverables/src/index.ts
Normal file
9
packages/client/ui-deliverables/src/index.ts
Normal file
@@ -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 {}
|
||||
32
packages/client/ui-deliverables/src/invariant.ts
Normal file
32
packages/client/ui-deliverables/src/invariant.ts
Normal file
@@ -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 */
|
||||
178
packages/client/ui-deliverables/tests/produced-files.spec.tsx
Normal file
178
packages/client/ui-deliverables/tests/produced-files.spec.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
// @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, selectProducedFiles } 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'])
|
||||
expect(selectProducedFiles({ nodes, seq: 7, openFile: () => {} })).toEqual(['out/index.html', 'out/app.css'])
|
||||
expect(selectProducedFiles({ nodes, seq: 9, openFile: () => {} })).toBeNull()
|
||||
// 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 view = render(<ProducedFiles matched={paths} openFile={openFile} t={t} />)
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
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: 'chain', 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)
|
||||
})
|
||||
})
|
||||
30
packages/client/ui-deliverables/tsconfig.json
Normal file
30
packages/client/ui-deliverables/tsconfig.json
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
3
packages/client/ui-deliverables/tsdown.config.ts
Normal file
3
packages/client/ui-deliverables/tsdown.config.ts
Normal file
@@ -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'])
|
||||
@@ -4,7 +4,7 @@
|
||||
* The shell renders the surrounding chrome (button, nav heading row) and
|
||||
* reads each entry's `label` option for aria text.
|
||||
*/
|
||||
import { IconSettingsOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconSettingsOutline14, IconSettingsOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import css from './chrome.module.css'
|
||||
|
||||
@@ -22,7 +22,7 @@ export type HeaderContentProps = PropsRuntime<'settings.header'> & PropsLocale<'
|
||||
export function TriggerContent({ wide, t }: TriggerContentProps) {
|
||||
return (
|
||||
<>
|
||||
<IconSettingsOutline14 size={wide ? 14 : 18} />
|
||||
{wide ? <IconSettingsOutline16 size={16} /> : <IconSettingsOutline14 size={18} />}
|
||||
{wide && <span className={css.triggerLabel}>{t('trigger')}</span>}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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-theme/README.md
|
||||
README.md: b79eac0d7777ac7af9b6a8960dc4d9797b41513d
|
||||
README.zh.md: c57ccbdb8fdfb735b3a5d0d66f3538dd01966ada
|
||||
README.md: 97761259720d247f6c1f60e06aa257cc30e8490f
|
||||
README.zh.md: 4958f86f37b53b4fcc2f1d8a8ceadeb6b827e411
|
||||
|
||||
@@ -21,4 +21,4 @@ None; this package neither assembles nor sends a provider request.
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Third-party themes are a surface, not a product** — registering one means overriding same-named alias variables; no validation exists that an override set is complete.
|
||||
- **The token sheets are the sole color authority** — values absent from cssdesign (for example the design's #4176E6 tab blue) are deliberately not appended; the nearest semantic token wins (arbitrated 2026-07-22).
|
||||
- **The token sheets are the sole color authority** — values absent from cssdesign (for example the design's #4176E6 tab blue) are deliberately not appended; the nearest semantic token wins (arbitrated 2026-07-22). Design-owner-approved additions are the exception and enter as a static step plus a semantic alias in the same change (`--dsw-static-blue-900` / `--dsw-alias-label-primary-bluish`, 2026-08-07).
|
||||
|
||||
@@ -21,4 +21,4 @@
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **第三方主题是表层,不是产品**:注册主题意味着覆盖同名别名变量;目前不会验证一组覆盖是否完整。
|
||||
- **token 样式表是颜色值的唯一权威来源**:会有意不补入 cssdesign 中缺失的值(例如设计中的 #4176E6 标签页蓝色);一律采用最接近的语义 token(裁定于 2026-07-22)。
|
||||
- **token 样式表是颜色值的唯一权威来源**:会有意不补入 cssdesign 中缺失的值(例如设计中的 #4176E6 标签页蓝色);一律采用最接近的语义 token(裁定于 2026-07-22)。设计负责人批准的新增值是例外:须在同一变更中以一个 static 梯度值加一个语义 alias 的形式进入(`--dsw-static-blue-900` / `--dsw-alias-label-primary-bluish`,2026-08-07)。
|
||||
|
||||
@@ -17,6 +17,7 @@ body {
|
||||
--dsw-static-blue-600: rgb(37, 99, 235);
|
||||
--dsw-static-blue-75: rgb(229, 240, 255);
|
||||
--dsw-static-blue-800: rgb(30, 64, 175);
|
||||
--dsw-static-blue-900: rgb(14, 48, 116);
|
||||
--dsw-static-blue-950: rgb(23, 37, 84);
|
||||
--dsw-static-deepseek-100: rgb(228, 237, 253);
|
||||
--dsw-static-deepseek-200: rgb(211, 226, 255);
|
||||
@@ -92,6 +93,7 @@ body[data-ds-dark-theme] {
|
||||
--dsw-static-blue-600: rgb(37, 99, 235);
|
||||
--dsw-static-blue-75: rgb(229, 240, 255);
|
||||
--dsw-static-blue-800: rgb(30, 64, 175);
|
||||
--dsw-static-blue-900: rgb(14, 48, 116);
|
||||
--dsw-static-blue-950: rgb(23, 37, 84);
|
||||
--dsw-static-deepseek-100: rgb(228, 237, 253);
|
||||
--dsw-static-deepseek-200: rgb(211, 226, 255);
|
||||
@@ -197,6 +199,7 @@ body {
|
||||
--dsw-alias-interactive-bg-hover: rgba(38, 49, 72, 0.06);
|
||||
--dsw-alias-label-caption: var(--dsw-static-neutral-bluish-400);
|
||||
--dsw-alias-label-dimmed: var(--dsw-static-neutral-bluish-200);
|
||||
--dsw-alias-label-primary-bluish: var(--dsw-static-blue-900);
|
||||
--dsw-alias-label-primary-dimmed: var(--dsw-static-neutral-bluish-950);
|
||||
--dsw-alias-label-primary-foreground: var(--dsw-static-neutral-bluish-00);
|
||||
--dsw-alias-label-primary-inverted: var(--dsw-static-neutral-bluish-00);
|
||||
@@ -287,6 +290,7 @@ body[data-ds-dark-theme] {
|
||||
--dsw-alias-interactive-bg-hover: rgba(255, 255, 255, 0.08);
|
||||
--dsw-alias-label-caption: var(--dsw-static-neutral-bluish-600);
|
||||
--dsw-alias-label-dimmed: var(--dsw-static-neutral-bluish-750);
|
||||
--dsw-alias-label-primary-bluish: var(--dsw-static-neutral-bluish-50);
|
||||
--dsw-alias-label-primary-dimmed: var(--dsw-static-neutral-bluish-100);
|
||||
--dsw-alias-label-primary-foreground: var(--dsw-static-neutral-bluish-1000);
|
||||
--dsw-alias-label-primary-inverted: var(--dsw-static-neutral-bluish-800);
|
||||
|
||||
Reference in New Issue
Block a user