Merge remote-tracking branch 'origin/master' into feat/scrollbar-tokens

This commit is contained in:
Chinesezjc
2026-07-28 18:15:00 +08:00
176 changed files with 4427 additions and 842 deletions
@@ -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: 56a445ccfa86e0b11cf5aefc37819a30746f0739
README.zh.md: a7c160ecdd74074257c9d149630663dacd05c070
README.md: a04c20f225c731581accbe8c12c52a5e7597029a
README.zh.md: f9e6a635ea6090c87a66f029785af214025b9bda
+2 -2
View File
@@ -8,9 +8,9 @@ The resident conversation shell survives no-session and session transitions. Wit
The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: <active id>`), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves.
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 `Write · <path>` or `Edit · <path>` summary while retaining the shared row-to-details interaction. 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), and the details panel resolves a selected sub-call id to its full logged args and complete output. 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 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.
Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders).
Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders).
The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`<done>/<total> 已完成 · <active item>` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1` — above the queue rows — and is the durable plan strip: it selects `todos` off the session snapshot and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and collapses to a header of title plus `"<done>/<total> tasks · <n> in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the persistent list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.
+2 -2
View File
@@ -8,9 +8,9 @@
视图环本身就是 slot:会话注册声明 `'conversation.view'` 列表 slotSession scope),并将其列在 `children` 表中;ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: <active id>`);视图标签页从环账本的注册选项(`id``order``label`)投影而来。聊天视图是该包自身的环配置项;其他插件(ui-trajectory)通过普通的 `ctx.slots.register` 贡献标签页。先前包内的视图注册表(`registerView``ViewEntry``ConversationViewMap` 及 chrome 附加表)已退役,逐视图 chrome 则被拆入视图组件自身。
通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和 `Write · <path>``Edit · <path>` 摘要,同时保留共享的行到详情交互。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行),details 面板则会根据选中的子调用 id 解析出其完整记录的参数与完整输出。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect``Mount temporary Plugin``Unmount temporary Plugin`mount 行保留 code 变体的可展开源码渲染。
通用工具行把内置的 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 变体的可展开源码渲染。
工具行同样是 slot:独立工具环(`ToolViewRegistry``ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps``callId``toolName``block``openDetails`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。
工具行同样是 slot:独立工具环(`ToolViewRegistry``ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps``callId``toolName``block``openFile`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']``TodoRow` 占用 `'conversation.chat.toolview'``todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock``order: -1` 占用 `'conversation.input.dock'` 列表 slot(位于队列行之上),是常驻的计划条:它从会话快照中选取 `todos` 并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏,折叠时收成标题加 `"<已完成>/<总数> tasks · <n> in progress"` 的表头(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;常驻列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。
@@ -49,6 +49,8 @@
},
"devDependencies": {
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-tool-todo": "workspace:^",
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
@@ -7,6 +7,7 @@ import type { ViewTab } from './contract/views.ts'
import type {
ChatViewInjected, ComposerBarInjected, ConversationInjected, ConversationSessionInjected, DetailsInjected,
} from './contract/slots.ts'
import { resolveToolPath } from './contract/tool-call-model.ts'
import { createChatStore } from './stores.ts'
import { ConversationService } from './service.ts'
import { InputHub } from './input/hub.ts'
@@ -158,7 +159,10 @@ export function apply(ctx: Context): void {
id: 'chat',
order: 0,
label: 'Chat',
children: { 'conversation.chat.toolview': { kind: 'keyed', scope: 'session' } },
children: {
'conversation.chat.toolview': { kind: 'keyed', scope: 'session' },
'conversation.chat.commandview': { kind: 'keyed', scope: 'session' },
},
store: chatStore,
inject: (sessionId: SessionId, actions: BoundActions<typeof chatStore>): ChatViewInjected => {
const scoped = scopedConversation(sessions, sessionId)
@@ -167,6 +171,13 @@ export function apply(ctx: Context): void {
actions.select(target)
layout.openDetails()
},
openFile: (path) => {
const cwd = sessions.list.getSnapshot().byId[sessionId]?.cwd
void workspaces.openPath(resolveToolPath(cwd, path)).catch(() => {
// Host/OS open failures stay silent in the chat row; the native
// app surfaces its own error dialog when the path is unusable.
})
},
loadOlder: () => { void scoped.loadOlder() },
}
},
@@ -20,14 +20,14 @@ import {
memo, useLayoutEffect, useMemo, useRef, useState, type ReactNode,
} from 'react'
import type {
CodeSubCall, ConversationNode, ConversationSnapshot, RunningToolCall, ToolResultNode,
CodeSubCall, CommandNode, ConversationNode, ConversationSnapshot, RunningToolCall, ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
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 type { SelectionTarget } from '../contract/views.ts'
import { deriveChatFlow, type ChatFlowItem } from './chat-flow.ts'
import { AssistantMarkdown } from './AssistantMarkdown.tsx'
import { GenericCommandCard } from './GenericCommandCard.tsx'
import { GenericToolCard } from './GenericToolCard.tsx'
import { MessageItem } from './MessageItem.tsx'
import { PendingCard } from './PendingCard.tsx'
@@ -36,7 +36,7 @@ import css from './ChatView.module.css'
const FOLLOW_THRESHOLD = 24
type OpenDetails = (target: SelectionTarget) => void
type OpenFile = (path: string) => void
/** The declared toolview hole's render share (stable framework binding, passed through memoized rows). */
type RenderToolRow = ChatViewSlotProps['renderSlot']
@@ -49,20 +49,18 @@ type UseConversation = SnapshotSelectorHook<ConversationSnapshot>
* top-level call (same registrations, same fallback), nested by the parent.
* A started-but-unsettled sub-call arrives as the RunningToolCall shape and
* renders the running state exactly as a native in-flight row. */
const SubCallRow = memo(function SubCallRow({ renderSlot, node, onOpenDetails, selected, cwd }: {
const SubCallRow = memo(function SubCallRow({ renderSlot, node, openFile, selected, cwd }: {
renderSlot: RenderToolRow
node: CodeSubCall
onOpenDetails: OpenDetails
openFile: OpenFile
selected: boolean
cwd: string | undefined
}) {
const settled = 'kind' in node
const toolName = settled ? node.call?.name ?? '' : node.name
const seq = settled ? node.seq : node.time
const owner = useMemo(() => ({
callId: node.callId, toolName, block: node, cwd,
openDetails: () => { onOpenDetails({ turnSeq: seq, callId: node.callId, toolName }) },
}), [node, toolName, seq, cwd, onOpenDetails])
callId: node.callId, toolName, block: node, openFile, cwd,
}), [node, toolName, openFile, cwd])
return (
<div className={css.callRow} data-selected={selected || undefined}>
{renderSlot('conversation.chat.toolview', owner, {
@@ -79,15 +77,13 @@ const SubCallRow = memo(function SubCallRow({ renderSlot, node, onOpenDetails, s
* renders its logged sub-dispatches as always-visible indented rows —
* each one the same keyed-slot dispatch as a native top-level call. */
const CallRow = memo(function CallRow({
renderSlot, callId, toolName, block, seq, onOpenDetails, selected, subCalls, selectedCallId, cwd,
renderSlot, callId, toolName, block, openFile, selected, subCalls, selectedCallId, cwd,
}: {
renderSlot: RenderToolRow
callId: string
toolName: string
block: ToolResultNode | RunningToolCall
/** Surface seq for finalized results; the call's turn for running calls. */
seq: number
onOpenDetails: OpenDetails
openFile: OpenFile
selected: boolean
/** `run_code` sub-dispatches in dispatch order (reference-stable per
* parent; running entries settle in place); undefined for ordinary calls. */
@@ -98,9 +94,8 @@ const CallRow = memo(function CallRow({
cwd: string | undefined
}) {
const owner = useMemo(() => ({
callId, toolName, block, cwd,
openDetails: () => { onOpenDetails({ turnSeq: seq, callId, toolName }) },
}), [callId, toolName, block, seq, cwd, onOpenDetails])
callId, toolName, block, openFile, cwd,
}), [callId, toolName, block, openFile, cwd])
return (
<div className={css.callRow} data-selected={selected || undefined}>
{renderSlot('conversation.chat.toolview', owner, {
@@ -114,7 +109,7 @@ const CallRow = memo(function CallRow({
key={node.callId}
renderSlot={renderSlot}
node={node}
onOpenDetails={onOpenDetails}
openFile={openFile}
selected={node.callId === selectedCallId}
cwd={cwd}
/>
@@ -126,10 +121,10 @@ const CallRow = memo(function CallRow({
})
/** Consecutive tool results as one step-run group (uniform 16px rhythm). */
const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails, selectedCallId, codeDispatches, cwd }: {
const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selectedCallId, codeDispatches, cwd }: {
renderSlot: RenderToolRow
results: readonly ToolResultNode[]
onOpenDetails: OpenDetails
openFile: OpenFile
/** Only set when the selected call lives in THIS group, top-level or nested (memo economy). */
selectedCallId: string | undefined
/** Sub-dispatch index off the snapshot (map reference is chunk-storm stable). */
@@ -146,8 +141,7 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails,
callId={node.callId}
toolName={node.call?.name ?? ''}
block={node}
seq={node.seq}
onOpenDetails={onOpenDetails}
openFile={openFile}
selected={node.callId === selectedCallId}
subCalls={codeDispatches.get(node.callId)}
selectedCallId={selectedCallId}
@@ -158,6 +152,24 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails,
)
})
/** One command lifecycle row: keyed dispatch on the command name with the
* generic card as the render-site fallback (zero registration required). A
* run-less cross-window node has no name and always lands on the fallback. */
const CommandRow = memo(function CommandRow({ renderSlot, node }: {
renderSlot: RenderToolRow
node: CommandNode
}) {
const owner = useMemo(() => ({ node }), [node])
return (
<div className={css.callRow}>
{renderSlot('conversation.chat.commandview', owner, {
entryKey: node.name ?? '',
fallback: <GenericCommandCard {...owner} />,
})}
</div>
)
})
/** Turn loader: one row of four 2.5px pixels (half a notch above the StateDot
* 2px cell, same blue) chasing left to right with a stepped trail — flat
* keyframe holds, no tweening, no rotation. Phase offsets come from
@@ -210,7 +222,7 @@ function StreamingTail({ useSession, onGrow }: {
* The chat view slot entry: pure component over the composed props (tool rows
* render through the declared keyed hole's renderSlot share).
*/
export function ChatView({ useSession, useSessions, useStore, renderSlot, sessionId, openDetails, loadOlder }: ChatViewSlotProps) {
export function ChatView({ useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder }: ChatViewSlotProps) {
const nodes = useSession(s => s.nodes)
// Workspace root off the session list row: path summaries display relative to it.
const cwd = useSessions(s => s.byId[sessionId]?.cwd)
@@ -311,7 +323,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
key={item.key}
renderSlot={renderSlot}
results={item.results}
onOpenDetails={openDetails}
openFile={openFile}
selectedCallId={inGroup ? selectedCallId : undefined}
codeDispatches={codeDispatches}
cwd={cwd}
@@ -322,6 +334,9 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
if (node.kind === 'assistant') {
return <AssistantMarkdown key={item.key} blocks={node.blocks} streaming={false} interrupted={node.interrupted} />
}
if (node.kind === 'command') {
return <CommandRow key={item.key} renderSlot={renderSlot} node={node} />
}
/* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */
if (node.kind === 'tool-result') return null
return <MessageItem key={item.key} node={node} />
@@ -351,8 +366,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
callId={call.callId}
toolName={call.name}
block={call}
seq={call.turn}
onOpenDetails={openDetails}
openFile={openFile}
selected={call.callId === selectedCallId}
subCalls={codeDispatches.get(call.callId)}
selectedCallId={selectedCallId}
@@ -0,0 +1,38 @@
// GenericCommandCard: the default command row — a stripped-down
// GenericToolCard rendering the dispatched command line and the settlement
// text. Supplied by the chat view as the keyed commandview slot's render-site
// fallback (an unregistered command name lands here); registrants may compose
// it as a base, feeding the same owner payload through.
import { ToolRow } from './ToolRow.tsx'
import type { ToolRowState } from '../contract/tool-call-model.ts'
import type { CommandRowOwnerProps } from '../contract/slots.ts'
import { IconApiOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
/** Node state → row state semantic (running while unsettled; outcome kind after). */
function stateOf(outcome: CommandRowOwnerProps['node']['outcome']): ToolRowState {
if (outcome === null) return 'running'
return outcome.kind === 'error' ? 'error' : 'ok'
}
export function GenericCommandCard({ node }: CommandRowOwnerProps) {
const text = node.outcome?.text
const summary = node.outcome === null
? '执行中…'
: text ?? (node.outcome.kind === 'error' ? '命令失败' : '已完成')
// Display line rebuilt from the structured payload (args carries its own
// separator whitespace verbatim); a cross-window node whose run page fell
// out of the window has neither.
const title = node.name === null ? '命令' : `/${node.name}${node.args ?? ''}`
return (
<ToolRow
variant="others"
icon={<IconApiOutline14 size={16} />}
title={title}
summary={summary}
// Expandable only when the outcome text overflows a one-line summary.
body={text !== undefined && text.includes('\n') ? text : null}
state={stateOf(node.outcome)}
/>
)
}
@@ -25,8 +25,9 @@ const VARIANT_ICONS: Record<ToolRowVariant, ReactNode> = {
others: <IconSparkle16 size={14} />,
}
export function GenericToolCard({ toolName, block, cwd, openDetails }: ToolRowOwnerProps) {
export function GenericToolCard({ toolName, block, cwd, openFile }: ToolRowOwnerProps) {
const model = toolRowModel(toolName, block, cwd)
const singleFile = model.filePath !== undefined
return (
<ToolRow
variant={model.variant}
@@ -34,9 +35,11 @@ export function GenericToolCard({ toolName, block, cwd, openDetails }: ToolRowOw
icon={VARIANT_ICONS[model.variant]}
title={model.title}
summary={model.summary}
body={model.body}
// Single-file tools never expose an args body — the path link is the only action.
body={singleFile ? null : model.body}
state={model.state}
onOpenDetails={openDetails}
filePath={model.filePath}
onOpenFile={singleFile ? openFile : undefined}
/>
)
}
@@ -41,10 +41,9 @@
90%, 100% { left: 100%; }
}
/* Clickable rows keep only the cursor affordance — no hover fill. */
.row[data-clickable] {
/* Expand-on-row (Think / code): pointer only — no row fill hover. */
.row[data-expandable] {
cursor: pointer;
border-radius: 6px;
}
.leading {
@@ -143,6 +142,29 @@ button.leading {
color: var(--dsw-alias-label-tertiary);
}
/* File-tool path: same geometry as .summary; hover underline + pointer. */
.fileLink {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin: 0;
padding: 0;
border: none;
background: none;
font: inherit;
text-align: left;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
.fileLink:hover {
text-decoration: underline;
}
/* Expanded body: pad-left 22 indented gray text, no border, no fill. */
.body {
padding: 4px 0 4px 22px;
@@ -2,9 +2,8 @@
// 16px leading slot (state dot / tool icon, chevron on hover or expanded) + title +
// separator dot + FILL-truncated summary. Expanded body is indented gray text;
// no inline output (full results live in the details panel). Expand state is
// component-local view state; row click hands the selection off to the owner.
// TODO(ux): converge every chat-tab tool row on in-place expansion for its
// expandable content, retiring the details-panel handoff where feasible.
// component-local view state. File-tool summaries are path links that open
// through the host; the row itself is not a details-panel control.
import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
import clsx from 'clsx'
@@ -26,8 +25,13 @@ export interface ToolRowProps {
state: ToolRowState
/** Makes the row itself the expand control instead of only its leading icon. */
expandOnRowClick?: boolean | undefined
/** Selection handoff (row click), already bound to this call by the owner. */
onOpenDetails?: (() => void) | undefined
/**
* Filesystem path from tool args; when set with onOpenFile, the summary
* renders as a hover-underline link that opens the host default app.
*/
filePath?: string | undefined
/** Open the path with the host OS default application (already cwd-resolved). */
onOpenFile?: ((path: string) => void) | undefined
}
/** Leading-slot state substitution: the tool icon yields to the terminal state
@@ -50,10 +54,15 @@ export function ToolRow({
body,
state,
expandOnRowClick = false,
onOpenDetails,
filePath,
onOpenFile,
}: ToolRowProps) {
const [expanded, setExpanded] = useState(false)
const expandable = body !== null
// A row that names a single file keeps one interaction (open that path);
// args expand is off whether or not the open callback is wired yet.
const singleFile = filePath !== undefined
const fileLink = singleFile && onOpenFile !== undefined
const expandable = body !== null && !singleFile
const open = expanded && expandable
const rowExpands = expandable && expandOnRowClick
const toggleExpand = () => {
@@ -68,6 +77,10 @@ export function ToolRow({
event.preventDefault()
toggleExpand()
}
const openFile = (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation()
if (filePath !== undefined) onOpenFile?.(filePath)
}
// Expandable rows preview the toggle on hover: the tool icon yields to a
// down chevron (CSS swap on .row:hover); state dots still take precedence.
const collapsedIcon = expandable
@@ -85,11 +98,11 @@ export function ToolRow({
<div className={css.root} data-variant={variant} data-tool={toolName} data-state={state}>
<div
className={css.row}
data-clickable={rowExpands || onOpenDetails !== undefined || undefined}
data-expandable={rowExpands || undefined}
role={rowExpands ? 'button' : undefined}
tabIndex={rowExpands ? 0 : undefined}
aria-expanded={rowExpands ? open : undefined}
onClick={rowExpands ? toggleExpand : onOpenDetails}
onClick={rowExpands ? toggleExpand : undefined}
onKeyDown={rowExpands ? toggleFromKeyboard : undefined}
>
{expandable && !rowExpands ? (
@@ -110,7 +123,17 @@ export function ToolRow({
{!open && (
<>
<span className={css.sep} aria-hidden />
<span className={css.summary}>{summary}</span>
{fileLink ? (
<button
type="button"
className={css.fileLink}
onClick={openFile}
>
{summary}
</button>
) : (
<span className={css.summary}>{summary}</span>
)}
</>
)}
</div>
@@ -3,7 +3,7 @@ import type { ReactNode, RefObject } from 'react'
import type {
InjectFace, MaybeSnapshotSelectorHook, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook,
} from '@deepseek-ai/dsh-client-ui-slots'
import type { ConversationSnapshot, ObservableSnapshot, PendingInteraction, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type { CommandNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import type { ComposerKeyboard, InputActions, InputNotice, InputState } from '../input/contract.ts'
import type { createChatStore } from '../stores.ts'
@@ -33,6 +33,15 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
* `fallback` for unregistered tools.
*/
'conversation.chat.toolview': { kind: 'keyed'; scope: 'session'; owner: ToolRowOwnerProps }
/**
* The chat view's per-command row hole: keyed dispatch on the command
* name (`command/run.name`; a run-less cross-window node has none and
* always lands on the fallback). Declared by the chat view entry; the
* render site dispatches via `entryKey: name` with GenericCommandCard as
* the `fallback` — a slash command renders durably with zero
* registration, and a domain upgrades by registering one row component.
*/
'conversation.chat.commandview': { kind: 'keyed'; scope: 'session'; owner: CommandRowOwnerProps }
/**
* The composer takeover chain: entries are selector-routed replacements
* of the default InputBar. Declared by this package's 'conversation'
@@ -145,8 +154,11 @@ export interface ToolRowOwnerProps {
block: ToolCallBlock
/** Session workspace root; path summaries display relative to it. */
cwd?: string | undefined
/** Open the details panel for this call (session-level facility, supplied by the view). */
openDetails: () => void
/**
* Open a tool-arg filesystem path with the host OS default application.
* The chat view resolves relative paths against the session cwd.
*/
openFile: (path: string) => void
}
/**
@@ -158,6 +170,22 @@ export interface ToolRowOwnerProps {
*/
export type ToolRowProps = PropsRuntime<'conversation.chat.toolview'>
/**
* Owner share of the per-command row slot: the frozen {@link CommandNode}
* slice off the snapshot (cache-stable reference — memo premise). The node
* carries the whole lifecycle (structured name/args, pairing id,
* outcome-or-executing), so a
* registrant needs no second data channel; domain state arrives through its
* own projection cell.
*/
export interface CommandRowOwnerProps {
/** Folded command lifecycle node (run + optional done). */
node: CommandNode
}
/** Full props of a registered command-row component (same shape rule as {@link ToolRowProps}). */
export type CommandRowProps = PropsRuntime<'conversation.chat.commandview'>
/**
* Base props of a conversation view entry: the framework standard kit for the
* session-scope 'conversation.view' slot (useSession narrowed to the
@@ -285,12 +313,17 @@ export type ConversationSessionSlotProps =
export interface ChatViewInjected {
/** Selection write + details panel opening in one gesture (store action + layout orchestration). */
openDetails: (target: SelectionTarget) => void
/**
* Open a tool-arg filesystem path with the host OS default application
* (relative paths resolve against the session cwd).
*/
openFile: (path: string) => void
loadOlder: () => void
}
/** Full chat-view component props: runtime share & the declared toolview hole's render share & store share & injected share. */
/** Full chat-view component props: runtime share & the declared toolview/commandview holes' render share & store share & injected share. */
export type ChatViewSlotProps =
PropsRuntime<'conversation.view'> & PropsRenderSlots<'conversation.chat.toolview'>
PropsRuntime<'conversation.view'> & PropsRenderSlots<'conversation.chat.toolview' | 'conversation.chat.commandview'>
& PropsStore<ChatStore> & ChatViewInjected
/**
@@ -62,6 +62,12 @@ export interface ToolRowModel {
variant: ToolRowVariant
title: string
summary: string
/**
* Filesystem path from args (`path` / `file_path`) when the row is a file
* tool; absent for URL reads and non-file tools. The chat view resolves
* relative values against the session cwd before opening.
*/
filePath: string | undefined
/** Expanded-body text (pretty args); null = row not expandable. */
body: string | null
state: ToolRowState
@@ -121,6 +127,35 @@ function deriveSummary(variant: ToolRowVariant, argsRaw: string): string {
return firstLine(argsRaw)
}
/** Path keys only — never `url` (web_fetch lands on the read variant). */
const FILE_PATH_KEYS = ['path', 'file_path'] as const
/** File-tool variants whose summary may be an openable workspace path. */
const FILE_PATH_VARIANTS: ReadonlySet<ToolRowVariant> = new Set(['read', 'write', 'edit'])
function deriveFilePath(variant: ToolRowVariant, argsRaw: string): string | undefined {
if (!FILE_PATH_VARIANTS.has(variant)) return undefined
const parsed = parseArgs(argsRaw)
if (typeof parsed !== 'object' || parsed === null) return undefined
const picked = pickString(parsed as Record<string, unknown>, FILE_PATH_KEYS)
return picked === undefined ? undefined : firstLine(picked)
}
/**
* Resolve a tool-arg path against the session cwd for host.openPath.
* Absolute POSIX/Windows paths pass through; relative paths join under cwd.
* @param cwd - session working directory (may be absent for ungrouped sessions).
* @param path - path as carried in tool args.
* @returns a host-facing path string.
*/
export function resolveToolPath(cwd: string | undefined, path: string): string {
if (path.startsWith('/') || /^[A-Za-z]:[/\\]/.test(path) || path.startsWith('\\\\')) return path
if (cwd === undefined || cwd === '') return path
const base = cwd.replace(/[/\\]+$/, '')
const rel = path.replace(/^[/\\]+/, '')
return `${base}/${rel}`
}
function deriveBody(variant: ToolRowVariant, argsRaw: string): string | null {
if (argsRaw === '') return null
const parsed = parseArgs(argsRaw)
@@ -159,6 +194,7 @@ export function toolRowModel(toolName: string, block: ToolCallBlock, cwd?: strin
variant,
title: toolTitle ?? VARIANT_TITLES[variant],
summary,
filePath: deriveFilePath(variant, argsRaw),
body: deriveBody(variant, argsRaw),
state,
}
@@ -13,7 +13,8 @@ export type {
} from './contract/views.ts'
export type { ToolCallBlock } from './contract/tool-call-model.ts'
export type {
ChatStore, ChatViewInjected, ChatViewSlotProps, ComposerBarInjected, ComposerChainProps, ConversationInjected,
ChatStore, ChatViewInjected, ChatViewSlotProps, CommandRowOwnerProps, CommandRowProps, ComposerBarInjected,
ComposerChainProps, ConversationInjected,
ConversationSessionInjected, ConversationSlotProps, ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps,
EmptyWorkspaceOwnerProps, ToolRowOwnerProps, ToolRowProps,
} from './contract/slots.ts'
@@ -8,7 +8,11 @@
import { useId, useState } from 'react'
import type { Context } from 'cordis'
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { TodoItem } from '@deepseek-ai/dsh-client-runtime/client'
// The domain's client-namespace pure-type outlet: one import edge delivers
// the `todos` projection-key merge (single source, no consumer-side restated
// declare) and the payload type. Type-only by construction — the outlet is
// free of host value imports, so no host Context merge enters this program.
import type { TodoItem } from '@deepseek-ai/dsh-tool-todo/client'
import { IconChevronDownOutline14, IconChevronUpOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import css from './TodoPanel.module.css'
@@ -115,10 +119,10 @@ export function TodoPanel({ todos }: TodoPanelProps) {
/** Full props of a dock entry: InputZone owner share + session standard kit + global seat. */
export type TodoDockProps = PropsRuntime<'conversation.input.dock'>
/** Dock adapter: selects the plan off the session snapshot and hands the strip a plain list. */
export function TodoDock({ useSession }: TodoDockProps) {
const todos = useSession(s => s.todos)
return <TodoPanel todos={todos} />
/** Dock adapter: reads the host-computed 'todos' projection (whole list; absent or null renders nothing). */
export function TodoDock({ useProjection }: TodoDockProps) {
const todos = useProjection('todos')
return <TodoPanel todos={todos ?? []} />
}
/**
@@ -7,8 +7,6 @@
align-items: center;
height: 24px;
min-width: 0;
cursor: pointer;
border-radius: 6px;
}
/* Running sweep glare — same deepsuite ShimmerText pattern as ToolRow. */
@@ -30,7 +30,7 @@ function stateStatus(state: ToolRowState): string | null {
}
/** Bash row: icon + Bash · {description}, matching the shared ToolRow chrome. */
export function BashRow({ toolName, block, openDetails, sessionId, useSessions }: ToolRowProps) {
export function BashRow({ toolName, block, sessionId, useSessions }: ToolRowProps) {
const model = toolRowModel(toolName, block)
const isChild = useSessions(list => list.byId[sessionId]?.parentId !== undefined)
const status = stateStatus(model.state)
@@ -40,8 +40,6 @@ export function BashRow({ toolName, block, openDetails, sessionId, useSessions }
data-sample={isChild ? 'bash-scoped' : 'bash-global'}
data-variant="bash"
data-state={model.state}
data-clickable
onClick={openDetails}
>
<span className={css.leading}>{leadingFor(model.state)}</span>
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
@@ -6,8 +6,6 @@
align-items: center;
height: 24px;
min-width: 0;
cursor: pointer;
border-radius: 6px;
}
.leading {
@@ -5,7 +5,6 @@
// durable list itself renders in the TodoPanel above the composer, so the
// row stays one line. Chrome matches ToolRow (figma 780:53675).
import type { KeyboardEvent } from 'react'
import type { Context } from 'cordis'
import { IconChecklistOutline16, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolRowProps } from '../contract/slots.ts'
@@ -51,29 +50,18 @@ function leadingFor(state: ToolRowState) {
}
}
/** One-line plan update row (click opens the raw args in details). Non-ok
* execution states keep the generic row's dot semantics — a cancelled call
* wrote no todo/write, so it must not read as a completed update. */
export function TodoRow({ toolName, block, openDetails }: ToolRowProps) {
/** One-line plan update row. Non-ok execution states keep the generic row's
* dot semantics — a cancelled call wrote no todo/write, so it must not read
* as a completed update. */
export function TodoRow({ toolName, block }: ToolRowProps) {
const model = toolRowModel(toolName, block)
const argsRaw = ('kind' in block ? block.call?.argsRaw : block.argsRaw) ?? ''
const summary = summarize(argsRaw) ?? model.summary
// Button semantics, not a <button>: the row carries inline spans a button
// would flatten, and ToolRow takes the same role/tabIndex/Enter-Space route.
const openFromKeyboard = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault()
openDetails()
}
return (
<div
className={css.row}
data-sample="todo-row"
data-state={model.state}
role="button"
tabIndex={0}
onClick={openDetails}
onKeyDown={openFromKeyboard}
>
<span className={css.leading} aria-hidden>{leadingFor(model.state)}</span>
<span className={css.title}></span>
@@ -107,6 +107,7 @@ async function bench() {
const workspacesFake = {
list: workspaceStore,
connectWorkspace: vi.fn(async () => ROOT),
openPath: vi.fn(async () => {}),
}
ctx.provide('workspaces', workspacesFake)
const layoutFake = { openDetails: vi.fn(), closeDetails: vi.fn() }
@@ -259,6 +260,15 @@ describe('conversation slot inject surface', () => {
expect(conv.instance).toBe(instance)
})
it('openFile (chat view face) resolves against session cwd and calls workspaces.openPath', async () => {
const b = await bench()
const { injected } = b.chatViewSurface(ROOT)
injected.openFile('src/a.ts')
await vi.waitFor(() => {
expect(b.workspacesFake.openPath).toHaveBeenCalledWith('/proj/src/a.ts')
})
})
it('routes navigation and workspace switching through the runtime owners, carrying the draft', async () => {
const b = await bench()
const { injected } = b.conversationSurface(ROOT)
@@ -48,6 +48,7 @@ async function bench() {
ctx.provide('workspaces', {
startSession: vi.fn(),
sendSession: vi.fn(),
openPath: vi.fn(async () => {}),
})
ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
ctx.provide('locale', { bind: () => (key: string) => key })
@@ -5,7 +5,7 @@
// always-visible nested rows through the SAME keyed toolview hole — the bash
// sub-call lands in the bash sample plugin's registration exactly like a
// top-level bash row, unregistered sub-tools fall back to GenericToolCard —
// and a sub-row click opens details for the sub-callId. Running parents
// and a file sub-row click opens the host path. Running parents
// (runningCalls) nest their so-far dispatches the same way.
import { Context } from 'cordis'
@@ -56,7 +56,7 @@ function snapshotWith(
): ConversationSnapshot {
return {
sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls, codeDispatches,
pending: [], queue: [], todos: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false,
pending: [], queue: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false,
openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
}
@@ -114,14 +114,16 @@ async function bench(snapshot: ConversationSnapshot) {
open: vi.fn(),
}
ctx.provide('sessions', sessionsFake)
ctx.provide('workspaces', {
const workspaces = {
list: createSnapshotStore<WorkspaceListState>({
items: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
}),
startSession: vi.fn(),
sendSession: vi.fn(),
})
openPath: vi.fn(async () => {}),
}
ctx.provide('workspaces', workspaces)
ctx.provide('layout', layout)
ctx.provide('i18n', { bind: () => (key: string) => key })
@@ -136,7 +138,7 @@ async function bench(snapshot: ConversationSnapshot) {
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
return { ctx, slots, fiber, session, layout }
return { ctx, slots, fiber, session, layout, workspaces }
}
function mountApp(slots: SlotsService) {
@@ -220,15 +222,21 @@ describe('run_code sub-calls through the real chat machinery', () => {
expect(nested).not.toBeNull()
})
it('a sub-row click opens details for the sub-callId', async () => {
it('a file sub-row click opens the host path; bash sub-rows do not open details', async () => {
const parent = 'call-64'
const dispatches = new Map([[parent, [
subCall(11, parent, 1, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt'),
subCall(11, parent, 1, 'read', { path: 'notes/demo.txt' }, 'ok'),
subCall(12, parent, 2, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt'),
]]])
const b = await bench(snapshotWith([codeResult(10, parent)], dispatches))
const view = mountApp(b.slots)
view.getByText('notes/demo.txt').click()
expect(b.layout.openDetails).not.toHaveBeenCalled()
await vi.waitFor(() => {
expect(b.workspaces.openPath).toHaveBeenCalledWith('notes/demo.txt')
})
view.getByText('List notes').click()
expect(b.layout.openDetails).toHaveBeenCalledTimes(1)
expect(b.layout.openDetails).not.toHaveBeenCalled()
})
it('a RUNNING run_code call nests its so-far dispatches under the spinner row', async () => {
@@ -5,7 +5,7 @@
// standard useSessions kit (no registry predicates — tool ring dissolved).
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { act, cleanup, render } from '@testing-library/react'
import type {
AssistantMessageNode, ConversationSnapshot, SessionId, SessionListState, ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
@@ -27,7 +27,7 @@ const assistant = (seq: number, turn: number, usage?: unknown): AssistantMessage
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
}
}
@@ -133,10 +133,9 @@ describe('bash sample row', () => {
const rowProps = (sessionId: SessionId, over?: {
store?: ReturnType<typeof listStore>
openDetails?: () => void
}): ToolRowProps => ({
callId: 'c1', toolName: 'bash', block: result('c1'),
openDetails: over?.openDetails ?? vi.fn(),
openFile: vi.fn(),
sessionId,
useSessions: bindSnapshotSelector(over?.store ?? listStore()),
} as unknown as ToolRowProps)
@@ -169,21 +168,17 @@ describe('bash sample row', () => {
expect(view.container.querySelector('[data-sample="bash-scoped"]')).not.toBeNull()
})
it('summarizes as Bash · description and hands clicks to openDetails on both arms', () => {
const openGlobal = vi.fn()
const global = render(<BashRow {...rowProps(ROOT, { openDetails: openGlobal })} />)
it('summarizes as Bash · description on both arms without row click targets', () => {
const global = render(<BashRow {...rowProps(ROOT)} />)
// Two renders share document.body: query inside each container.
const globalRow = global.container.querySelector('[data-sample="bash-global"]')!
expect(globalRow.textContent).toContain('Bash')
expect(globalRow.textContent).toContain('Build')
fireEvent.click(globalRow)
expect(openGlobal).toHaveBeenCalledTimes(1)
const openScoped = vi.fn()
const scoped = render(<BashRow {...rowProps(CHILD, { openDetails: openScoped })} />)
expect(globalRow.getAttribute('data-clickable')).toBeNull()
const scoped = render(<BashRow {...rowProps(CHILD)} />)
const scopedRow = scoped.container.querySelector('[data-sample="bash-scoped"]')!
expect(scopedRow.textContent).toContain('Bash')
expect(scopedRow.textContent).toContain('Build')
fireEvent.click(scopedRow)
expect(openScoped).toHaveBeenCalledTimes(1)
expect(scopedRow.getAttribute('data-clickable')).toBeNull()
})
})
@@ -4,7 +4,7 @@ import { cleanup, fireEvent, render } from '@testing-library/react'
afterEach(cleanup)
import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import { classifyTool, toolRowModel } from '../src/client/contract/tool-call-model.ts'
import { classifyTool, resolveToolPath, toolRowModel } from '../src/client/contract/tool-call-model.ts'
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
import { ToolRow } from '../src/client/chat/ToolRow.tsx'
import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
@@ -64,6 +64,22 @@ describe('tool-call-model', () => {
expect(toolRowModel('', running({ argsRaw: '' })).summary).toBe('c1')
})
it('exposes filePath for path/file_path args and skips URL-only reads', () => {
expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"src/a.ts"}' })).filePath).toBe('src/a.ts')
expect(toolRowModel('write', running({ name: 'write', argsRaw: '{"file_path":"src/a.ts"}' })).filePath).toBe('src/a.ts')
expect(toolRowModel('edit', running({ name: 'edit', argsRaw: '{"file_path":"src/a.ts"}' })).filePath).toBe('src/a.ts')
expect(toolRowModel('web_fetch', running({ name: 'web_fetch', argsRaw: '{"url":"https://example.com"}' })).filePath)
.toBeUndefined()
expect(toolRowModel('bash', running()).filePath).toBeUndefined()
})
it('resolveToolPath joins relative paths under cwd and passes absolute through', () => {
expect(resolveToolPath('/w', 'src/a.ts')).toBe('/w/src/a.ts')
expect(resolveToolPath('/w/', '/abs/a.ts')).toBe('/abs/a.ts')
expect(resolveToolPath(undefined, 'src/a.ts')).toBe('src/a.ts')
expect(resolveToolPath('/w', 'C:\\x\\a.ts')).toBe('C:\\x\\a.ts')
})
it('displays workspace-rooted paths relative to the session cwd', () => {
const cwd = '/Users/u/ws/'
expect(toolRowModel('edit', running({ name: 'edit', argsRaw: '{"file_path":"/Users/u/ws/src/x.ts"}' }), cwd).summary).toBe('src/x.ts')
@@ -149,13 +165,34 @@ describe('ToolRow', () => {
expect(view.queryByTestId('tool-icon')).not.toBeNull()
})
it('row click hands off to onOpenDetails; the expand toggle does not', () => {
it('file-path summary opens through onOpenFile; the leading slot is not an expand control', () => {
const open = vi.fn()
const view = render(<ToolRow {...rowProps} onOpenDetails={open} />)
const view = render(
<ToolRow {...rowProps} variant="read" title="Read" summary="src/a.ts" filePath="src/a.ts" onOpenFile={open} />,
)
fireEvent.click(view.getByText('src/a.ts'))
expect(open).toHaveBeenCalledWith('src/a.ts')
// Only the path link is a button — no args-expand affordance on file rows.
expect(view.container.querySelectorAll('button')).toHaveLength(1)
expect(view.container.querySelector('[aria-expanded]')).toBeNull()
expect(view.queryByText(/"a": 1/)).toBeNull()
})
it('a single-file path disables expand even when onOpenFile is absent', () => {
const view = render(
<ToolRow {...rowProps} variant="write" title="Write" summary="作文.md" filePath="作文.md" />,
)
expect(view.container.querySelector('button')).toBeNull()
expect(view.container.querySelector('[aria-expanded]')).toBeNull()
fireEvent.click(view.getByText('作文.md'))
expect(view.queryByText(/"a": 1/)).toBeNull()
})
it('non-file rows do not open anything when the summary is clicked', () => {
const open = vi.fn()
const view = render(<ToolRow {...rowProps} onOpenFile={open} />)
fireEvent.click(view.getByText('List files'))
expect(open).toHaveBeenCalledTimes(1)
fireEvent.click(view.container.querySelector('button')!)
expect(open).toHaveBeenCalledTimes(1)
expect(open).not.toHaveBeenCalled()
})
})
@@ -180,7 +217,7 @@ describe('ThinkRow', () => {
describe('GenericToolCard', () => {
const props = (toolName: string, block: RunningToolCall | ToolResultNode): ToolRowOwnerProps => ({
callId: 'c1', toolName, block, openDetails: vi.fn(),
callId: 'c1', toolName, block, openFile: vi.fn(),
})
it('renders the classified variant row from the frozen slice', () => {
@@ -225,10 +262,15 @@ describe('GenericToolCard', () => {
expect(view.container.querySelector('svg')).not.toBeNull()
})
it('row click reaches openDetails', () => {
const p = props('bash', result())
const view = render(<GenericToolCard {...p} />)
fireEvent.click(view.getByText('List files'))
expect(p.openDetails).toHaveBeenCalledTimes(1)
it('file-path summary click reaches openFile; bash summary does not', () => {
const file = props('read', running({ name: 'read', argsRaw: '{"path":"src/x.ts"}' }))
const fileView = render(<GenericToolCard {...file} />)
fireEvent.click(fileView.getByText('src/x.ts'))
expect(file.openFile).toHaveBeenCalledWith('src/x.ts')
const bash = props('bash', result())
const bashView = render(<GenericToolCard {...bash} />)
fireEvent.click(bashView.getByText('List files'))
expect(bash.openFile).not.toHaveBeenCalled()
})
})
@@ -43,7 +43,7 @@ const toolResult = (seq: number, callId: string, name: string, args = '{"command
function snapshotWith(nodes: ToolResultNode[]): ConversationSnapshot {
return {
sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
}
}
@@ -121,14 +121,16 @@ async function bench(nodes: ToolResultNode[]) {
open: vi.fn(),
updateIntent: vi.fn(),
})
ctx.provide('workspaces', {
const workspaces = {
list: createSnapshotStore<WorkspaceListState>({
items: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
}),
startSession: vi.fn(),
sendSession: vi.fn(),
})
openPath: vi.fn(async () => {}),
}
ctx.provide('workspaces', workspaces)
ctx.provide('layout', layout)
ctx.provide('locale', { bind: () => (key: string) => key })
@@ -143,7 +145,7 @@ async function bench(nodes: ToolResultNode[]) {
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
return { ctx, slots, fiber, session, list, layout }
return { ctx, slots, fiber, session, list, layout, workspaces }
}
/** Render the whole tree through the ctx-level root seam (the shell's own entry). */
@@ -186,11 +188,22 @@ describe('keyed toolview hole through the real machinery', () => {
expect(mounted!.querySelector('pre.shiki')?.textContent).toBe(code)
})
it('row clicks travel owner openDetails → chat inject → layout orchestration', async () => {
it('file-path clicks travel owner openFile → chat inject → workspaces.openPath', async () => {
const b = await bench([toolResult(3, 'c1', 'read', '{"path":"src/a.ts"}')])
const view = mountApp(b.slots)
view.getByText('src/a.ts').click()
expect(b.layout.openDetails).not.toHaveBeenCalled()
await vi.waitFor(() => {
expect(b.workspaces.openPath).toHaveBeenCalledWith('src/a.ts')
})
})
it('bash summary clicks do not open details or host paths', async () => {
const b = await bench([toolResult(3, 'c1', 'bash')])
const view = mountApp(b.slots)
view.getByText('Build').click()
expect(b.layout.openDetails).toHaveBeenCalledTimes(1)
expect(b.layout.openDetails).not.toHaveBeenCalled()
expect(b.workspaces.openPath).not.toHaveBeenCalled()
})
it('a live keyed registration takes over its tool row and unload reverts to the fallback', async () => {
@@ -271,6 +284,7 @@ describe('registrant load-order seam', () => {
}),
startSession: vi.fn(),
sendSession: vi.fn(),
openPath: vi.fn(async () => {}),
})
ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
ctx.provide('locale', { bind: () => (key: string) => key })
@@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Profiler } from 'react'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import type {
AssistantMessageNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId,
AssistantMessageNode, CommandNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId,
SessionListState, ToolResultNode, UserMessageNode, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
@@ -30,7 +30,7 @@ const SID = 's1' as SessionId
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
}
}
@@ -88,6 +88,7 @@ function emptyWorkspaces() {
function makeHarness(init?: Partial<ConversationSnapshot>) {
const { set, source } = makeSource(init)
const openDetails = vi.fn<(t: SelectionTarget) => void>()
const openFile = vi.fn<(path: string) => void>()
const loadOlder = vi.fn()
// Selection rides the REAL chat store (same construction path as
// production; the view reads it through the PropsStore useStore share).
@@ -105,6 +106,7 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
useSession: bindSnapshotSelector(source),
useSessions: emptySessions(),
useWorkspaces: emptyWorkspaces(),
useProjection: (() => undefined),
useInput: (() => { throw new Error('unused') }),
inputActions: { setDraft: () => {}, submit: () => {} },
useStore: bindSnapshotSelector(chat),
@@ -112,10 +114,11 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
renderSlot,
SessionProvider: SessionProviderStub,
openDetails,
openFile,
loadOlder,
}
const setSelection = (next: SelectionTarget | null): void => { chat.actions.select(next) }
return { set, ChatView, props, openDetails, loadOlder, setSelection }
return { set, ChatView, props, openDetails, openFile, loadOlder, setSelection }
}
describe('chat-flow derivation', () => {
@@ -281,16 +284,31 @@ describe('ChatView', () => {
expect(view.getByText(/"command": "cmd-a"/)).toBeTruthy()
})
it('clicking a tool row opens details with callId and toolName; selection marks data-selected', () => {
it('clicking a bash summary does not open details; selection still marks data-selected', () => {
const h = makeHarness({ nodes: [toolResult(3, 'a')] })
const view = render(<h.ChatView {...h.props} />)
fireEvent.click(view.getByText('run a'))
expect(h.openDetails).toHaveBeenCalledWith({ turnSeq: 3, callId: 'a', toolName: 'bash' })
expect(h.openDetails).not.toHaveBeenCalled()
expect(h.openFile).not.toHaveBeenCalled()
expect(view.container.querySelector('[data-selected]')).toBeNull()
act(() => { h.setSelection({ turnSeq: 3, callId: 'a', toolName: 'bash' }) })
expect(view.container.querySelector('[data-selected]')).not.toBeNull()
})
it('clicking a file-tool path summary opens the host file, not details', () => {
const h = makeHarness({
nodes: [{
kind: 'tool-result', seq: 3, time: 3_000, callId: 'r1',
call: { name: 'read', argsRaw: '{"path":"src/a.ts"}' },
callTime: 2_500, content: [], isError: false, callView: null, resultView: null,
}],
})
const view = render(<h.ChatView {...h.props} />)
fireEvent.click(view.getByText('src/a.ts'))
expect(h.openFile).toHaveBeenCalledWith('src/a.ts')
expect(h.openDetails).not.toHaveBeenCalled()
})
it('running calls render as a live tool group with the running state', () => {
const h = makeHarness({ runningCalls: [runningCall('r1')], running: true })
const view = render(<h.ChatView {...h.props} />)
@@ -378,4 +396,41 @@ describe('ChatView', () => {
const view = render(<h.ChatView {...h.props} />)
expect(view.getByText(/等待审批/)).toBeTruthy()
})
it('renders command nodes as durable rows: settled text, error state, executing spinner, run-less soft-fall', () => {
const command = (over: Partial<CommandNode>): CommandNode => ({
kind: 'command', seq: 5, time: 5_000, commandId: 'cmd-1' as CommandNode['commandId'],
name: 'plan', args: '', outcome: { kind: 'success', text: '已进入 plan mode' },
...over,
})
// Settled success: the command line is the title, the outcome text the summary.
const settled = makeHarness({ nodes: [user(1, 'hi'), command({})] })
const view = render(<settled.ChatView {...settled.props} />)
expect(view.getByText('/plan')).toBeTruthy()
expect(view.getByText('已进入 plan mode')).toBeTruthy()
// Error outcome flips the row state; a text-less error gets the default copy.
const failed = makeHarness({
nodes: [command({ seq: 6, commandId: 'cmd-2' as CommandNode['commandId'], outcome: { kind: 'error' } })],
})
const fv = render(<failed.ChatView {...failed.props} />)
expect(fv.container.querySelector('[data-state="error"]')).not.toBeNull()
expect(fv.getByText('命令失败')).toBeTruthy()
// Still executing: running state with the executing copy.
const executing = makeHarness({
nodes: [command({ seq: 7, commandId: 'cmd-3' as CommandNode['commandId'], outcome: null })],
})
const xv = render(<executing.ChatView {...executing.props} />)
expect(xv.container.querySelector('[data-state="running"]')).not.toBeNull()
expect(xv.getByText('执行中…')).toBeTruthy()
// Cross-window soft-fall (run page truncated): generic title, outcome preserved.
const orphan = makeHarness({
nodes: [command({ seq: 8, commandId: 'cmd-4' as CommandNode['commandId'], name: null, args: null, outcome: { kind: 'success' } })],
})
const ov = render(<orphan.ChatView {...orphan.props} />)
expect(ov.getByText('命令')).toBeTruthy()
expect(ov.getByText('已完成')).toBeTruthy()
})
})
@@ -82,7 +82,7 @@ describe('tails', () => {
content: [], isError: false, callView: null, resultView: null,
}
const props: ToolRowOwnerProps = {
callId: 'c5', toolName: 'todo_write', block: settled, openDetails: vi.fn(),
callId: 'c5', toolName: 'todo_write', block: settled, openFile: vi.fn(),
}
const view = render(<GenericToolCard {...props} />)
// Settled ok state keeps the variant icon (sparkle) instead of a StateDot.
@@ -99,7 +99,7 @@ describe('tails', () => {
phase: 'ready',
})
const props = (block: RunningToolCall | ToolResultNode) => ({
callId: 'c1', toolName: 'bash', block, openDetails: vi.fn(),
callId: 'c1', toolName: 'bash', block, openFile: vi.fn(),
sessionId: sid, useSessions: bindSnapshotSelector(list),
} as unknown as ToolRowProps)
@@ -19,7 +19,7 @@ const SID = 's1' as SessionId
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
}
}
@@ -76,6 +76,7 @@ describe('render branch tails', () => {
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} })}
useSessions={bindSnapshotSelector(emptyList)}
useWorkspaces={bindSnapshotSelector(emptyWorkspaces)}
useProjection={(() => undefined)}
useInput={(() => { throw new Error('unused') })}
inputActions={{ setDraft: () => {}, submit: () => {} }}
useStore={bindSnapshotSelector(chat)}
@@ -111,6 +112,7 @@ describe('render branch tails', () => {
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} })}
useSessions={bindSnapshotSelector(emptyList)}
useWorkspaces={bindSnapshotSelector(emptyWorkspaces)}
useProjection={(() => undefined)}
useInput={(() => { throw new Error('unused') })}
inputActions={{ setDraft: () => {}, submit: () => {} }}
useStore={bindSnapshotSelector(chat)}
@@ -21,7 +21,7 @@ const SID = 's1' as SessionId
function snapshotOf(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false,
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, lastAgentError: null,
...overrides,
@@ -88,6 +88,7 @@ function bench(over?: BenchOptions) {
items: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})),
useProjection: (() => undefined),
useInput: bindSnapshotSelector(shell.state),
inputActions: shell.actions,
keyboard: shell,
@@ -24,7 +24,7 @@ const SID = 's1' as SessionId
function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled?: boolean }) {
const session = createSnapshotStore<ConversationSnapshot>({
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], todos: [], running: over?.running ?? false, composerPhase: 'active',
pending: [], queue: [], running: over?.running ?? false, composerPhase: 'active',
removed: over?.disabled ?? false, openState: 'open', openError: null, hasMore: false,
loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
})
@@ -39,6 +39,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
items: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})),
useProjection: (() => undefined),
useInput: bindSnapshotSelector(shell.state),
inputActions: shell.actions,
keyboard: shell,
@@ -110,7 +110,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
const wiring = shell
const sessionStore = createSnapshotStore<ConversationSnapshot>({
sessionId, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false,
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, lastAgentError: null,
})
@@ -125,6 +125,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
items: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})),
useProjection: (() => undefined),
useInput: bindSnapshotSelector(shell.state),
inputActions: shell.actions,
keyboard: shell,
@@ -19,7 +19,7 @@ const SID = 's1' as SessionId
function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue, todos: [], running: true, composerPhase: 'active', removed: false, openState: 'open', openError: null,
pending: [], queue, running: true, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
}
}
@@ -53,6 +53,7 @@ function kitFor(snapshot: ConversationSnapshot) {
sessionId: SID,
useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<SessionListState>,
useWorkspaces: (() => { throw new Error('unused') }) as never,
useProjection: (() => undefined) as never,
useInput: (() => { throw new Error('unused') }) as never,
inputActions: { setDraft: () => {}, submit: () => {} } as never,
session: snapshot,
@@ -48,7 +48,7 @@ const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState =>
function conversationSnapshot(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false,
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, lastAgentError: null,
...overrides,
@@ -93,6 +93,7 @@ function mount(
useSession={useSession}
useSessions={props.useSessions}
useWorkspaces={props.useWorkspaces}
useProjection={(() => undefined)}
useInput={useInput}
inputActions={inputActions}
useStore={bindSnapshotSelector(chat)}
@@ -115,6 +116,7 @@ function mount(
useSession={useSession}
useSessions={props.useSessions}
useWorkspaces={props.useWorkspaces}
useProjection={(() => undefined)}
useInput={useInput}
inputActions={inputActions}
keyboard={wiring}
@@ -135,6 +137,7 @@ function mount(
useSession,
useSessions: bindSnapshotSelector(sessions),
useWorkspaces: bindSnapshotSelector(workspaces),
useProjection: (() => undefined),
useInput,
inputActions,
renderSlot,
@@ -64,20 +64,23 @@ describe('TodoPanel', () => {
})
})
/** Dock props stub: the adapter reads useSession only; the rest of the owner share is unused. */
function dockProps(store: ReturnType<typeof createSnapshotStore<{ todos: readonly TodoItem[] }>>): TodoDockProps {
return { useSession: bindSnapshotSelector(store) } as unknown as TodoDockProps
/** Dock props stub: the adapter reads the 'todos' projection only; the rest of the owner share is unused. */
function dockProps(store: ReturnType<typeof createSnapshotStore<{ value: readonly TodoItem[] | null | undefined }>>): TodoDockProps {
const useProjection = (_key: string, selector?: (v: unknown) => unknown) =>
bindSnapshotSelector(store)(s => (selector ?? (v => v))(s.value))
return { useProjection } as unknown as TodoDockProps
}
describe('TodoDock', () => {
it('selects the plan off the session snapshot and follows later writes', () => {
const store = createSnapshotStore<{ todos: readonly TodoItem[] }>({ todos: [] })
it('reads the host-computed todos projection and follows pushed updates', () => {
const store = createSnapshotStore<{ value: readonly TodoItem[] | null | undefined }>({ value: undefined })
render(<TodoDock {...dockProps(store)} />)
// Capability absent (no baseline/frame yet) renders nothing.
expect(screen.queryByTestId('todo-panel')).toBeNull()
act(() => { store.set({ todos: LIST }) })
act(() => { store.set({ value: LIST }) })
expect(screen.getByText('1/3 tasks · 1 in progress')).toBeTruthy()
// A rollback to the empty list retires the strip (the panel owns no data).
act(() => { store.set({ todos: [] }) })
// The pre-first-write whole value (null) retires the strip (the panel owns no data).
act(() => { store.set({ value: null }) })
expect(screen.queryByTestId('todo-panel')).toBeNull()
})
@@ -96,10 +99,10 @@ const resultNode = (argsRaw: string, over?: Partial<ToolResultNode>): ToolResult
content: [], isError: false, callView: null, resultView: null, ...over,
})
function rowProps(block: unknown, openDetails = vi.fn()): ToolRowProps {
function rowProps(block: unknown): ToolRowProps {
return {
callId: 'c1', toolName: 'todo_write', block,
openDetails,
openFile: vi.fn(),
sessionId: 's1',
useSessions: () => undefined,
} as unknown as ToolRowProps
@@ -140,27 +143,10 @@ describe('TodoRow', () => {
expect(screen.getByText('todo_write · not json')).toBeTruthy()
})
it('falls back when parsed args carry no todos array, and click opens details', () => {
const openDetails = vi.fn()
render(<TodoRow {...rowProps(resultNode('{"other":1}'), openDetails)} />)
it('falls back when parsed args carry no todos array and stays non-interactive', () => {
render(<TodoRow {...rowProps(resultNode('{"other":1}'))} />)
expect(screen.getByText('todo_write · {"other":1}')).toBeTruthy()
fireEvent.click(screen.getByText('更新任务清单'))
expect(openDetails).toHaveBeenCalledTimes(1)
})
it('opens details from the keyboard on Enter and Space, ignoring other keys', () => {
const openDetails = vi.fn()
render(<TodoRow {...rowProps(resultNode(ARGS), openDetails)} />)
const row = screen.getByRole('button')
expect(row.getAttribute('tabindex')).toBe('0')
fireEvent.keyDown(row, { key: 'Enter' })
fireEvent.keyDown(row, { key: ' ' })
expect(openDetails).toHaveBeenCalledTimes(2)
// Space must not also scroll the flow: the handler claims the event.
expect(fireEvent.keyDown(row, { key: ' ' })).toBe(false)
fireEvent.keyDown(row, { key: 'a' })
fireEvent.keyDown(row, { key: 'ArrowDown' })
expect(openDetails).toHaveBeenCalledTimes(3)
expect(screen.queryByRole('button')).toBeNull()
})
it.each([
@@ -49,6 +49,8 @@ describe('view-ring type negatives (compile-time; body never runs)', () => {
const chatProps = (props: ChatViewSlotProps): ReactNode => {
// @ts-expect-error openDetails takes a SelectionTarget, not a string
props.openDetails('nope')
// @ts-expect-error openFile takes a path string, not a SelectionTarget
props.openFile({ turnSeq: 1, callId: 'c' })
return null
}
void chatProps
@@ -23,6 +23,12 @@
{
"path": "../runtime"
},
{
"path": "../../session-projection/session-projection"
},
{
"path": "../../todo/tool-todo"
},
{
"path": "../ui-slash"
},