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:
ZiyaZhang
2026-08-06 06:21:19 -07:00
parent 3742214226
commit f00a44fd44
38 changed files with 685 additions and 253 deletions

View File

@@ -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 => {

View File

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

View File

@@ -30,7 +30,7 @@ import type {
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { 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}
/>
)

View File

@@ -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);
}

View File

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

View File

@@ -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.

View File

@@ -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'>
/**

View File

@@ -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.

View File

@@ -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',