Merge remote-tracking branch 'origin/master' into feat/web-workspace-file-links

# Conflicts:
#	packages/client/ui-conversation/README.i18n.yaml
#	packages/client/ui-conversation/src/client/chat/ToolRow.module.css
#	packages/host/apiproxy/src/native-path-opener.ts
This commit is contained in:
imccyu
2026-08-07 16:49:20 +08:00
604 changed files with 21472 additions and 2659 deletions
@@ -4,10 +4,10 @@
// view groups them into tool rows through its keyed toolview slot (figma
// step-summary flow). Shared by finalized nodes and the streaming partial;
// the turn-level loading dots live in the chat view's tail, not here.
// Finalized content (text) nodes append IconActions once streaming ends
// (`time` is omitted for mid-turn narration); their branch action is enabled
// only when the node is also the completed turn's transcript tail. Think /
// tool-head-only nodes stay chrome-free.
// Finalized content (text) nodes append IconActions once their turn ends
// (`time` is omitted for mid-turn narration and while the turn still runs);
// their branch action is enabled only when the node is also the completed
// turn's transcript tail. Think / tool-head-only nodes stay chrome-free.
import { memo, useMemo, type ReactNode } from 'react'
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
@@ -15,6 +15,7 @@ import {
IconThinkOutline14, JsonBlock, MarkdownText,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { hasContentText } from './chat-flow.ts'
import { MessageIconActions } from './MessageIconActions.tsx'
import { ToolRow } from './ToolRow.tsx'
import css from './AssistantMarkdown.module.css'
@@ -25,7 +26,8 @@ export interface AssistantMarkdownProps {
/** Frozen partial of an aborted turn: rendered with a stopped marker. */
interrupted?: boolean | undefined
/** Unix epoch ms for the IconActions clock; omitted while streaming or when
* the parent withholds chrome (mid-turn content assistants). */
* the parent withholds chrome (mid-turn content assistants and every node
* of a turn that has not ended). */
time?: number | undefined
/** Turn wall time in ms for the IconActions run-time label; omitted when the
* turn's triggering input is outside the loaded window. */
@@ -68,11 +70,6 @@ function copyText(blocks: readonly AssistantBlock[]): string {
return parts.join('')
}
/** True when the node has model-visible text content worth chrome under. */
function hasContentText(blocks: readonly AssistantBlock[]): boolean {
return blocks.some(block => block.kind === 'text' && block.text.trim() !== '')
}
/** Reasoning block as the Think variant summary row (figma 39:28304). */
function ThinkRow({ text, running, t }: { text: string; running: boolean; t: AssistantMarkdownProps['t'] }) {
return (
@@ -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, type ChatFlowItem } from './chat-flow.ts'
import { assistantActionsSeqs, assistantBranchSeqs, deriveChatFlow, runningTurnStartTime, type ChatFlowItem } from './chat-flow.ts'
import { AssistantMarkdown } from './AssistantMarkdown.tsx'
import { GenericCommandCard } from './GenericCommandCard.tsx'
import { GenericToolCard } from './GenericToolCard.tsx'
@@ -358,10 +358,11 @@ export function ChatView({
[inbox],
)
const activeRetry = useMemo(() => activeRetrySeq(nodes, running), [nodes, running])
// Only the last content assistant of each turn owns IconActions; mid-turn
// text (before tools) omits `time` so AssistantMarkdown stays chrome-free.
const actionSeqs = useMemo(() => assistantActionsSeqs(nodes), [nodes])
const branchSeqs = useMemo(() => messageBranchSeqs(nodes, turnEnds), [nodes, turnEnds])
// Only the last content assistant of each completed turn owns IconActions;
// mid-turn text and every node of a running turn omit `time`, so
// AssistantMarkdown stays chrome-free until the answer settles.
const actionSeqs = useMemo(() => assistantActionsSeqs(nodes, turnEnds), [nodes, turnEnds])
const branchSeqs = useMemo(() => assistantBranchSeqs(nodes, turnEnds), [nodes, turnEnds])
const runningTurnStart = useMemo(() => runningTurnStartTime(turnTimings), [turnTimings])
const turnMetrics = useMemo(() => deriveTurnMetrics(nodes), [nodes])
@@ -371,9 +372,6 @@ export function ChatView({
const [atBottom, setAtBottom] = useState(true)
/** Last position delivered or written on the main thread. */
const observedTopRef = useRef(0)
/** Pre-input position for the current wheel gesture. */
const wheelStartRef = useRef<number | null>(null)
const wheelEpochRef = useRef(0)
/** Paging anchor: semantic row/position at click, updated by reader scrolls
* while the request is pending and restored after the prepend lands. */
const anchorRef = useRef<PagingAnchor | null>(null)
@@ -393,8 +391,6 @@ export function ChatView({
const followSig = `${openState}:${firstSeq}:${lastKey}:${nodes.length}:${running ? 1 : 0}:${runningCalls.length}:${lastSteeringId ?? ''}`
const toBottom = (el: HTMLElement): void => {
wheelStartRef.current = null
wheelEpochRef.current += 1
anchorRef.current = null
el.scrollTop = el.scrollHeight
observedTopRef.current = el.scrollTop
@@ -471,17 +467,19 @@ export function ChatView({
/* v8 ignore next -- ref-null guard: the handler only fires while mounted. */
if (local === null) return
const el = scrollerOf(local)
// Only wheel input may make raw scroll geometry change follow ownership.
// Browser clamping and delayed programmatic scroll events otherwise have
// the same event shape and must preserve the current ownership state.
// Only reader input may make raw scroll geometry change follow ownership:
// a delivered position that deviates from the observed-top ledger (every
// programmatic write records itself there synchronously). This covers
// wheel, touch, scrollbar, and keyboard alike without naming devices.
// Browser shrink-clamps land exactly on the floor min and delayed
// programmatic deliveries land on the ledger itself, so both preserve
// the current ownership state.
const floor = Math.max(0, el.scrollHeight - el.clientHeight)
const wheelStart = wheelStartRef.current
const movedByWheel = wheelStart !== null
&& Math.abs(el.scrollTop - Math.min(wheelStart, floor)) > 0.5
const isAtBottom = movedByWheel
const movedByReader = Math.abs(el.scrollTop - Math.min(observedTopRef.current, floor)) > 0.5
const isAtBottom = movedByReader
? floor - el.scrollTop <= FOLLOW_THRESHOLD + 1
: atBottomRef.current
if (!movedByWheel && isAtBottom) {
if (!movedByReader && isAtBottom) {
toBottom(el)
return
}
@@ -500,34 +498,18 @@ export function ChatView({
observedTopRef.current = el.scrollTop
}
// Bind scroll and the wheel provenance needed to distinguish reader input
// from layout-driven scrolls on the resolved scrollport once per mount.
// Bind the scroll listener on the resolved scrollport once per mount;
// reader-input attribution rides the observed-top ledger, not per-device
// input listeners.
useEffect(() => {
const local = listRef.current
/* v8 ignore next -- ref-null guard: effect runs after the list node commits. */
if (local === null) return
const el = scrollerOf(local)
const onScroll = (): void => { onScrollRef.current() }
const onWheel = (event: WheelEvent): void => {
if (event.ctrlKey || event.deltaY === 0) return
const startTop = observedTopRef.current
const floor = Math.max(0, el.scrollHeight - el.clientHeight)
const canMove = event.deltaY < 0 ? startTop > 1 : startTop < floor - 1
if (!canMove) return
wheelStartRef.current = startTop
const epoch = ++wheelEpochRef.current
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (wheelEpochRef.current === epoch) wheelStartRef.current = null
})
})
}
el.addEventListener('scroll', onScroll, { passive: true })
el.addEventListener('wheel', onWheel, { capture: true, passive: true })
return () => {
wheelStartRef.current = null
el.removeEventListener('scroll', onScroll)
el.removeEventListener('wheel', onWheel, true)
}
}, [])
@@ -634,8 +616,6 @@ export function ChatView({
<MessageItem
node={node}
retryActive={node.kind === 'model-retry' && node.seq === activeRetry}
onFork={forkAt}
forkUnavailable={!branchSeqs.has(node.seq)}
t={t}
/>
)
@@ -27,8 +27,6 @@ export interface MessageIconActionsProps {
onBranch?: (() => void) | undefined
/** The message is not a completed transcript tail, so branch stays visible but unavailable. */
branchUnavailable?: boolean | undefined
/** Additional branch visibility gate for transient message chrome; defaults to true. */
showBranch?: boolean | undefined
/** Parent layout class composed onto the actions row. */
className?: string | undefined
/** The owning view's locale seat, passed down as a plain prop. */
@@ -41,7 +39,7 @@ export interface MessageIconActionsProps {
* @returns The actions row element.
*/
export function MessageIconActions({
text, time, runMs, ttftMs, tokensPerSecond, clock, onBranch, branchUnavailable = false, showBranch = true, className, t,
text, time, runMs, ttftMs, tokensPerSecond, clock, onBranch, branchUnavailable = false, className, t,
}: MessageIconActionsProps) {
const day = useCalendarDay()
const reasonId = useId()
@@ -111,7 +109,7 @@ export function MessageIconActions({
{copied ? <IconCheckOutline16 /> : <IconCopyOutline16 />}
</button>
</Tooltip>
{showBranch && onBranch !== undefined && (
{onBranch !== undefined && (
<Tooltip label={branchUnavailable ? t('message.branchUnavailable') : t('message.branch')} side="bottom">
{/* Native disabled buttons do not deliver the hover/focus events Tooltip needs. */}
<button
@@ -127,7 +125,7 @@ export function MessageIconActions({
</button>
</Tooltip>
)}
{showBranch && onBranch !== undefined && branchUnavailable && (
{onBranch !== undefined && branchUnavailable && (
<span id={reasonId} className={css.visuallyHidden}>{t('message.branchUnavailable')}</span>
)}
{clock === 'end' ? clockEl : null}
@@ -1,8 +1,8 @@
// MessageItem: simple chat nodes — user and consumed-steering bubbles
// (right-aligned, with clock + copy / branch IconActions; steering adds the
// interjection caption that names it), pending steering (caption + copy only),
// context injection, compaction marker, retry disclosure, and unknown-surface
// JSON rows.
// (right-aligned, with clock + copy IconActions; steering adds the
// interjection caption that names it; branch lives only under assistant
// answers), pending steering (caption + copy only), context injection,
// compaction marker, retry disclosure, and unknown-surface JSON rows.
import { memo, useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
@@ -27,10 +27,6 @@ export interface MessageItemProps {
| TurnErrorNode
| UnknownSurfaceNode
retryActive?: boolean
/** Fork through this message's completed turn when eligible. */
onFork?: (seq: number) => void
/** The message is not the transcript tail of a completed turn. */
forkUnavailable?: boolean
/** The owning view's locale seat, passed down as a plain prop. */
t: ChatViewSlotProps['t']
}
@@ -217,7 +213,6 @@ export function PendingSteeringBubble({ content, t }: {
<MessageIconActions
text={text}
clock="start"
showBranch={false}
className={css.actions}
t={t}
/>
@@ -227,7 +222,7 @@ export function PendingSteeringBubble({ content, t }: {
}
export const MessageItem = memo(function MessageItem({
node, retryActive = false, onFork, forkUnavailable = false, t,
node, retryActive = false, t,
}: MessageItemProps) {
const truncated = (total: number): string => t('json.truncated', { total })
switch (node.kind) {
@@ -243,8 +238,6 @@ export const MessageItem = memo(function MessageItem({
text={text}
time={node.time}
clock="start"
onBranch={onFork === undefined ? undefined : () => { onFork(node.seq) }}
branchUnavailable={forkUnavailable}
className={css.actions}
t={t}
/>
@@ -89,10 +89,21 @@
text-overflow: clip;
}
/* File-tool path: same geometry as .summary, but it must READ as a link. A
path styled exactly like the surrounding prose, underlined only on hover, is
an affordance nobody finds — the reported "I can't open what it made" was
this, not a missing capability. */
/* Trailing summary fragment kept out of .summary's ellipsis, for a count whose
whole value is that it survives a narrow row (the todo row's parallel-active
`+n`). Repeats .summary's type because it sits beside that text, and its
`nowrap` too: `flex: none` stops the box shrinking but not the text wrapping,
which would break the one-line row in the narrow case the slot exists for. */
.summarySuffix {
flex: none;
margin-left: 4px;
white-space: nowrap;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-tertiary);
}
/* File-tool path: same geometry as .summary, with a persistent link affordance. */
.fileLink {
flex: 1 1 auto;
min-width: 0;
@@ -23,7 +23,7 @@
import { useEffect, useRef, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
import clsx from 'clsx'
import {
CodeBlock, DiffBlock, ReadBlock, SearchBlock, StateDot, TerminalBlock, WebBlock,
CodeBlock, DiffBlock, IconInspectOutline12, ReadBlock, SearchBlock, StateDot, TerminalBlock, WebBlock,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { WebBlockProps } from '@deepseek-ai/dsh-client-ui-primitives'
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
@@ -46,6 +46,14 @@ export interface ToolRowProps {
icon: ReactNode
title: string
summary: string
/**
* Trailing summary fragment rendered outside the ellipsized summary text, so
* a narrow row clips the summary before this. For a fragment whose whole
* value is surviving that clip — the todo row's parallel-active count.
* null/absent = the summary is the whole collapsed content. Dropped on an
* error row, whose collapsed summary is the failure line instead.
*/
summarySuffix?: string | null | undefined
/** Expanded-body input text; null = no input section. */
body: string | null
/** Flattened result text for the expanded Output section; null/absent = no output section. */
@@ -99,15 +107,6 @@ export interface ToolRowProps {
inspect?: (() => void) | undefined
}
/** The Inspect pill's code glyph (user-supplied 16×16), fill follows text color. */
function IconInspect() {
return (
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden>
<path d="M16 8L10.8571 12V10.552L14.1383 8L10.8571 5.448V4L16 8ZM5.14286 10.552L1.86171 8L5.14286 5.448V4L0 8L5.14286 12V10.552ZM9.02514 4L5.59657 12H6.84057L10.2691 4H9.02514Z" fill="currentColor" />
</svg>
)
}
/** Leading-slot state substitution: the tool icon yields to the terminal state
* semantic (error = red, interrupted = amber halo). Running keeps the icon —
* the row sweep (CSS on data-state) carries the in-flight signal. */
@@ -139,6 +138,7 @@ export function ToolRow({
icon,
title,
summary,
summarySuffix,
body,
output,
errorSummary,
@@ -173,6 +173,9 @@ export function ToolRow({
// the error color outranks both the args summary and a terminal description.
const failureLine = state === 'error' ? errorSummary ?? null : null
const summaryText = failureLine ?? summary
// The failure line replaces the summary wholesale, so a suffix derived from
// the call args has nothing left to sit beside.
const suffix = failureLine === null ? summarySuffix ?? null : null
// The failure line is error prose, not the path: no open-file affordance.
const fileLink = filePath !== undefined && onOpenFile !== undefined && failureLine === null
const isThink = variant === 'think'
@@ -249,6 +252,7 @@ export function ToolRow({
{summaryText}
</span>
)}
{suffix !== null && <span className={css.summarySuffix}>{suffix}</span>}
</>
)}
>
@@ -319,7 +323,7 @@ export function ToolRow({
className={css.inspectButton}
onClick={inspect}
>
<IconInspect />
<IconInspectOutline12 />
Inspect
</button>
)}
@@ -17,8 +17,14 @@ export type ChatFlowItem =
| { kind: 'node'; key: string; node: ConversationNode }
| { kind: 'tool-group'; key: string; results: readonly ToolResultNode[] }
/** True when the node has model-visible text content worth IconActions chrome. */
function hasContentText(blocks: readonly AssistantBlock[]): boolean {
/**
* True when the node has model-visible text content worth IconActions chrome.
* Shared with {@link AssistantMarkdown}'s mount gate so ownership and mounting
* cannot diverge.
* @param blocks - assistant blocks of one finalized node.
* @returns Whether any text block carries non-blank content.
*/
export function hasContentText(blocks: readonly AssistantBlock[]): boolean {
return blocks.some(block => block.kind === 'text' && block.text.trim() !== '')
}
@@ -34,14 +40,20 @@ function rendersNothing(node: ConversationNode): boolean {
/**
* Seq set of assistants that own IconActions: the last content-text assistant
* in each turn. Mid-turn narration (text before tools) stays chrome-free.
* of each *completed* turn. A turn without a `turn/end` in the window is still
* producing steps, so its latest narration is not the settled answer and owns
* nothing; mid-turn narration of a completed turn stays chrome-free too.
* @param nodes - snapshot nodes (surface order).
* @param turnEnds - completed turn boundaries retained from the event window.
* @returns Seq values ChatView may pass as `time` into AssistantMarkdown.
*/
export function assistantActionsSeqs(nodes: readonly ConversationNode[]): ReadonlySet<number> {
export function assistantActionsSeqs(
nodes: readonly ConversationNode[],
turnEnds: ReadonlyMap<number, number>,
): ReadonlySet<number> {
const lastByTurn = new Map<number, number>()
for (const node of nodes) {
if (node.kind !== 'assistant' || !hasContentText(node.blocks)) continue
if (node.kind !== 'assistant' || !turnEnds.has(node.turn) || !hasContentText(node.blocks)) continue
lastByTurn.set(node.turn, node.seq)
}
return new Set(lastByTurn.values())
@@ -63,15 +75,18 @@ export function runningTurnStartTime(
}
/**
* Seq set of message rows that may fork: the last transcript node of a
* completed turn, when that node owns message chrome. A later tool, reasoning,
* error, or other transcript node leaves the earlier message's branch action
* unavailable because the Host would include the whole turn.
* Seq set of assistant answers that may fork: the completed turn's transcript
* tail, when that tail is the turn's own content-text assistant. A later tool,
* reasoning, error, or other transcript node leaves the answer's branch action
* unavailable because the Host would include the whole turn. User and steering
* bubbles carry no branch action at all: a fork at their seq cuts at the same
* `turn/end` as the answer's, so the affordance lives only under the settled
* answer.
* @param nodes - snapshot nodes in event order.
* @param turnEnds - completed turn boundaries retained from the event window.
* @returns Message seq values whose visible position matches the fork boundary.
* @returns Assistant seq values whose visible position matches the fork boundary.
*/
export function messageBranchSeqs(
export function assistantBranchSeqs(
nodes: readonly ConversationNode[],
turnEnds: ReadonlyMap<number, number>,
): ReadonlySet<number> {
@@ -86,8 +101,7 @@ export function messageBranchSeqs(
tail = candidate
nodeIndex++
}
if (tail?.kind === 'user' || tail?.kind === 'steering'
|| (tail?.kind === 'assistant' && tail.turn === turn && hasContentText(tail.blocks))) {
if (tail?.kind === 'assistant' && tail.turn === turn && hasContentText(tail.blocks)) {
result.add(tail.seq)
}
}
@@ -191,6 +191,14 @@
flex-direction: column;
min-height: 0;
overflow-y: auto;
/* The column scrolls on ONE axis. Stating `hidden` rather than leaving the
initial `visible` is what removes the horizontal bar: a box that scrolls in
one axis computes `visible` to `auto` in the other, so any bleed becomes
user-scrollable. `.heroGlow` bleeds by construction (1051/776 of the hero
box), which put a horizontal scrollbar under every center column narrower
than the glow. Clipping is unchanged — `overflow-y: auto` already made this
a scroll container that clips both axes, so this only takes away the bar. */
overflow-x: hidden;
/* Reserved unconditionally: the composer seat rides this box's content box in
Chat and its padding box under a view's composer overlay, so an `auto`
gutter moves the input card sideways by the bar's width whenever the two
@@ -17,7 +17,7 @@ import { useState, type KeyboardEvent } from 'react'
import type { Context } from 'cordis'
import clsx from 'clsx'
import {
IconApiOutline14, IconChevronDownOutline14, StateDot, TerminalBlock,
IconApiOutline14, IconChevronDownOutline14, IconInspectOutline12, StateDot, TerminalBlock,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { ToolRowProps } from '../contract/slots.ts'
@@ -153,9 +153,7 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }:
)}
{inspect !== undefined && (
<button type="button" className={css.inspectButton} onClick={inspect}>
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden>
<path d="M16 8L10.8571 12V10.552L14.1383 8L10.8571 5.448V4L16 8ZM5.14286 10.552L1.86171 8L5.14286 5.448V4L0 8L5.14286 12V10.552ZM9.02514 4L5.59657 12H6.84057L10.2691 4H9.02514Z" fill="currentColor" />
</svg>
<IconInspectOutline12 />
Inspect
</button>
)}
@@ -0,0 +1,60 @@
/**
* Pure plan derivation for the todo_write row's one-line summary. Several items
* may be `in_progress` at once — parallel work runs concurrent tasks, so a
* summary built from one active item would silently drop the rest. The plan
* strip header derives its own counts inline and shares nothing with this, so
* this stays inside the toolviews domain rather than in `contract/` (the
* inter-domain face).
* @module
*/
/**
* One list item as the row sees it: unvalidated model JSON parsed from a call's
* args, so any field may be missing or mistyped.
*/
export interface PlanItemLike {
content?: unknown
status?: unknown
}
/**
* Counts plus the two halves of the summary, deliberately NOT pre-joined: the
* row ellipsizes its summary text, and a count concatenated onto the end of the
* task name is the first thing a narrow row clips — exactly when it carries
* information. The row renders `activeExtra` in its own non-shrinking span
* beside the truncatable text.
*/
export interface PlanSummary {
done: number
total: number
/** First `in_progress` content, or null when that first item is unusable. */
activeContent: string | null
/** Active items beyond the first; 0 whenever there is no `activeContent` to sit beside. */
activeExtra: number
}
/**
* Derive the counts and the active summary from a whole-list snapshot. It names
* the first `in_progress` item and counts the remaining active ones, so a
* parallel plan reports how many tasks are running rather than naming one and
* hiding the others. `activeContent` is null when nothing is in progress, or
* when the first active item's content is missing, mistyped, or blank once
* trimmed — the tool's own rule for usable content, applied here because a
* rejected call keeps its args verbatim. The row then renders the counts alone
* rather than falling back to the generic tool summary: the counts are already
* known to be good, and the active-item clause is the only part an unusable
* name costs.
* @param todos - the whole list, in model order.
* @returns the done/total counts and the two summary halves.
*/
export function planSummary(todos: readonly PlanItemLike[]): PlanSummary {
const active = todos.filter(t => t.status === 'in_progress')
const first = active[0]?.content
const named = typeof first === 'string' && first.trim() !== ''
return {
done: todos.filter(t => t.status === 'completed').length,
total: todos.length,
activeContent: named ? first : null,
activeExtra: named ? active.length - 1 : 0,
}
}
@@ -2,9 +2,10 @@
// "Tool call" card, registered into the keyed 'conversation.chat.toolview'
// hole like the bash sample (a product registration, not a sample). The row
// composes ToolRow (chrome, running sweep, whole-row expand) and swaps in a
// summary of the written list (counts + active item) from the call args; the
// durable list itself renders in the TodoPanel above the composer, so the
// row stays one line until expanded.
// summary of the written list (counts + active items) from the call args, with
// the parallel-active count riding ToolRow's non-shrinking summary suffix so a
// narrow row never clips it; the durable list itself renders in the TodoPanel
// above the composer, so the row stays one line until expanded.
import { IconChecklistOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context } from 'cordis'
@@ -13,18 +14,26 @@ import type { ToolRowProps } from '../contract/slots.ts'
import { toolRowModel } from '../contract/tool-call-model.ts'
import { ToolRow } from '../chat/ToolRow.tsx'
import { NS } from '../locales.ts'
import { planSummary, type PlanItemLike } from './plan-summary.ts'
/** Todo row props: the toolview runtime share plus the standard locale seat. */
type TodoRowProps = ToolRowProps & PropsLocale<'conversation'>
/** One parsed args item, shape-checked (model JSON: any field may be missing or mistyped). */
interface TodoWriteItem { content?: unknown; status?: unknown }
function isItem(value: unknown): value is TodoWriteItem {
function isItem(value: unknown): value is PlanItemLike {
return typeof value === 'object' && value !== null
}
function summarize(argsRaw: string, t: TodoRowProps['t']): string | null {
/**
* The row's summary split at the ellipsis boundary: `text` truncates, `extra`
* is the parallel-active count that must not, so a narrow row never clips the
* one part that says several tasks are running.
*/
interface RowSummary {
text: string
extra: number
}
function summarize(argsRaw: string, t: TodoRowProps['t']): RowSummary | null {
let parsed: unknown
try {
parsed = JSON.parse(argsRaw)
@@ -37,12 +46,12 @@ function summarize(argsRaw: string, t: TodoRowProps['t']): string | null {
if (typeof parsed !== 'object' || parsed === null) return null
const todos = (parsed as { todos?: unknown }).todos
if (!Array.isArray(todos) || !todos.every(isItem)) return null
const done = todos.filter(item => item.status === 'completed').length
const active = todos.find(item => item.status === 'in_progress')
const head = t('todo.completed', { done, total: todos.length })
return typeof active?.content === 'string' && active.content !== ''
? `${head} · ${active.content}`
: head
const { done, total, activeContent, activeExtra } = planSummary(todos)
const head = t('todo.completed', { done, total })
return {
text: activeContent === null ? head : `${head} · ${activeContent}`,
extra: activeExtra,
}
}
/** One-line plan update row (the whole row toggles the call's Input/Output
@@ -52,7 +61,7 @@ function summarize(argsRaw: string, t: TodoRowProps['t']): string | null {
export function TodoRow({ toolName, block, inspect, t }: TodoRowProps) {
const model = toolRowModel(toolName, block)
const argsRaw = ('kind' in block ? block.call?.argsRaw : block.argsRaw) ?? ''
const summary = summarize(argsRaw, t) ?? model.summary
const summary = summarize(argsRaw, t) ?? { text: model.summary, extra: 0 }
return (
<ToolRow
t={t}
@@ -60,7 +69,8 @@ export function TodoRow({ toolName, block, inspect, t }: TodoRowProps) {
toolName={toolName}
icon={<IconChecklistOutline14 />}
title={t('todo.rowTitle')}
summary={summary}
summary={summary.text}
summarySuffix={summary.extra > 0 ? `+${summary.extra}` : null}
body={model.body}
output={model.output}
errorSummary={model.errorSummary}