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.
This commit is contained in:
38 files changed
+685
-253
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 中组合掉即可关闭该交互面,空位以零成本渲染为空。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -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<typeof chatStore>): ChatViewInjected => {
|
||||
|
||||
@@ -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 && <span className={css.stopped}>{t('message.stopped')}</span>}
|
||||
</div>
|
||||
{showActions && produced !== undefined && openFile !== undefined && (
|
||||
<Deliverables paths={produced} openFile={openFile} t={t} />
|
||||
)}
|
||||
{showActions && tail}
|
||||
{showActions && (
|
||||
<MessageIconActions
|
||||
text={copyText(blocks)}
|
||||
|
||||
@@ -30,7 +30,7 @@ import type {
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import { assistantActionsSeqs, deriveChatFlow, messageBranchSeqs, runningTurnStartTime, turnDeliverables, type ChatFlowItem } from './chat-flow.ts'
|
||||
import { assistantActionsSeqs, deriveChatFlow, messageBranchSeqs, runningTurnStartTime, type ChatFlowItem } from './chat-flow.ts'
|
||||
import { AssistantMarkdown } from './AssistantMarkdown.tsx'
|
||||
import { GenericCommandCard } from './GenericCommandCard.tsx'
|
||||
import { GenericToolCard } from './GenericToolCard.tsx'
|
||||
@@ -361,9 +361,6 @@ export function ChatView({
|
||||
// Only the last content assistant of each turn owns IconActions; mid-turn
|
||||
// text (before tools) omits `time` so AssistantMarkdown stays chrome-free.
|
||||
const actionSeqs = useMemo(() => assistantActionsSeqs(nodes), [nodes])
|
||||
// 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}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
/* 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);
|
||||
}
|
||||
@@ -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 (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -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<number, readonly string[]> {
|
||||
const closing = assistantActionsSeqs(nodes)
|
||||
const byClosingSeq = new Map<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
|
||||
}
|
||||
// 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.
|
||||
|
||||
@@ -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<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.
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(<h.ChatView {...h.props} />)
|
||||
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 <div data-testid="counting-row" />
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user