Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input
# Conflicts: # .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml # apps/web/tests/code-mode-fixture.snapshot.ts # docs/architecture.i18n.yaml # docs/core-data-structures/core.i18n.yaml # docs/module-graph.md # packages/README.i18n.yaml # packages/client/runtime/README.i18n.yaml # packages/client/runtime/README.md # packages/client/runtime/README.zh.md # packages/client/test-runtime/tests/runtime.spec.tsx # packages/client/ui-conversation/README.i18n.yaml # packages/client/ui-conversation/package.json # packages/client/ui-conversation/src/client/chat/ChatView.tsx # packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx # packages/client/ui-conversation/src/client/skeleton/InputBar.tsx # packages/client/ui-conversation/tests/skeleton.spec.tsx # packages/compact/compact-basic/README.i18n.yaml # packages/compact/compact-basic/README.zh.md # packages/compact/compact-basic/src/summarizer.ts # packages/host/apiproxy/src/api/sessions.ts # packages/llm/llm-pi-ai/README.i18n.yaml # packages/llm/llm/README.i18n.yaml # packages/ui/tui/README.i18n.yaml # pnpm-lock.yaml
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
|
||||
README.md: e40d0f21613da0c4ff529541dd7ad507e6fa48bb
|
||||
README.zh.md: 778e5405a3782e702926330543dfa3638b3625b8
|
||||
README.md: 74137edccc3ca68c40afd545350ce3811d6ae2e2
|
||||
README.zh.md: c974640ba08451d0a666061c6ec46ca88d66a85e
|
||||
|
||||
@@ -2,23 +2,25 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, stats line, per-tool row slot with a bash sample registrant and the todo row), input dock (queue rows plus the todo plan strip), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares).
|
||||
Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, per-tool row slot with a bash sample registrant and the todo row), composer dock (session stats sticky with the input), input dock (queue rows plus the todo plan strip), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares).
|
||||
|
||||
The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store.
|
||||
The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header occupies the top as ordinary column chrome; beneath it a scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host.
|
||||
|
||||
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.
|
||||
|
||||
Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The sidebar mirrors the blocked state through the manager-tracked `waitingApproval` list bit (lit for uninstantiated sessions too), which outranks the running ring until the question resolves. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); a pick submits the `/permission <preset>` command line through the bar's injected `command` callback.
|
||||
Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The sidebar mirrors the blocked state through the manager-tracked `waitingApproval` list bit (lit for uninstantiated sessions too), which outranks the running ring until the question resolves. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels (the `/permission` popup's display transform twin), and a pick submits the `/permission <preset>` command line through the bar's injected `command` callback.
|
||||
|
||||
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.
|
||||
|
||||
A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed for this intent alone; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)).
|
||||
|
||||
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 plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) 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 standing 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.
|
||||
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 plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as 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 standing 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.
|
||||
|
||||
Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks.
|
||||
|
||||
The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). The resident no-session shell uses `DisabledInputBar` and therefore dispatches no session-scoped control seats.
|
||||
The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `command.hint` locale namespace this package registers and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The resident no-session shell uses `DisabledInputBar` and therefore dispatches no session-scoped control seats.
|
||||
|
||||
Image drafts keep only ordered runtime ids in that store. `ConversationService` owns the corresponding browser `File` and object URLs, applies the latest host capability and upload-limit snapshot before allocation, and releases draft URLs on removal or send plus historical URLs when their rendered session unmounts. Paste and drop share the same validation path; mixed clipboard text remains native textarea input.
|
||||
|
||||
@@ -35,7 +37,7 @@ None; this package neither assembles nor sends a provider request.
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **The stats line has no duration segment** — assistant `usage` carries token accounting only; elapsed-time needs a host data source.
|
||||
- **Details panel is the minimal form** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred.
|
||||
- **Details panel is the minimal form and currently has no entry point** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred. Tool rows stopped being details-panel click targets and nothing replaced that gesture, so `ChatViewInjected.openDetails` is implemented but uncalled and the panel (including its terminal card) is unreachable in the assembled application; its rendering stays covered by mounting it with a selection directly.
|
||||
- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized IconActions row (copy / branch / clock) ships; branch remains a chrome stub.
|
||||
- **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export.
|
||||
- **The approval panel's "Always allow this type" is deferred** — durable grants need a grant-storage design; only allow-once/reject answer today.
|
||||
|
||||
@@ -2,23 +2,25 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、统计行、逐工具行 slot 及一个 bash 示例注册方与 todo 行)、输入区 dock(队列行加 todo 计划条)、最小详情面板、按 scope 寻址的 ConversationService。契约:api-contracts v3 §7 加 slot 终端设计(store seat/props share)。
|
||||
会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、逐工具行 slot 及一个 bash 示例注册方与 todo 行)、编辑器 dock(与输入区一同 sticky 的会话统计行)、输入区 dock(队列行加 todo 计划条)、最小详情面板、按 scope 寻址的 ConversationService。契约:api-contracts v3 §7 加 slot 终端设计(store seat/props share)。
|
||||
|
||||
常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。
|
||||
常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段会话标题栏以普通列 chrome 占据顶部;其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。
|
||||
|
||||
视图环本身就是 slot:会话注册声明 `'conversation.view'` 列表 slot(Session 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 图标和路径摘要;该路径是悬停下划线链接,点击后通过宿主操作系统的默认应用打开文件(`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行)。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。
|
||||
|
||||
声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出只对该意图开放;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。
|
||||
|
||||
工具行同样是 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 会拒绝没有任何渲染方的声明)。
|
||||
|
||||
审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位(未实例化会话同样点亮)镜像该阻塞状态,其优先级高于运行中圆环,直至问题解决。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);选中会经由输入栏注入的 `command` 回调提交 `/permission <preset>` 命令行。
|
||||
审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位(未实例化会话同样点亮)镜像该阻塞状态,其优先级高于运行中圆环,直至问题解决。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,kebab-case 预设名渲染为 Title Case 标签(与 `/permission` popup 的显示变换孪生),选中会经由输入栏注入的 `command` 回调提交 `/permission <preset>` 命令行。
|
||||
|
||||
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: -1` 占用 `'conversation.input.dock'` 列表 slot(位于队列行之上),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏,折叠时收成标题加 `"<已完成>/<总数> tasks · <n> in progress"` 的表头(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。
|
||||
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: -1` 占用 `'conversation.input.dock'` 列表 slot(位于队列行之上),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · <n> in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。
|
||||
|
||||
逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。
|
||||
|
||||
输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。常驻无会话壳使用 `DisabledInputBar`,因此不会分发任何会话作用域的控件 seat。
|
||||
输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `command.hint` locale 命名空间本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。常驻无会话壳使用 `DisabledInputBar`,因此不会分发任何会话作用域的控件 seat。
|
||||
|
||||
图片草稿在该 store 中只保留有序的运行时 id。`ConversationService` 持有对应的浏览器 `File` 和对象 URL,在分配前应用最新的宿主能力与上传限制快照,并在图片移除或发送时释放草稿 URL,在所渲染的会话卸载时释放历史 URL。粘贴与拖放共用同一校验路径;混合剪贴板文本仍由 textarea 原生输入。
|
||||
|
||||
@@ -35,7 +37,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **统计行没有耗时区段**:assistant `usage` 只携带 token 计数;耗时需要主机数据源。
|
||||
- **详情面板是最小形态**:以原始形式显示已选择调用的参数/结果;Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。
|
||||
- **详情面板是最小形态,且当前没有入口**:以原始形式显示已选择调用的参数/结果;Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。工具行已不再是详情面板的点击目标,且没有任何手势接替它,因此 `ChatViewInjected.openDetails` 虽已实现却无人调用,该面板(含其终端卡片)在组装后的应用中不可达;其渲染仍由直接以选中态挂载它来覆盖。
|
||||
- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的 IconActions 行(复制/分支/时钟)已落地;分支仍是 chrome stub。
|
||||
- **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。
|
||||
- **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-attachment": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-locale": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-slash": "^0.0.1",
|
||||
@@ -50,10 +51,10 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-attachment": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-plan-mode": "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:^",
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { Context } from 'cordis'
|
||||
import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { ViewTab } from './contract/views.ts'
|
||||
import type {
|
||||
ApprovalWait, ChatViewInjected, ComposerBarInjected, ComposerChainProps, ConversationInjected,
|
||||
@@ -15,6 +17,7 @@ import type { IConversation } from './service.ts'
|
||||
import { InputHub } from './input/hub.ts'
|
||||
import { InputBar } from './skeleton/InputBar.tsx'
|
||||
import { ChatView } from './chat/ChatView.tsx'
|
||||
import { StatsLine } from './chat/StatsLine.tsx'
|
||||
import { bashToolviewSample } from './toolviews/bash-sample.tsx'
|
||||
import { ApprovalPanel } from './skeleton/ApprovalPanel.tsx'
|
||||
import { todoToolview } from './toolviews/todo-row.tsx'
|
||||
@@ -25,7 +28,7 @@ import { ConversationSession } from './skeleton/ConversationSession.tsx'
|
||||
import { DetailsPanel } from './skeleton/DetailsPanel.tsx'
|
||||
|
||||
/** Services required by the conversation plugin. */
|
||||
export const inject = ['slots', 'layout', 'sessions', 'workspaces']
|
||||
export const inject = ['slots', 'layout', 'sessions', 'workspaces', 'locale']
|
||||
|
||||
/** Resolve the session-scoped conversation face (scope-addressed send/cancel), failing loud. */
|
||||
function scopedConversation(sessions: ISessions, id: SessionId): IConversation {
|
||||
@@ -50,6 +53,33 @@ export function apply(ctx: Context): void {
|
||||
const layout = ctx.layout
|
||||
const slots = ctx.slots
|
||||
|
||||
// Command hint locale: friendly placeholder text for claimed commands. The
|
||||
// claimed /plan hint and the plan-mode textarea placeholder share one
|
||||
// string: both describe the same next action.
|
||||
const HINT_NS = 'command.hint'
|
||||
const PLAN_HINT_ZH = '描述你的任务以生成计划'
|
||||
const PLAN_HINT_EN = 'describe your task to generate plan'
|
||||
ctx.effect(() => {
|
||||
const disposers = [
|
||||
ctx.locale.register(HINT_NS, 'zh', {
|
||||
plan: PLAN_HINT_ZH,
|
||||
goal: '输入目标,智能体将持续执行',
|
||||
'goal.active': '当前目标进行中。可输入 edit 修改 / pause 暂停 / resume 继续 / clear 清除',
|
||||
'placeholder.plan': PLAN_HINT_ZH,
|
||||
'placeholder.default': '给智能体发消息',
|
||||
}),
|
||||
ctx.locale.register(HINT_NS, 'en', {
|
||||
plan: PLAN_HINT_EN,
|
||||
goal: 'describe the objective for a long-running task',
|
||||
'goal.active': 'goal active — edit / pause / resume / clear',
|
||||
'placeholder.plan': PLAN_HINT_EN,
|
||||
'placeholder.default': 'Message the agent',
|
||||
}),
|
||||
]
|
||||
return () => { for (const dispose of disposers) dispose() }
|
||||
}, 'ui-conversation: command hint dictionaries')
|
||||
const translateHint = ctx.locale.bind(HINT_NS)
|
||||
|
||||
// Apply-time construction keeps store identity bound to this fiber.
|
||||
const chatStore = createChatStore()
|
||||
|
||||
@@ -186,6 +216,7 @@ export function apply(ctx: Context): void {
|
||||
const result = await session.command(line)
|
||||
return result.ok && result.value.matched
|
||||
},
|
||||
translateHint,
|
||||
hooks: { notices: shell.notices, lexicon: shell.lexicon },
|
||||
}
|
||||
},
|
||||
@@ -238,6 +269,9 @@ export function apply(ctx: Context): void {
|
||||
},
|
||||
}, ChatView)
|
||||
|
||||
// Session stats stick with the composer (composer.dock = stats-line family).
|
||||
slots.register({ name: 'conversation.composer.dock', id: 'stats', order: 0 }, StatsLine)
|
||||
|
||||
// Class-plugin mount (packages/AGENTS.md service form): the service
|
||||
// registers itself as `conversation` and lives on its own child fiber.
|
||||
// Mounted AFTER the chat entry register above — construction guarantee for
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/* Chat flow: one 16px rhythm everywhere — between blocks (prose <-> tool
|
||||
runs) via the column gap and between consecutive tool rows via the group
|
||||
gap. Input padding cap rides the skeleton. */
|
||||
gap. Input padding cap rides the skeleton. Under
|
||||
`[data-conversation-scroll]` the column host owns overflow and this view
|
||||
is ordinary flow (see ConversationRoot active-phase rules). */
|
||||
|
||||
.root {
|
||||
position: relative;
|
||||
@@ -17,6 +19,18 @@
|
||||
padding: 16px 24px;
|
||||
}
|
||||
|
||||
:global([data-conversation-scroll]) .root {
|
||||
flex: 0 0 auto;
|
||||
min-height: auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
:global([data-conversation-scroll]) .scroll {
|
||||
overflow: visible;
|
||||
flex: 0 0 auto;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
/* Message column: 736px fixed width, centered on the same axis as the
|
||||
input box; the scroller itself stays full-bleed. */
|
||||
.column {
|
||||
@@ -113,16 +127,34 @@
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Back-to-bottom: 34px circular icon button at the column's right edge. */
|
||||
.toBottom {
|
||||
position: absolute;
|
||||
right: max(24px, calc((100% - 736px) / 2));
|
||||
/* Back-to-bottom: zero-height sticky slot so the control does not extend
|
||||
scrollHeight; the button translates up into the viewport. Under the
|
||||
conversation host, clearance sits above the sticky composer stack. */
|
||||
.toBottomSlot {
|
||||
position: sticky;
|
||||
bottom: 16px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
/* Above the sticky composer (z-index 7) so the control stays clickable and
|
||||
visible over the input card. */
|
||||
z-index: 8;
|
||||
height: 0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-right: max(0px, calc((100% - 736px) / 2));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
:global([data-conversation-scroll]) .toBottomSlot {
|
||||
/* Clears the sticky composer stack (stats + docks + input card). */
|
||||
bottom: 168px;
|
||||
}
|
||||
|
||||
.toBottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
margin-top: -34px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 100px;
|
||||
@@ -130,6 +162,7 @@
|
||||
background: var(--dsw-alias-button-floating-fill);
|
||||
box-shadow: var(--dsw-shadow-lv2);
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.toBottom:hover {
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
// ChatView: the default conversation view — message flow with user bubbles,
|
||||
// assistant narration, tool summary rows grouped into step runs, pending
|
||||
// cards, paging, bottom-follow, and the session stats line under the flow
|
||||
// (chrome dissolved into the view: the footer is part of what a chat view
|
||||
// IS, not registration metadata). Pure component registered directly; its
|
||||
// registration declares the keyed 'conversation.chat.toolview' hole, so tool
|
||||
// rows render through the props renderSlot share (entryKey = tool name,
|
||||
// GenericToolCard as the render-site fallback).
|
||||
// cards, paging, and bottom-follow. Session stats live on
|
||||
// 'conversation.composer.dock' (sticky with the composer). Pure component
|
||||
// registered directly; its registration declares the keyed
|
||||
// 'conversation.chat.toolview' hole, so tool rows render through the props
|
||||
// renderSlot share (entryKey = tool name, GenericToolCard as the render-site
|
||||
// fallback).
|
||||
//
|
||||
// Scroll: when nested under `[data-conversation-scroll]` (active conversation
|
||||
// column), that host is the scrollport and this view is flow content; when
|
||||
// mounted alone (unit tests), `.scroll` owns overflow. Bottom-follow and
|
||||
// prepend anchoring always target the resolved scrollport.
|
||||
//
|
||||
// Render economics (architecture RFC performance model): the list parent
|
||||
// subscribes to snapshot segments that do NOT change per streaming chunk
|
||||
@@ -17,7 +22,7 @@
|
||||
// memoized rows never churns them.
|
||||
|
||||
import {
|
||||
memo, useLayoutEffect, useMemo, useRef, useState, type ReactNode,
|
||||
memo, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode,
|
||||
} from 'react'
|
||||
import type {
|
||||
CodeSubCall, CommandNode, ConversationNode, ConversationSnapshot, RunningToolCall, ToolResultNode,
|
||||
@@ -31,11 +36,15 @@ import { GenericCommandCard } from './GenericCommandCard.tsx'
|
||||
import { GenericToolCard } from './GenericToolCard.tsx'
|
||||
import { MessageItem } from './MessageItem.tsx'
|
||||
import type { ImageLoader } from './MessageImage.tsx'
|
||||
import { StatsLine } from './StatsLine.tsx'
|
||||
import css from './ChatView.module.css'
|
||||
|
||||
const FOLLOW_THRESHOLD = 24
|
||||
|
||||
/** Active column host when present; otherwise the view-local scroller. */
|
||||
function scrollerOf(from: HTMLElement): HTMLElement {
|
||||
return (from.closest('[data-conversation-scroll]')) ?? from
|
||||
}
|
||||
|
||||
type OpenFile = (path: string) => void
|
||||
|
||||
/** The declared toolview hole's render share (stable framework binding, passed through memoized rows). */
|
||||
@@ -248,26 +257,34 @@ export function ChatView({
|
||||
const firstSeqRef = useRef<number | null>(null)
|
||||
const openedRef = useRef(false)
|
||||
const lastKeyRef = useRef<string | null>(null)
|
||||
/** Flow tip signature — follow-scroll only when this moves, never on a
|
||||
* scroll-driven at-bottom chrome re-render (that was snapping inertial
|
||||
* scrolls the rest of the way to the floor). */
|
||||
const followSigRef = useRef<string | null>(null)
|
||||
|
||||
const firstSeq = nodes[0]?.seq ?? null
|
||||
const lastItem = items[items.length - 1]
|
||||
const lastKey = lastItem?.key ?? null
|
||||
const followSig = `${openState}:${firstSeq}:${lastKey}:${nodes.length}:${running ? 1 : 0}:${runningCalls.length}`
|
||||
|
||||
const toBottom = (el: HTMLDivElement): void => {
|
||||
const toBottom = (el: HTMLElement): void => {
|
||||
el.scrollTop = el.scrollHeight
|
||||
atBottomRef.current = true
|
||||
setAtBottom(true)
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = listRef.current
|
||||
const local = listRef.current
|
||||
/* v8 ignore next -- ref-null guard: React attaches the ref before layout effects run. */
|
||||
if (el === null) return
|
||||
if (local === null) return
|
||||
const el = scrollerOf(local)
|
||||
// Open completed: jump to the bottom once.
|
||||
if (openState === 'open' && !openedRef.current) {
|
||||
openedRef.current = true
|
||||
toBottom(el)
|
||||
firstSeqRef.current = firstSeq
|
||||
lastKeyRef.current = lastItem?.key ?? null
|
||||
lastKeyRef.current = lastKey
|
||||
followSigRef.current = followSig
|
||||
return
|
||||
}
|
||||
// Prepend (head seq decreased): compensate by the height delta.
|
||||
@@ -276,42 +293,65 @@ export function ChatView({
|
||||
anchorRef.current = null
|
||||
firstSeqRef.current = firstSeq
|
||||
/* v8 ignore next -- ?? arm: a prepend adds nodes, so the flow list here is never empty. */
|
||||
lastKeyRef.current = lastItem?.key ?? null
|
||||
lastKeyRef.current = lastKey
|
||||
followSigRef.current = followSig
|
||||
return
|
||||
}
|
||||
firstSeqRef.current = firstSeq
|
||||
// Own words must be visible: a new trailing user node force-scrolls
|
||||
// (send lives in the composer, so arrival is detected here, not armed there).
|
||||
const lastKey = lastItem?.key ?? null
|
||||
const appendedUser = lastKey !== lastKeyRef.current
|
||||
&& lastItem !== undefined && lastItem.kind === 'node' && lastItem.node.kind === 'user'
|
||||
const tipMoved = followSigRef.current !== followSig
|
||||
lastKeyRef.current = lastKey
|
||||
if (appendedUser || atBottomRef.current) toBottom(el)
|
||||
followSigRef.current = followSig
|
||||
// Follow new flow content while pinned; do NOT re-pin on every render
|
||||
// merely because atBottomRef is true (scroll threshold → setState → snap).
|
||||
if (appendedUser || (tipMoved && atBottomRef.current)) toBottom(el)
|
||||
})
|
||||
|
||||
const onScroll = (): void => {
|
||||
const el = listRef.current
|
||||
/* v8 ignore next -- ref-null guard: the handler only fires on the mounted element. */
|
||||
if (el === null) return
|
||||
const onScrollRef = useRef(() => {})
|
||||
onScrollRef.current = () => {
|
||||
const local = listRef.current
|
||||
/* v8 ignore next -- ref-null guard: the handler only fires while mounted. */
|
||||
if (local === null) return
|
||||
const el = scrollerOf(local)
|
||||
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1
|
||||
atBottomRef.current = isAtBottom
|
||||
setAtBottom(isAtBottom)
|
||||
}
|
||||
|
||||
// Bind scroll to the resolved scrollport (host or local) once per mount.
|
||||
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() }
|
||||
el.addEventListener('scroll', onScroll, { passive: true })
|
||||
return () => { el.removeEventListener('scroll', onScroll) }
|
||||
}, [])
|
||||
|
||||
// Follow streaming growth the parent never re-renders for (stable ref).
|
||||
// The ref starts null and is assigned every render, so the placeholder
|
||||
// initializer a function initial value would need never exists.
|
||||
const followRef = useRef<(() => void) | null>(null)
|
||||
followRef.current = () => {
|
||||
const el = listRef.current
|
||||
if (el !== null && atBottomRef.current) el.scrollTop = el.scrollHeight
|
||||
const local = listRef.current
|
||||
if (local !== null && atBottomRef.current) {
|
||||
const el = scrollerOf(local)
|
||||
el.scrollTop = el.scrollHeight
|
||||
}
|
||||
}
|
||||
const onGrow = useRef(() => followRef.current?.()).current
|
||||
|
||||
const loadOlderAnchored = (): void => {
|
||||
const el = listRef.current
|
||||
const local = listRef.current
|
||||
/* v8 ignore next -- ref-null guard: the paging button renders inside the list tree. */
|
||||
if (el !== null) anchorRef.current = { h: el.scrollHeight, t: el.scrollTop }
|
||||
if (local !== null) {
|
||||
const el = scrollerOf(local)
|
||||
anchorRef.current = { h: el.scrollHeight, t: el.scrollTop }
|
||||
}
|
||||
loadOlder()
|
||||
}
|
||||
|
||||
@@ -355,7 +395,7 @@ export function ChatView({
|
||||
|
||||
return (
|
||||
<div className={css.root}>
|
||||
<div ref={listRef} className={css.scroll} onScroll={onScroll}>
|
||||
<div ref={listRef} className={css.scroll}>
|
||||
<div className={css.column}>
|
||||
{openState === 'loading' && <div className={css.hint}>载入历史…</div>}
|
||||
{openState === 'error' && <div className={css.openError}>历史加载失败:{openErrorMessage}</div>}
|
||||
@@ -393,22 +433,23 @@ export function ChatView({
|
||||
wait, tool execution, streaming) so it never flickers per step. */}
|
||||
{running && <TurnDots />}
|
||||
</div>
|
||||
{!atBottom && (
|
||||
<div className={css.toBottomSlot}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.toBottom}
|
||||
aria-label="回到底部"
|
||||
onClick={() => {
|
||||
const local = listRef.current
|
||||
/* v8 ignore next -- ref-null guard: the button only renders alongside the mounted list. */
|
||||
if (local !== null) toBottom(scrollerOf(local))
|
||||
}}
|
||||
>
|
||||
<IconChevronDownOutline14 />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<StatsLine useSession={useSession} />
|
||||
{!atBottom && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.toBottom}
|
||||
aria-label="回到底部"
|
||||
onClick={() => {
|
||||
const el = listRef.current
|
||||
/* v8 ignore next -- ref-null guard: the button only renders alongside the mounted list. */
|
||||
if (el !== null) toBottom(el)
|
||||
}}
|
||||
>
|
||||
<IconChevronDownOutline14 />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
IconThinkOutline14,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ToolRowOwnerProps } from '../contract/slots.ts'
|
||||
import { terminalCardModel } from '../contract/terminal-card-model.ts'
|
||||
import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.ts'
|
||||
import { ToolRow } from './ToolRow.tsx'
|
||||
|
||||
@@ -27,6 +28,7 @@ const VARIANT_ICONS: Record<ToolRowVariant, ReactNode> = {
|
||||
|
||||
export function GenericToolCard({ toolName, block, cwd, openFile }: ToolRowOwnerProps) {
|
||||
const model = toolRowModel(toolName, block, cwd)
|
||||
const terminal = terminalCardModel(block, cwd)
|
||||
const singleFile = model.filePath !== undefined
|
||||
return (
|
||||
<ToolRow
|
||||
@@ -34,9 +36,12 @@ export function GenericToolCard({ toolName, block, cwd, openFile }: ToolRowOwner
|
||||
toolName={toolName}
|
||||
icon={VARIANT_ICONS[model.variant]}
|
||||
title={model.title}
|
||||
summary={model.summary}
|
||||
// A terminal presenter's description is the contract's above-card text, so
|
||||
// it outranks the args-derived summary here exactly as it does in BashRow.
|
||||
summary={terminal?.description ?? model.summary}
|
||||
// Single-file tools never expose an args body — the path link is the only action.
|
||||
body={singleFile ? null : model.body}
|
||||
terminal={terminal}
|
||||
state={model.state}
|
||||
filePath={model.filePath}
|
||||
onOpenFile={singleFile ? openFile : undefined}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
// Settled-node identity prevents stream-delta updates from rerendering this row.
|
||||
// Mounted on 'conversation.composer.dock' so it sticks with the composer in the
|
||||
// active conversation scrollport (see ConversationRoot data-conversation-scroll).
|
||||
|
||||
import { memo, useMemo } from 'react'
|
||||
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -49,7 +51,7 @@ export function deriveStats(nodes: ConversationSnapshot['nodes']): UsageTotals {
|
||||
}
|
||||
}
|
||||
|
||||
/** Props: the conversation-snapshot selector hook (handed down by ChatView). */
|
||||
/** Props: the conversation-snapshot selector (dock registration or unit mount). */
|
||||
export interface StatsLineProps { useSession: SnapshotSelectorHook<ConversationSnapshot> }
|
||||
|
||||
export const StatsLine = memo(function StatsLine({ useSession }: StatsLineProps) {
|
||||
|
||||
@@ -175,9 +175,23 @@ button.leading {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* The code variant's expanded body is the run_code program, rendered through
|
||||
the shared CodeBlock (shiki-highlighted TypeScript); only indentation is
|
||||
this row's concern. */
|
||||
.codeBody {
|
||||
/* The two block-shaped expanded bodies: the code variant's run_code program
|
||||
through CodeBlock (shiki-highlighted TypeScript) and a terminal card's
|
||||
command output through TerminalBlock. Both are drawn by the shared
|
||||
primitive, so only the row's indentation is this file's concern — the margin
|
||||
also replaces each primitive's own standalone vertical spacing with the
|
||||
flow's row rhythm. */
|
||||
.codeBody,
|
||||
.terminalBody {
|
||||
margin: 4px 0 4px 22px;
|
||||
}
|
||||
|
||||
/* Indented to the body's own column so the description reads as the card's
|
||||
heading rather than as another summary row, and sits tight against the card
|
||||
below it. Its own rule: grouping it with a body would put description
|
||||
typography on a `CodeBlock` wrapper and change that body's spacing. */
|
||||
.terminalDescription {
|
||||
margin: 4px 0 0 22px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
// ToolRow: the single-line tool summary row (figma component set 122:9479) —
|
||||
// 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
|
||||
// separator dot + FILL-truncated summary. The collapsed row is always one
|
||||
// line; the expanded body is indented gray text, the run_code program through
|
||||
// CodeBlock, or — for a call whose render intent is a terminal card — the
|
||||
// command's own output through TerminalBlock, capped at
|
||||
// CHAT_TERMINAL_MAX_LINES so the message flow stays scannable. Expand state is
|
||||
// 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'
|
||||
import { CodeBlock, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { CodeBlock, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { CHAT_TERMINAL_MAX_LINES, type TerminalCardModel } from '../contract/terminal-card-model.ts'
|
||||
import type { ToolRowState, ToolRowVariant } from '../contract/tool-call-model.ts'
|
||||
import css from './ToolRow.module.css'
|
||||
|
||||
@@ -20,8 +24,15 @@ export interface ToolRowProps {
|
||||
icon: ReactNode
|
||||
title: string
|
||||
summary: string
|
||||
/** Expanded-body text; null = not expandable (leading slot never toggles). */
|
||||
/** Expanded-body text; null = no text body (`terminal` is the other body source). */
|
||||
body: string | null
|
||||
/**
|
||||
* Terminal-card material for a call whose render intent is a terminal card
|
||||
* (derived by `terminalCardModel`); it replaces the text body when present.
|
||||
* Null or absent leaves the text body, and a row with neither is not
|
||||
* expandable (its leading slot never toggles).
|
||||
*/
|
||||
terminal?: TerminalCardModel | null | undefined
|
||||
state: ToolRowState
|
||||
/** Makes the row itself the expand control instead of only its leading icon. */
|
||||
expandOnRowClick?: boolean | undefined
|
||||
@@ -52,17 +63,25 @@ export function ToolRow({
|
||||
title,
|
||||
summary,
|
||||
body,
|
||||
terminal,
|
||||
state,
|
||||
expandOnRowClick = false,
|
||||
filePath,
|
||||
onOpenFile,
|
||||
}: ToolRowProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const terminalBody = terminal ?? 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.
|
||||
// args expand is off whether or not the open callback is wired yet. Terminal
|
||||
// material still expands: only the file variants carry a path, so a terminal
|
||||
// card and a file link never land on the same row.
|
||||
const singleFile = filePath !== undefined
|
||||
const fileLink = singleFile && onOpenFile !== undefined
|
||||
const expandable = body !== null && !singleFile
|
||||
const expandable = (body !== null && !singleFile) || terminalBody !== null
|
||||
// The text arms take the empty string for a null body: a row expandable
|
||||
// only through its terminal material renders the terminal body instead, so
|
||||
// this substitution never shows.
|
||||
const text = body ?? ''
|
||||
const open = expanded && expandable
|
||||
const rowExpands = expandable && expandOnRowClick
|
||||
const toggleExpand = () => {
|
||||
@@ -137,9 +156,17 @@ export function ToolRow({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{open && (variant === 'code'
|
||||
? <CodeBlock code={body} lang="typescript" className={css.codeBody} />
|
||||
: <div className={css.body}>{body}</div>)}
|
||||
{/* The terminal presenter's description belongs ABOVE the card per the
|
||||
render-intent contract, so an expanded terminal row keeps showing it
|
||||
even though the collapsed summary is hidden while open. */}
|
||||
{open && terminalBody?.description !== undefined && (
|
||||
<div className={css.terminalDescription}>{terminalBody.description}</div>
|
||||
)}
|
||||
{open && (terminalBody !== null
|
||||
? <TerminalBlock {...terminalBody.card} maxLines={CHAT_TERMINAL_MAX_LINES} className={css.terminalBody} />
|
||||
: variant === 'code'
|
||||
? <CodeBlock code={text} lang="typescript" className={css.codeBody} />
|
||||
: <div className={css.body}>{text}</div>)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
export async function writeClipboard(text: string): Promise<void> {
|
||||
// lib.dom types clipboard non-optional, but insecure contexts omit it —
|
||||
// that runtime gap is exactly what this guard detects.
|
||||
/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */
|
||||
/* oxlint-disable-next-line typescript/no-unnecessary-condition */
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
@@ -19,7 +19,7 @@ export async function writeClipboard(text: string): Promise<void> {
|
||||
}
|
||||
// execCommand('copy') is the only clipboard fallback where the async API
|
||||
// is missing (insecure contexts); deprecated but deliberately retained.
|
||||
/* eslint-disable @typescript-eslint/no-deprecated */
|
||||
/* oxlint-disable typescript/no-deprecated */
|
||||
const exec = typeof document.execCommand === 'function'
|
||||
? document.execCommand.bind(document)
|
||||
: undefined
|
||||
@@ -36,7 +36,7 @@ export async function writeClipboard(text: string): Promise<void> {
|
||||
} catch {
|
||||
// Clipboard unavailable; the button stays idle.
|
||||
}
|
||||
/* eslint-enable @typescript-eslint/no-deprecated */
|
||||
/* oxlint-enable typescript/no-deprecated */
|
||||
el.remove()
|
||||
}
|
||||
|
||||
|
||||
@@ -126,6 +126,18 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
|
||||
/** Owner share of the strict session content seat. */
|
||||
export interface ConversationSessionOwnerProps {
|
||||
/**
|
||||
* Wrap the view ring in the transcript scrollport that also hosts the
|
||||
* sticky composer seat (whole `'conversation.composer'` chain output).
|
||||
* Supplied for every real session (hero/settling/active) so the composer
|
||||
* keeps one tree seat across the blank → active flip; the header stays
|
||||
* outside that wrapper as ordinary column chrome (`flex: none`), while
|
||||
* active CSS sticks the seat to the bottom of the same scrollport so wheel
|
||||
* over the footer scrolls the flow.
|
||||
* @param view - the session view-ring content (null while blank chrome is hidden).
|
||||
* @returns the scrollport containing `view` and the sticky composer seat.
|
||||
*/
|
||||
wrapActiveBody?: (view: ReactNode) => ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -274,6 +286,8 @@ export interface ComposerBarInjected {
|
||||
* Resolves admission: false = rejected/unmatched/transport failure.
|
||||
*/
|
||||
command: (line: string) => Promise<boolean>
|
||||
/** Locale-aware hint translator for claimed command placeholders. */
|
||||
translateHint: (key: string) => string
|
||||
/** Registrant hooks compartment: the renderer binds these to useNotices/useLexicon. */
|
||||
hooks: {
|
||||
/** Latest surfaced notice (null after none; seq keys re-render of repeats). */
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Pure derivation of the terminal-card props from a frozen call slice: the
|
||||
* `card:'terminal'` render intent the bash tool declares arrives on the
|
||||
* snapshot as `callView`/`resultView`, and this is the one place that turns
|
||||
* that pair into what {@link TerminalBlock} draws. Both conversation render
|
||||
* sites (the chat tool row's expanded body and the details panel's Output
|
||||
* section) call this, so the command, cwd, output and exit status they show
|
||||
* are derived once.
|
||||
* @module
|
||||
*/
|
||||
import type { TerminalBlockProps } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { resolveToolPath, type ToolCallBlock } from './tool-call-model.ts'
|
||||
|
||||
/**
|
||||
* Output lines the chat row's expanded terminal body shows before collapsing
|
||||
* the middle — half the primitive's own default, which the details panel
|
||||
* keeps. A chat row is a summary surface inside the message flow: the flow
|
||||
* must stay scannable across many calls, while the details panel is the
|
||||
* single-call reading surface. A design constant of this UI's row geometry,
|
||||
* not a deployment choice, so it is fixed here rather than a plugin Config
|
||||
* field.
|
||||
*/
|
||||
export const CHAT_TERMINAL_MAX_LINES = 8
|
||||
|
||||
/**
|
||||
* The {@link TerminalBlock} props this derivation owns. Picked off the
|
||||
* primitive's props so the two stay in step; `home` is absent because the web
|
||||
* client has no home path for the session host (a cwd renders as its last
|
||||
* path segment), and `maxLines`/`className` belong to each render site.
|
||||
*/
|
||||
export interface TerminalCardModel {
|
||||
/**
|
||||
* The props {@link TerminalBlock} draws. Held as a nested object so a render
|
||||
* site spreads exactly the primitive's own surface and can never leak a
|
||||
* neighbouring field into it.
|
||||
*/
|
||||
card: Pick<TerminalBlockProps, 'command' | 'cwd' | 'output' | 'exitCode' | 'signal' | 'running'>
|
||||
/**
|
||||
* The call view's model-authored description, which the contract defines as
|
||||
* rendering ABOVE the card (the card itself has no description slot). Absent
|
||||
* when the presenter supplied none, or when the window dropped the call side;
|
||||
* a row then keeps its args-derived summary.
|
||||
*/
|
||||
description: string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a terminal view's working directory the way the render-intent
|
||||
* contract assigns to the UI bridge: an absolute path is used as-is, a relative
|
||||
* one joins under the session workspace, and an omitted one IS the session
|
||||
* workspace. A pure presenter cannot see the session cwd, which is why this
|
||||
* resolution belongs here rather than in the tool. Without a session cwd there
|
||||
* is nothing to resolve against, so a relative path stays as authored and an
|
||||
* omitted one stays absent (the prompt row then draws a bare `$`).
|
||||
* @param viewCwd - the cwd the terminal call view carries, if any.
|
||||
* @param sessionCwd - the session workspace root, if the caller knows it.
|
||||
* @returns the working directory for the prompt label, or undefined.
|
||||
*/
|
||||
function resolveTerminalCwd(viewCwd: string | undefined, sessionCwd: string | undefined): string | undefined {
|
||||
if (viewCwd === undefined || viewCwd === '') return sessionCwd
|
||||
if (sessionCwd === undefined || sessionCwd === '') return normalizeSegments(viewCwd)
|
||||
return normalizeSegments(resolveToolPath(sessionCwd, viewCwd))
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse `.` and `..` segments so the prompt label names the directory the
|
||||
* command actually ran in. The bash executor resolves the workdir before
|
||||
* running, so a joined `/w/app/..` must display as `w`, not as `..`. Separators
|
||||
* are preserved as authored (a Windows path keeps its backslashes) because this
|
||||
* value is only ever displayed; a `..` that would climb past the root is
|
||||
* dropped, which is what a filesystem does with it. A UNC path's `server` and
|
||||
* `share` are part of its root, not poppable segments: Windows cannot climb
|
||||
* above a share, so `\\\\server\\share` with a `..` stays there.
|
||||
* @param path - a joined or absolute path, possibly carrying `.`/`..` segments.
|
||||
* @returns the same path with those segments resolved.
|
||||
*/
|
||||
function normalizeSegments(path: string): string {
|
||||
if (!/(?:^|[/\\])\.\.?(?:[/\\]|$)/.test(path)) return path
|
||||
// A UNC path is `\\\\server\\share\\...`: the server and share form the root,
|
||||
// so they are split off here and neither is a segment `..` may pop. Its
|
||||
// separator is fixed to a backslash, since a joined relative part may have
|
||||
// introduced a forward slash that UNC syntax does not use.
|
||||
const unc = /^[/\\]{2}([^/\\]+)[/\\]+([^/\\]+)/.exec(path)
|
||||
if (unc !== null) {
|
||||
// Both groups are mandatory in the pattern, so destructuring types them as
|
||||
// strings without an assertion.
|
||||
const [matched, server, share] = unc
|
||||
const root = `\\\\${String(server)}\\${String(share)}`
|
||||
// Rooted: what follows the share hangs off it, so a `..` at the top is
|
||||
// dropped rather than kept — Windows cannot climb above a share.
|
||||
const rest = collapse(path.slice(matched.length), true)
|
||||
return rest === '' ? root : `${root}\\${rest}`
|
||||
}
|
||||
const backslashed = path.includes('\\') && !path.includes('/')
|
||||
const separator = backslashed ? '\\' : '/'
|
||||
const rooted = /^[/\\]/.test(path)
|
||||
const drive = /^[A-Za-z]:/.exec(path)?.[0] ?? ''
|
||||
const body = collapse(path.slice(drive.length), rooted || drive !== '', separator)
|
||||
const leading = rooted ? separator : ''
|
||||
return drive === '' ? `${leading}${body}` : `${drive}${rooted ? leading : separator}${body}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse the `.`/`..` segments of a path body against a known root state.
|
||||
* @param body - the path after any drive letter or UNC root.
|
||||
* @param rooted - the body hangs off a root, so a `..` at its top is dropped
|
||||
* the way a filesystem drops one; without a root the `..` is kept, since it
|
||||
* stays meaningful against a cwd this function cannot see.
|
||||
* @param separator - separator to rejoin with (default `/`).
|
||||
* @returns the collapsed body, without leading or trailing separators.
|
||||
*/
|
||||
function collapse(body: string, rooted: boolean, separator = '/'): string {
|
||||
const kept: string[] = []
|
||||
for (const segment of body.split(/[/\\]/)) {
|
||||
if (segment === '' || segment === '.') continue
|
||||
if (segment === '..') {
|
||||
if (kept.length > 0 && kept[kept.length - 1] !== '..') kept.pop()
|
||||
else if (!rooted) kept.push(segment)
|
||||
continue
|
||||
}
|
||||
kept.push(segment)
|
||||
}
|
||||
return kept.join(separator)
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the terminal-card props for a tool call, or null when this call is
|
||||
* not a terminal card and belongs on the generic path.
|
||||
*
|
||||
* The call side supplies the command and its working directory; the result
|
||||
* side supplies the captured output and exit status. Three cases produce
|
||||
* null, all of them the documented generic-card default:
|
||||
*
|
||||
* - Neither side declares `card:'terminal'` — including a `card` value this
|
||||
* UI version does not know, which arrives over the wire and therefore
|
||||
* cannot be trusted to be one of the compiled variants.
|
||||
* - A settled call whose result view is not a terminal card: the result
|
||||
* presentation decides how the settled call renders, and the bash tool
|
||||
* returns a generic fenced card for an execution error or a background
|
||||
* start, whose text and error styling the generic path preserves.
|
||||
*
|
||||
* Window truncation can drop the call head from a settled result (see
|
||||
* `ToolResultNode.call`/`callView` in dsh-client-runtime), leaving a terminal
|
||||
* result with no call side. That still renders: the command falls back to the
|
||||
* result view's replacement title, then to an empty command (the prompt line
|
||||
* draws bare), and the prompt shows no cwd.
|
||||
* @param block - RunningToolCall or ToolResultNode off the snapshot caches.
|
||||
* @param sessionCwd - the session workspace root, which resolves an omitted or
|
||||
* relative view cwd (see {@link resolveTerminalCwd}); absent leaves both unresolved.
|
||||
* @returns the terminal-card props, or null for the generic path.
|
||||
*/
|
||||
export function terminalCardModel(block: ToolCallBlock, sessionCwd?: string): TerminalCardModel | null {
|
||||
const call = block.callView?.card === 'terminal' ? block.callView : null
|
||||
if (!('kind' in block)) {
|
||||
// Running: the call view exists, the result view does not yet.
|
||||
return call === null ? null : {
|
||||
description: call.description,
|
||||
card: {
|
||||
command: call.title,
|
||||
cwd: resolveTerminalCwd(call.cwd, sessionCwd),
|
||||
output: undefined,
|
||||
exitCode: undefined,
|
||||
signal: undefined,
|
||||
running: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
const result = block.resultView?.card === 'terminal' ? block.resultView : null
|
||||
if (result === null) return null
|
||||
return {
|
||||
description: call?.description,
|
||||
card: {
|
||||
// The result's title REPLACES the pending one when the tool supplies it
|
||||
// (the presentation contract's replacement-title rule); the call title is
|
||||
// what a result without one keeps.
|
||||
command: result.title ?? call?.title ?? '',
|
||||
// Only a PRESENT call view can mean "omitted the cwd, so use the
|
||||
// workspace". When the window dropped the call head there is no cwd
|
||||
// anywhere — the result view carries none — and the original call may
|
||||
// well have used an explicit workdir, so the prompt draws a bare `$`
|
||||
// rather than naming a directory this card cannot know.
|
||||
cwd: call === null ? undefined : resolveTerminalCwd(call.cwd, sessionCwd),
|
||||
output: result.output,
|
||||
exitCode: result.exitCode,
|
||||
signal: result.signal,
|
||||
running: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* Pure row-model derivation for tool summary rows: variant classification,
|
||||
* one-line summary and expanded-body text from the frozen call slice. No
|
||||
* inline output ever — full results live in the details panel.
|
||||
* one-line summary and expanded-body text from the frozen call slice. This
|
||||
* derivation reads the call ARGUMENTS only; a call whose render intent is a
|
||||
* terminal card gets its expanded body from the views instead, through
|
||||
* `terminalCardModel` in terminal-card-model.ts.
|
||||
*/
|
||||
// The block union's defining home is runtime (fold-product types); this
|
||||
// contract only forwards it (type-definition authority stays with the layer
|
||||
|
||||
@@ -298,14 +298,23 @@ export class InputMachine {
|
||||
return []
|
||||
}
|
||||
|
||||
/** Shared chip-insertion transaction: replace [span) with one placeholder occurrence (insert-ref and paste-upgrade both land here). */
|
||||
private replaceSpanWithChip(reference: ReferenceInsert, span: TokenSpan): void {
|
||||
/**
|
||||
* Shared chip-insertion transaction: replace [span) with one placeholder
|
||||
* occurrence (insert-ref and paste-upgrade both land here). A separating
|
||||
* space follows the chip unless one is already next.
|
||||
* @returns the inserted length (placeholder plus optional gap).
|
||||
*/
|
||||
private replaceSpanWithChip(reference: ReferenceInsert, span: TokenSpan): number {
|
||||
this.pushTxn()
|
||||
this.typingRun = undefined
|
||||
this.reconcile({ start: span.start, end: span.end, insertedLength: 1 })
|
||||
const tail = this.draft.slice(span.end)
|
||||
const gap = tail.length === 0 || tail[0] !== ' ' ? ' ' : ''
|
||||
const inserted = PLACEHOLDER + gap
|
||||
this.reconcile({ start: span.start, end: span.end, insertedLength: inserted.length })
|
||||
this.withMinted([this.mint(reference, span.start)])
|
||||
this.adopt(this.draft.slice(0, span.start) + PLACEHOLDER + this.draft.slice(span.end))
|
||||
this.adopt(this.draft.slice(0, span.start) + inserted + tail)
|
||||
this.watchClaim()
|
||||
return inserted.length
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -443,10 +452,10 @@ export class InputMachine {
|
||||
if (attempt === undefined || attempt.attemptId !== attemptId) return []
|
||||
if (this.phase !== 'plain' && this.phase !== 'claimed') return []
|
||||
if (!this.casOk(span) || span.start === span.end) return []
|
||||
this.replaceSpanWithChip(reference, span)
|
||||
const insertedLength = this.replaceSpanWithChip(reference, span)
|
||||
this.paste = {
|
||||
...attempt,
|
||||
insertedRange: { start: attempt.insertedRange.start, end: attempt.insertedRange.end + 1 - (span.end - span.start) },
|
||||
insertedRange: { start: attempt.insertedRange.start, end: attempt.insertedRange.end + insertedLength - (span.end - span.start) },
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -17,6 +17,12 @@
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
/* Blank hero/settling: keep the header node mounted (stable Session tree for
|
||||
the wrapActiveBody composer) without taking column space. */
|
||||
.headerHidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.crumbRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -127,6 +133,46 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Common seat for the composer chain (fallback + elected overlay siblings). */
|
||||
.composerSeat {
|
||||
display: flex;
|
||||
flex: none;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Active phase: header is ordinary column chrome above the scrollport (not
|
||||
sticky). The scroll body holds the transcript and the sticky composer seat
|
||||
so wheel over the footer moves the flow. */
|
||||
.root[data-phase='active'] {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.root[data-phase='active'] .header {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.scrollBody {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.root[data-phase='active'] .viewArea {
|
||||
flex: 1 0 auto;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.root[data-phase='active'] .composerSeat {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
/* Above markdown CodeBlock sticky banners (z-index 6) so the footer never
|
||||
paints under a sticking code header while scrolling. */
|
||||
z-index: 7;
|
||||
background: var(--dsw-alias-bg-base);
|
||||
}
|
||||
|
||||
/* Hero phase: the composer stack (hero chrome + workspace row + card) is
|
||||
flex-centered in the column; composer phase docks it at the bottom. Flex,
|
||||
NOT absolute+transform: a transform would make this box the containing
|
||||
@@ -165,12 +211,15 @@
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.root[data-phase='hero'] {
|
||||
/* Hero: the composer sits inside the session scroll body; center there so
|
||||
the tree seat matches active (sticky footer) without a Root remount. */
|
||||
.root[data-phase='hero'] .scrollBody {
|
||||
justify-content: center;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Settling (session replaying, hero/docked unknown): keep the composer
|
||||
/* Settling (session replaying, hero/docked unknown): keep the composer seat
|
||||
mounted but invisible so no wrong layout flashes before the phase lands. */
|
||||
.root[data-phase='settling'] .composerStack {
|
||||
.root[data-phase='settling'] .composerSeat {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// chain stay mounted across no-session/session transitions. Only the inert
|
||||
// input body swaps for the strict session InputBar.
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationSlotProps, InputZone } from '../contract/slots.ts'
|
||||
@@ -113,24 +113,53 @@ export function ConversationRoot({
|
||||
{hero && <HeroGlow className={css.heroGlow} />}
|
||||
{hero && <HeroShell />}
|
||||
{hero && heroWorkspaceRow}
|
||||
{!hero && zone !== undefined && renderSlot('conversation.input.dock', zone)}
|
||||
{/* Stats band above the input-dock strips so the prior ChatView footer
|
||||
order (stats → todo/queue → card) is preserved under the sticky stack. */}
|
||||
{!hero && zone !== undefined && renderSlot('conversation.composer.dock', zone)}
|
||||
{!hero && zone !== undefined && renderSlot('conversation.input.dock', zone)}
|
||||
{inputBar}
|
||||
</div>
|
||||
)
|
||||
|
||||
const phase = settling ? 'settling' : hero ? 'hero' : 'active'
|
||||
const composer = renderSlotChain(
|
||||
'conversation.composer',
|
||||
{ interactions: pending },
|
||||
{ fallback: composerBar, overlay: true },
|
||||
)
|
||||
|
||||
// Sticky wraps the whole chain output (fallback + elected overlay), not
|
||||
// only `.composerStack`: overlay:true renders those as siblings, and sticky
|
||||
// on the fallback alone would leave Question/Approval panels at the content
|
||||
// end off-screen when the user is not pinned to the floor.
|
||||
const composerSeat = (
|
||||
<div className={css.composerSeat} data-composer-seat="">
|
||||
{composer}
|
||||
</div>
|
||||
)
|
||||
|
||||
// Header stays column chrome above this scrollport; the sticky composer
|
||||
// seat lives inside it with the transcript. Always wrap while a session
|
||||
// exists (hero/settling/active) so the composer keeps one tree seat across
|
||||
// the blank → active flip — relocating it only in active remounted the textarea.
|
||||
const wrapActiveBody = (view: ReactNode): ReactNode => (
|
||||
<div className={css.scrollBody} data-conversation-scroll="">
|
||||
{view}
|
||||
{composerSeat}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={css.root} data-phase={settling ? 'settling' : hero ? 'hero' : 'active'}>
|
||||
<div className={css.root} data-phase={phase}>
|
||||
{/* Mounted for every real session, hero included: ConversationSession
|
||||
renders no chrome while blank but owns the draft-persistence mirror
|
||||
bind — unmounting it in the hero would lose pre-first-send text on
|
||||
a refresh or scope rebuild. */}
|
||||
{sessionId !== undefined && renderSlot('conversation.session', {})}
|
||||
{renderSlotChain(
|
||||
'conversation.composer',
|
||||
{ interactions: pending },
|
||||
{ fallback: composerBar, overlay: true },
|
||||
keeps a chrome-hidden shell while blank and owns the draft-
|
||||
persistence mirror bind — unmounting it in the hero would lose
|
||||
pre-first-send text on a refresh or scope rebuild. */}
|
||||
{sessionId !== undefined && renderSlot(
|
||||
'conversation.session',
|
||||
{ wrapActiveBody },
|
||||
)}
|
||||
{sessionId === undefined ? composerSeat : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Strict per-session conversation content: header, view ring, and chat store bindings. */
|
||||
|
||||
import { useEffect, useSyncExternalStore } from 'react'
|
||||
import { useEffect, useSyncExternalStore, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -24,7 +24,7 @@ function deriveAncestry(list: SessionListState, id: SessionId): readonly Session
|
||||
|
||||
export function ConversationSession({
|
||||
sessionId, useSession, useSessions, useInput, inputActions, useStore, actions,
|
||||
renderSlot, views, bindDraftMirror, releaseSessionImages, open,
|
||||
renderSlot, views, bindDraftMirror, releaseSessionImages, open, wrapActiveBody,
|
||||
}: ConversationSessionProps) {
|
||||
useSyncExternalStore(views.subscribe, views.version)
|
||||
const tabs = views.list()
|
||||
@@ -44,56 +44,72 @@ export function ConversationSession({
|
||||
// the machine mirror, not this seed effect.
|
||||
}, [inputActions])
|
||||
|
||||
// Historical image object URLs die with this session's rendered window.
|
||||
useEffect(() => () => {
|
||||
releaseSessionImages(sessionId)
|
||||
}, [releaseSessionImages, sessionId])
|
||||
|
||||
if (blank && composerPhase === 'blank') return null
|
||||
// Blank hero/settling: keep the same header + body tree shape so a
|
||||
// wrapActiveBody-hosted composer keeps its DOM identity across the first
|
||||
// send (hero → active). Chrome is hidden; the draft-persistence mirror
|
||||
// still runs because this component stays mounted.
|
||||
const hideChrome = blank && composerPhase === 'blank'
|
||||
|
||||
const view: ReactNode = hideChrome ? null : (
|
||||
<div className={css.viewArea}>
|
||||
{active !== undefined && renderSlot('conversation.view', {}, { only: active.id })}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className={css.header}>
|
||||
<div className={css.crumbRow}>
|
||||
<nav className={css.crumbs} aria-label="Session hierarchy">
|
||||
{ancestry.map((summary, index) => {
|
||||
const last = index === ancestry.length - 1
|
||||
return (
|
||||
<span key={summary.id} className={css.crumbSeg}>
|
||||
{index > 0 && <span className={css.crumbSep}>/</span>}
|
||||
<header
|
||||
className={clsx(css.header, hideChrome && css.headerHidden)}
|
||||
aria-hidden={hideChrome || undefined}
|
||||
>
|
||||
{!hideChrome && (
|
||||
<>
|
||||
<div className={css.crumbRow}>
|
||||
<nav className={css.crumbs} aria-label="Session hierarchy">
|
||||
{ancestry.map((summary, index) => {
|
||||
const last = index === ancestry.length - 1
|
||||
return (
|
||||
<span key={summary.id} className={css.crumbSeg}>
|
||||
{index > 0 && <span className={css.crumbSep}>/</span>}
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.crumb, last && css.crumbCurrent)}
|
||||
disabled={last}
|
||||
onClick={() => { open(summary.id) }}
|
||||
>
|
||||
{summary.displayTitle}
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
{ancestry.length === 0 && <span className={css.crumbCurrent}>{sessionId}</span>}
|
||||
</nav>
|
||||
</div>
|
||||
{tabs.length > 1 && (
|
||||
<div className={css.tabs} role="tablist">
|
||||
{tabs.map(viewTab => (
|
||||
<button
|
||||
key={viewTab.id}
|
||||
type="button"
|
||||
className={clsx(css.crumb, last && css.crumbCurrent)}
|
||||
disabled={last}
|
||||
onClick={() => { open(summary.id) }}
|
||||
role="tab"
|
||||
aria-selected={viewTab.id === active?.id}
|
||||
className={clsx(css.tab, viewTab.id === active?.id && css.tabActive)}
|
||||
onClick={() => { actions.setView(viewTab.id) }}
|
||||
>
|
||||
{summary.displayTitle}
|
||||
{viewTab.label}
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
{ancestry.length === 0 && <span className={css.crumbCurrent}>{sessionId}</span>}
|
||||
</nav>
|
||||
</div>
|
||||
{tabs.length > 1 && (
|
||||
<div className={css.tabs} role="tablist">
|
||||
{tabs.map(view => (
|
||||
<button
|
||||
key={view.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={view.id === active?.id}
|
||||
className={clsx(css.tab, view.id === active?.id && css.tabActive)}
|
||||
onClick={() => { actions.setView(view.id) }}
|
||||
>
|
||||
{view.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</header>
|
||||
<div className={css.viewArea}>
|
||||
{active !== undefined && renderSlot('conversation.view', {}, { only: active.id })}
|
||||
</div>
|
||||
{wrapActiveBody !== undefined ? wrapActiveBody(view) : view}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -92,3 +92,17 @@
|
||||
.code[data-error] {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
/* Above the card, which is where the render-intent contract puts a terminal
|
||||
call's description; the panel has no summary row to carry it. */
|
||||
.terminalDescription {
|
||||
margin: 0 0 6px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
/* The terminal card sits directly under its section label, so it drops the
|
||||
primitive's standalone vertical margin; the section owns the spacing. */
|
||||
.terminal {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -1,47 +1,59 @@
|
||||
// DetailsPanel, P-I minimal form: close button + the selected call's args and
|
||||
// result rendered raw. The three-段 Switch / Prev-Next stepping / See-in-
|
||||
// trajectory are deferred (ledger). Reads the selection from the shared chat
|
||||
// result — args as JSON, the result raw except for a terminal-card call, whose
|
||||
// Output section is the command's terminal card. The three-段 Switch /
|
||||
// Prev-Next stepping / See-in-trajectory are deferred (ledger). Reads the
|
||||
// selection from the shared chat
|
||||
// store (conversation writes, this panel reads — the cross-registration
|
||||
// share the store seat exists for) and derives the call material from the
|
||||
// session snapshot — no data of its own.
|
||||
|
||||
import { CodeBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { CodeBlock, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationSnapshot, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { DetailsSlotProps } from '../contract/slots.ts'
|
||||
import { terminalCardModel } from '../contract/terminal-card-model.ts'
|
||||
import type { ToolCallBlock } from '../contract/tool-call-model.ts'
|
||||
import css from './DetailsPanel.module.css'
|
||||
|
||||
/** Full props composed by reference from the contract (automatic shares & injected share). */
|
||||
export type DetailsPanelProps = DetailsSlotProps
|
||||
|
||||
/** Selected call material: resolved result node, or the in-flight running call's args. */
|
||||
/**
|
||||
* Selected call material: the call's display name and args plus the frozen
|
||||
* block slice it came from. `block` is a snapshot-cached reference, so the
|
||||
* wrapper stays shallow-equal across unrelated snapshot frames; the settled /
|
||||
* running split is read off it with the `'kind' in block` discrimination
|
||||
* instead of duplicated as flags.
|
||||
*/
|
||||
interface CallMaterial {
|
||||
name: string
|
||||
argsRaw: string | null
|
||||
result: ToolResultNode | null
|
||||
running: boolean
|
||||
block: ToolCallBlock
|
||||
}
|
||||
|
||||
/** Material of a settled result node (native call or run_code sub-dispatch). */
|
||||
function settledMaterial(node: ToolResultNode, callId: string): CallMaterial {
|
||||
return { name: node.call?.name ?? callId, argsRaw: node.call?.argsRaw ?? null, block: node }
|
||||
}
|
||||
|
||||
/** Material of an in-flight call (native call or run_code sub-dispatch). */
|
||||
function runningMaterial(call: RunningToolCall): CallMaterial {
|
||||
return { name: call.name, argsRaw: call.argsRaw, block: call }
|
||||
}
|
||||
|
||||
function materialFor(s: ConversationSnapshot, callId: string): CallMaterial | null {
|
||||
for (const node of s.nodes) {
|
||||
if (node.kind === 'tool-result' && node.callId === callId) {
|
||||
return { name: node.call?.name ?? callId, argsRaw: node.call?.argsRaw ?? null, result: node, running: false }
|
||||
}
|
||||
if (node.kind === 'tool-result' && node.callId === callId) return settledMaterial(node, callId)
|
||||
}
|
||||
const open = s.runningCalls.find(c => c.callId === callId)
|
||||
if (open !== undefined) {
|
||||
return { name: open.name, argsRaw: open.argsRaw, result: null, running: true }
|
||||
}
|
||||
if (open !== undefined) return runningMaterial(open)
|
||||
// run_code sub-dispatches: the native call-block shapes, so a selected
|
||||
// sub-row resolves through the same material as a native call — the
|
||||
// settled ToolResultNode form, or the RunningToolCall form mid-flight.
|
||||
for (const subs of s.codeDispatches.values()) {
|
||||
for (const sub of subs) {
|
||||
if (sub.callId !== callId) continue
|
||||
if ('kind' in sub) {
|
||||
return { name: sub.call?.name ?? callId, argsRaw: sub.call?.argsRaw ?? null, result: sub, running: false }
|
||||
}
|
||||
return { name: sub.name, argsRaw: sub.argsRaw, result: null, running: true }
|
||||
return 'kind' in sub ? settledMaterial(sub, callId) : runningMaterial(sub)
|
||||
}
|
||||
}
|
||||
return null
|
||||
@@ -56,8 +68,11 @@ function pretty(raw: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function DetailsPanel({ useSession, useStore, closeDetails }: DetailsPanelProps) {
|
||||
export function DetailsPanel({ useSession, useSessions, sessionId, useStore, closeDetails }: DetailsPanelProps) {
|
||||
const selection = useStore(s => s.selection)
|
||||
// Session workspace root: an omitted or relative terminal cwd resolves
|
||||
// against it, which the pure presenter cannot see.
|
||||
const sessionCwd = useSessions(list => list.byId[sessionId]?.cwd)
|
||||
const callId = selection?.callId
|
||||
// materialFor builds a fresh wrapper; shallowEqual short-circuits on its
|
||||
// stable members (result node reference rides the snapshot's structural sharing).
|
||||
@@ -95,15 +110,11 @@ export function DetailsPanel({ useSession, useStore, closeDetails }: DetailsPane
|
||||
)}
|
||||
<section className={css.section}>
|
||||
<div className={css.sectionLabel}>Output</div>
|
||||
{/* materialFor invariant: result===null ⇔ running (a settled
|
||||
material always carries its result node). */}
|
||||
{material.result === null
|
||||
? <div className={css.empty}>运行中…</div>
|
||||
: (
|
||||
<pre className={css.code} data-error={material.result.isError || undefined}>
|
||||
{renderResult(material.result)}
|
||||
</pre>
|
||||
)}
|
||||
{/* Keyed by the selected call: the body owns per-call view
|
||||
state (the terminal card's expand and copy), which React
|
||||
would otherwise carry into the next selection because the
|
||||
panel does not unmount between calls. */}
|
||||
<OutputBody key={callId} material={material} cwd={sessionCwd} />
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
@@ -112,6 +123,41 @@ export function DetailsPanel({ useSession, useStore, closeDetails }: DetailsPane
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The Output section's body for the selected call. A terminal-card call — a
|
||||
* shell command's call/result views — renders through the shared TerminalBlock
|
||||
* at the primitive's own full height allowance, so column-aligned output keeps
|
||||
* its alignment and scrolls sideways instead of folding. Every other call, and
|
||||
* a running call with no terminal card yet, keeps the flattened text form.
|
||||
* @param props.material - the selected call's material from {@link materialFor}.
|
||||
* @param props.cwd - the session workspace root, resolving the terminal view's cwd.
|
||||
* @returns the Output section's body element.
|
||||
*/
|
||||
function OutputBody({ material, cwd }: { material: CallMaterial; cwd: string | undefined }) {
|
||||
const terminal = terminalCardModel(material.block, cwd)
|
||||
if (terminal !== null) {
|
||||
// The contract renders the presenter's description above the card, and the
|
||||
// panel has no summary row to carry it, so it is drawn here.
|
||||
return (
|
||||
<>
|
||||
{terminal.description !== undefined && (
|
||||
<div className={css.terminalDescription}>{terminal.description}</div>
|
||||
)}
|
||||
<TerminalBlock {...terminal.card} className={css.terminal} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
// A settled call always carries the result node the flattened form needs;
|
||||
// the running shape has no result to flatten.
|
||||
if (!('kind' in material.block)) return <div className={css.empty}>运行中…</div>
|
||||
const result = material.block
|
||||
return (
|
||||
<pre className={css.code} data-error={result.isError || undefined}>
|
||||
{renderResult(result)}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
/** Flatten result content blocks to display text (text blocks verbatim, others as JSON). */
|
||||
function renderResult(node: ToolResultNode): string {
|
||||
const parts: string[] = []
|
||||
|
||||
@@ -115,9 +115,9 @@ export function HeroShell({ children }: HeroShellProps) {
|
||||
Let's start building
|
||||
</div>
|
||||
<div className={css.body}>
|
||||
{/* The resident composer (rendered by ConversationRoot at its stable
|
||||
tree position; the workspace row rides its accessory hole) is
|
||||
CSS-positioned into this gap during the hero phase — see
|
||||
{/* The resident composer (ConversationRoot wrapActiveBody seat; the
|
||||
workspace row rides the stack above the card) is CSS-centered in
|
||||
the session scroll body during hero — see
|
||||
ConversationRoot.module.css [data-phase='hero']. */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -195,20 +195,18 @@
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
color: transparent;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hlToken {
|
||||
border-radius: 4px;
|
||||
/* GOAL 稿 amber token emphasis (state warn pair; glyphs stay the textarea's). */
|
||||
background: var(--dsw-alias-state-warn-tertiary);
|
||||
color: transparent;
|
||||
background-color: transparent;
|
||||
color: var(--dsw-alias-state-warn-label);
|
||||
}
|
||||
|
||||
.hlSegment {
|
||||
border-radius: 4px;
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
background-color: transparent;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
@@ -240,7 +238,7 @@
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
color: transparent;
|
||||
/* Business blue, not brand-primary: that token resolves to ink in this sheet. */
|
||||
caret-color: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
@@ -418,25 +416,13 @@
|
||||
draft's own glyphs — advance untouched, so the two layers cannot drift.
|
||||
Chip family colors; clone keeps rounded ends on soft-wrap fragments. */
|
||||
.textRef {
|
||||
color: transparent;
|
||||
background-color: transparent;
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
box-decoration-break: clone;
|
||||
-webkit-box-decoration-break: clone;
|
||||
position: relative;
|
||||
}
|
||||
.textRef:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
border-radius: 6px;
|
||||
background: rgba(97, 135, 216, 0.22);
|
||||
transform: translate(-2px, -1px);
|
||||
padding: 2px 4px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Reference chip: rendered in the backdrop at the placeholder offset. Hard
|
||||
|
||||
@@ -13,6 +13,8 @@ import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
// Type-only: the `plan` projection key merge (the TodoDock posture — the
|
||||
// composer reads a host-computed value; the domain owns the key).
|
||||
import type {} from '@deepseek-ai/dsh-plan-mode/client'
|
||||
// Type-only: the `goal` projection key merge (hint disambiguation).
|
||||
import type {} from '@deepseek-ai/dsh-goal/client'
|
||||
import type { ComposerAttachment, ComposerBarProps } from '../contract/slots.ts'
|
||||
import { deriveDecorations } from '../input/decorations.ts'
|
||||
import { ImageLightbox } from './ImageLightbox.tsx'
|
||||
@@ -29,7 +31,7 @@ export type InputBarProps = ComposerBarProps
|
||||
|
||||
export function InputBar({
|
||||
useSession, useInput, inputActions, keyboard, addImages, removeImage, draftImages,
|
||||
stop, command, renderSlot, useNotices, useLexicon, useProjection,
|
||||
stop, command, translateHint, renderSlot, useNotices, useLexicon, useProjection,
|
||||
variant, placeholder, accessory, overlay, leftItems, rightItems, onAdd, addLabel = 'Add attachment',
|
||||
}: InputBarProps) {
|
||||
const input = useInput(s => s)
|
||||
@@ -41,6 +43,8 @@ export function InputBar({
|
||||
// Plan mode swaps the textarea placeholder (the projection is the folded
|
||||
// host value; owner-prop placeholders — hero, session-unavailable — win).
|
||||
const planActive = useProjection('plan', plan => plan !== undefined && (plan.pending ? !plan.active : plan.active))
|
||||
// Absent (undefined: no frame yet) and cleared (null) both mean no goal.
|
||||
const hasGoal = useProjection('goal', goal => goal != null)
|
||||
// Prompt failures are ordinary failures (no create/attach transaction
|
||||
// exists anymore): the strip renders promptError, the draft stays in the
|
||||
// machine, and the user resubmits.
|
||||
@@ -92,12 +96,33 @@ export function InputBar({
|
||||
if (!locked) inputRef.current?.focus()
|
||||
}, [locked])
|
||||
|
||||
// Active conversation scrollport: chain the wheel. While the textarea (capped
|
||||
// at 14 lines with overflow-y:auto) can still move in this direction, keep
|
||||
// the native scroll; only at its own edge forward delta to the host so a
|
||||
// short draft never traps the gesture and a long draft stays scrollable.
|
||||
// Hero mounts have no host and keep native wheel scrolling.
|
||||
useEffect(() => {
|
||||
const el = inputRef.current
|
||||
if (el === null) return
|
||||
const onWheel = (e: WheelEvent): void => {
|
||||
const host = el.closest('[data-conversation-scroll]')
|
||||
if (!(host instanceof HTMLElement) || e.deltaY === 0) return
|
||||
const atTop = el.scrollTop <= 0
|
||||
const atEnd = el.scrollTop + el.clientHeight >= el.scrollHeight - 1
|
||||
if ((e.deltaY < 0 && !atTop) || (e.deltaY > 0 && !atEnd)) return
|
||||
e.preventDefault()
|
||||
host.scrollTop += e.deltaY
|
||||
}
|
||||
el.addEventListener('wheel', onWheel, { passive: false })
|
||||
return () => { el.removeEventListener('wheel', onWheel) }
|
||||
}, [])
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
|
||||
// Shift+Enter is the native newline UNCONDITIONALLY — decided before the
|
||||
// IME guard so a composition-closing Shift+Enter still breaks the line.
|
||||
if (e.key === 'Enter' && e.shiftKey) return
|
||||
// keyCode 229 is the legacy IME-composition signal engines emit without isComposing.
|
||||
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
||||
// oxlint-disable-next-line typescript/no-deprecated
|
||||
const composing = composingRef.current || e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229
|
||||
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||||
if (keyboard.arbitrate(e.key === 'ArrowUp' ? 'up' : 'down', composing) === 'consumed') e.preventDefault()
|
||||
@@ -157,8 +182,8 @@ export function InputBar({
|
||||
if (machineBusy) return // submitting is the read-only span; adjudicating holds the pending lock
|
||||
const next = e.target.value
|
||||
keyboard.setDraft(next)
|
||||
// selectionStart is number|null in lib.dom; the eslint program narrows it.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
// selectionStart is number|null in lib.dom; the type-aware lint program narrows it.
|
||||
// oxlint-disable-next-line typescript/no-unnecessary-condition
|
||||
keyboard.track(next, e.target.selectionStart ?? next.length)
|
||||
}
|
||||
|
||||
@@ -170,13 +195,13 @@ export function InputBar({
|
||||
// too (one char = one step). Mouse selection of a chip is handled in the
|
||||
// backdrop click handler below. Undo/redo must NOT reach the browser: the
|
||||
// machine owns the transaction log.
|
||||
// selectionStart/End are number|null in lib.dom; the eslint program narrows them.
|
||||
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
|
||||
// selectionStart/End are number|null in lib.dom; the type-aware lint program narrows them.
|
||||
/* oxlint-disable typescript/no-unnecessary-condition */
|
||||
const selectionOf = (el: HTMLTextAreaElement) => ({
|
||||
start: el.selectionStart ?? 0,
|
||||
end: el.selectionEnd ?? el.selectionStart ?? 0,
|
||||
})
|
||||
/* eslint-enable @typescript-eslint/no-unnecessary-condition */
|
||||
/* oxlint-enable typescript/no-unnecessary-condition */
|
||||
|
||||
const onCopyOrCut = (e: React.ClipboardEvent<HTMLTextAreaElement>, cut: boolean): void => {
|
||||
const el = e.currentTarget
|
||||
@@ -357,7 +382,12 @@ export function InputBar({
|
||||
}
|
||||
pushPlain(draft.length)
|
||||
if (deco.hint !== null) {
|
||||
backdrop.push(<span key="hint" className={css.hint} data-decoration="hint">{deco.hint}</span>)
|
||||
// Claim tokens are shaped `/name ` (trailing space); trim to the bare name.
|
||||
const commandName = input.claim?.token.slice(1).trim() ?? ''
|
||||
const hintKey = commandName === 'goal' && hasGoal ? 'goal.active' : commandName
|
||||
const translated = translateHint(hintKey)
|
||||
const displayHint = translated !== hintKey ? translated : deco.hint
|
||||
backdrop.push(<span key="hint" className={css.hint} data-decoration="hint">{displayHint}</span>)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,6 +406,7 @@ export function InputBar({
|
||||
{dropError !== null && <div className={css.error} role="alert">{dropError}</div>}
|
||||
<div
|
||||
className={clsx(css.card, dragActive && css.dragActive)}
|
||||
data-composer-card
|
||||
onDragEnter={onDragEnter}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
@@ -423,7 +454,7 @@ export function InputBar({
|
||||
data-phase={input.phase}
|
||||
placeholder={placeholder ?? (disabled
|
||||
? 'Session unavailable'
|
||||
: planActive ? 'describe your task to generate plan' : 'Message the agent')}
|
||||
: planActive ? translateHint('placeholder.plan') : translateHint('placeholder.default'))}
|
||||
rows={2}
|
||||
onChange={(event) => {
|
||||
setDropError(null)
|
||||
|
||||
@@ -1,49 +1,43 @@
|
||||
/* Composer bottom-row permission chip (draft start.jpeg `Read-only ∨`): a
|
||||
quiet text chip with a chevron; hover paints the standard interactive pill.
|
||||
The native select is stretched invisibly over the chip so the platform
|
||||
dropdown does the menu work — keyboard/AT semantics come free. */
|
||||
|
||||
.root {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chip {
|
||||
.trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
pointer-events: none; /* the overlaid select owns the interaction */
|
||||
}
|
||||
|
||||
.root:hover .chip {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.chevron {
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
/* Invisible native select stretched over the chip: real menu, zero drawing. */
|
||||
.select {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
min-width: 0;
|
||||
max-width: 220px;
|
||||
height: 28px;
|
||||
padding: 0 4px 0 8px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.select:disabled {
|
||||
.trigger:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.trigger:focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--dsw-alias-border-l3);
|
||||
}
|
||||
|
||||
.trigger:disabled {
|
||||
color: var(--dsw-alias-label-dimmed);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.root:has(.select:disabled) .chip {
|
||||
opacity: 0.5;
|
||||
.triggerLabel {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
flex: 0 0 auto;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,14 @@
|
||||
// PermissionSelect: the composer bottom-row permission chip (draft
|
||||
// start.jpeg's `Read-only ∨` control), the Access seat's wired occupant.
|
||||
// Options and the current value read from the host-computed `permissions`
|
||||
// projection (baseline block + push frames — no fetch, no mount timing);
|
||||
// key absence (a permission-less composition, or a Draft with no host
|
||||
// session yet) renders nothing. The visible chip is presentation only — an
|
||||
// invisible native select stretched over it owns the menu and interaction.
|
||||
// A switch submits the `/permission <preset>` command line (the one write
|
||||
// path); the control shows the picked value optimistically and disables
|
||||
// until the admission result, then re-follows the projection — the pushed
|
||||
// frame confirms the switch, and a failed/unmatched submit falls back to
|
||||
// the still-authoritative projection value (`custom` is shown as the
|
||||
// current value but never offered as a target — the host omits it from
|
||||
// switchable options).
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { PermissionSelect as PermissionSelectValue } from '@deepseek-ai/dsh-permission/client'
|
||||
import { Menu } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { MenuEntry } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import css from './PermissionSelect.module.css'
|
||||
|
||||
/**
|
||||
* Display transform: kebab-case machine names render as title-case labels
|
||||
* (`workspace-write` → `Workspace Write`). Presentation-only — the wire
|
||||
* vocabulary and the host's advertised names are untouched; a host-configured
|
||||
* name that is not kebab-case (contains spaces or uppercase) passes through.
|
||||
* (`workspace-write` → `Workspace Write`); non-kebab host-configured names
|
||||
* pass through. Twin of the /permission popup's (client ui-permission) — the
|
||||
* two permission surfaces must show the same text.
|
||||
*/
|
||||
function displayName(name: string): string {
|
||||
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) return name
|
||||
@@ -29,52 +16,57 @@ function displayName(name: string): string {
|
||||
}
|
||||
|
||||
export interface PermissionSelectProps {
|
||||
/** The host-computed select, or undefined while the capability is absent. */
|
||||
value: PermissionSelectValue | undefined
|
||||
/** Session-removed lock (the bar's chrome disable state). */
|
||||
locked: boolean
|
||||
/** Submit one slash-command line; resolves admission (false = rejected/unmatched). */
|
||||
command: (line: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
export function PermissionSelect({ value, locked, command }: PermissionSelectProps) {
|
||||
// Optimistic pick, shown while the admission round-trip runs; null follows
|
||||
// the projection (the pushed frame lands the confirmed value there).
|
||||
const [pick, setPick] = useState<string | null>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
if (value === undefined) return null
|
||||
|
||||
const currentValue = pick ?? value.currentValue
|
||||
const current = value.options.find(option => option.value === currentValue)
|
||||
const busy = pick !== null
|
||||
|
||||
const onChange = (next: string): void => {
|
||||
if (next === value.currentValue) return
|
||||
setPick(next)
|
||||
void command(`/permission ${next}`)
|
||||
const items: MenuEntry[] = value.options
|
||||
.filter(o => o.value !== 'custom')
|
||||
.map(option => ({ id: option.value, label: displayName(option.name) }))
|
||||
|
||||
const choose = (id: string): void => {
|
||||
setOpen(false)
|
||||
if (id === value.currentValue) return
|
||||
setPick(id)
|
||||
void command(`/permission ${id}`)
|
||||
.catch(() => false)
|
||||
.then(() => { setPick(null) })
|
||||
}
|
||||
|
||||
return (
|
||||
<label className={css.root} title={current?.description}>
|
||||
<span className={css.chip}>
|
||||
{displayName(current?.name ?? currentValue)}
|
||||
<svg className={css.chevron} viewBox="0 0 12 12" width="12" height="12" aria-hidden>
|
||||
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</svg>
|
||||
</span>
|
||||
<select
|
||||
className={css.select}
|
||||
aria-label="Access mode"
|
||||
value={currentValue}
|
||||
disabled={locked || pick !== null}
|
||||
onChange={(e) => { onChange(e.target.value) }}
|
||||
>
|
||||
{value.options.map(option => (
|
||||
<option key={option.value} value={option.value} disabled={option.value === 'custom'}>
|
||||
{displayName(option.name)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<Menu
|
||||
open={open}
|
||||
items={items}
|
||||
selectedId={currentValue}
|
||||
onSelect={choose}
|
||||
onClose={() => { setOpen(false) }}
|
||||
side="top"
|
||||
anchor={
|
||||
<button
|
||||
type="button"
|
||||
className={css.trigger}
|
||||
aria-label={`Access mode, current: ${displayName(current?.name ?? currentValue)}`}
|
||||
title={current?.description}
|
||||
disabled={locked || busy}
|
||||
onClick={() => { setOpen(!open) }}
|
||||
>
|
||||
<span className={css.triggerLabel}>{displayName(current?.name ?? currentValue)}</span>
|
||||
<svg className={css.chevron} viewBox="0 0 12 12" width="12" height="12" aria-hidden>
|
||||
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</svg>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ function progressLabel(todos: readonly TodoItem[]): string {
|
||||
}
|
||||
|
||||
export function TodoPanel({ todos }: TodoPanelProps) {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const [collapsed, setCollapsed] = useState(true)
|
||||
if (todos.length === 0) return null
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
/* Bash toolview: same geometry/tokens as ToolRow (figma Bash · description). */
|
||||
/* Bash toolview: same geometry/tokens as ToolRow (figma Bash · description),
|
||||
plus the terminal card the row stacks under its summary line. */
|
||||
|
||||
/* Summary line over the terminal card; the summary row keeps its own 24px
|
||||
height, so the card is a column around it rather than a change to it. */
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap),
|
||||
and replaces the primitive's standalone vertical margin with the flow's. */
|
||||
.terminal {
|
||||
margin: 4px 0 4px 22px;
|
||||
}
|
||||
|
||||
.root {
|
||||
position: relative; /* sweep-glare overlay anchor */
|
||||
|
||||
@@ -3,10 +3,20 @@
|
||||
// Product chrome matches ToolRow / Think (figma: Bash · {description}).
|
||||
// Child sessions keep a scoped badge so session-dimension differentiation stays
|
||||
// observable inside the component (no parallel registry).
|
||||
//
|
||||
// A bash call declares the terminal render intent, so this row also renders
|
||||
// the command's own output through TerminalBlock. This row has no expand
|
||||
// control and is not a details-panel target either (tool rows stopped being
|
||||
// one), so its terminal body is resident rather than expand-gated as in
|
||||
// ToolRow, and the card's own copy and expand controls are the row's only
|
||||
// interactions. CHAT_TERMINAL_MAX_LINES is passed as `maxLines` — the chat
|
||||
// flow's tighter cap over the block's own default of 16 — and the block's
|
||||
// internal expander keeps a long output from taking over the message flow.
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { IconApiOutline14, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconApiOutline14, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ToolRowProps } from '../contract/slots.ts'
|
||||
import { CHAT_TERMINAL_MAX_LINES, terminalCardModel } from '../contract/terminal-card-model.ts'
|
||||
import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts'
|
||||
import css from './bash-sample.module.css'
|
||||
|
||||
@@ -29,24 +39,40 @@ function stateStatus(state: ToolRowState): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
/** Bash row: icon + Bash · {description}, matching the shared ToolRow chrome. */
|
||||
/**
|
||||
* Bash row: icon + Bash · {description} in the shared ToolRow chrome, with the
|
||||
* command's terminal card resident below it. The summary row is not a
|
||||
* details-panel control (tool rows stopped being one), so the card's copy and
|
||||
* expand controls are the row's only interactions.
|
||||
*/
|
||||
export function BashRow({ toolName, block, sessionId, useSessions }: ToolRowProps) {
|
||||
const model = toolRowModel(toolName, block)
|
||||
// Session workspace root: the terminal view's cwd resolves against it (an
|
||||
// omitted workdir IS the workspace), which the pure presenter cannot do.
|
||||
const cwd = useSessions(list => list.byId[sessionId]?.cwd)
|
||||
const terminal = terminalCardModel(block, cwd)
|
||||
const isChild = useSessions(list => list.byId[sessionId]?.parentId !== undefined)
|
||||
const status = stateStatus(model.state)
|
||||
return (
|
||||
<div
|
||||
className={css.root}
|
||||
data-sample={isChild ? 'bash-scoped' : 'bash-global'}
|
||||
data-variant="bash"
|
||||
data-state={model.state}
|
||||
>
|
||||
<span className={css.leading}>{leadingFor(model.state)}</span>
|
||||
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
|
||||
{isChild && <span className={css.scopeBadge}>scoped</span>}
|
||||
<span className={css.title}>{model.title}</span>
|
||||
<span className={css.sep} aria-hidden />
|
||||
<span className={css.summary}>{model.summary}</span>
|
||||
<div className={css.card}>
|
||||
<div
|
||||
className={css.root}
|
||||
data-sample={isChild ? 'bash-scoped' : 'bash-global'}
|
||||
data-variant="bash"
|
||||
data-state={model.state}
|
||||
>
|
||||
<span className={css.leading}>{leadingFor(model.state)}</span>
|
||||
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
|
||||
{isChild && <span className={css.scopeBadge}>scoped</span>}
|
||||
<span className={css.title}>{model.title}</span>
|
||||
<span className={css.sep} aria-hidden />
|
||||
{/* The terminal presenter's description is the contractual
|
||||
above-card summary; it outranks the args-derived one. */}
|
||||
<span className={css.summary}>{terminal?.description ?? model.summary}</span>
|
||||
</div>
|
||||
{terminal !== null && (
|
||||
<TerminalBlock {...terminal.card} maxLines={CHAT_TERMINAL_MAX_LINES} className={css.terminal} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { SessionBehaviorOverrides } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { ISession, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type {
|
||||
@@ -49,6 +50,7 @@ async function bench() {
|
||||
})
|
||||
const layoutFake = { openDetails: vi.fn(), closeDetails: vi.fn() }
|
||||
runtime.provide('layout', layoutFake)
|
||||
runtime.provide('locale', new LocaleService(runtime.ctx))
|
||||
|
||||
// The AppFrame role: the conversation-package slots must be declared by a
|
||||
// live entry before apply can contribute into them.
|
||||
|
||||
245
packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx
Normal file
245
packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Assembly-level acceptance on SlotTestRuntime (real apply, real slot
|
||||
* machinery, real renderer; data fed as fixtures) for surfaces that were
|
||||
* previously pinned only by the assembled-app jsdom snapshots
|
||||
* (apps/web/tests/{todo-display,terminal-card,slash-flow}.snapshot.ts):
|
||||
*
|
||||
* - the todo_write turn reaches BOTH surfaces through the product
|
||||
* registrations (keyed toolview row in the flow, plan strip in the input
|
||||
* dock via the 'todos' projection) and the strip follows projection
|
||||
* retirement;
|
||||
* - the bash keyed row carries its resident terminal card, and the fallback
|
||||
* row reaches the same card through its expand control;
|
||||
* - the resident composer textarea survives the blank→active conversion as
|
||||
* the SAME DOM node (focus/IME continuity rides React reconciliation:
|
||||
* component identity + tree position, which this assembled tree pins).
|
||||
*
|
||||
* Component-level behavior (collapse interaction, card model arms, summary
|
||||
* derivations) lives in todo-panel.spec.tsx / terminal-card.spec.tsx; this
|
||||
* suite only proves the assembled wiring.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, waitFor, within } from '@testing-library/react'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { ISession, SessionId, TodoItem, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
|
||||
afterEach(cleanup)
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
const TODOS: TodoItem[] = [
|
||||
{ content: '梳理需求', status: 'completed' },
|
||||
{ content: '实现 fixture 样本', status: 'in_progress' },
|
||||
{ content: '浏览器验收', status: 'pending' },
|
||||
]
|
||||
|
||||
const todoResult = (seq: number): ToolResultNode => ({
|
||||
kind: 'tool-result', seq, time: seq * 1_000, callId: `todo-${seq}`,
|
||||
call: { name: 'todo_write', argsRaw: JSON.stringify({ todos: TODOS }) },
|
||||
callTime: seq * 1_000 - 500,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
})
|
||||
|
||||
const bashResult = (seq: number, callId: string, over?: Partial<ToolResultNode>): ToolResultNode => ({
|
||||
kind: 'tool-result', seq, time: seq * 1_000, callId,
|
||||
call: { name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}' },
|
||||
callTime: seq * 1_000 - 500,
|
||||
content: [{ type: 'text', text: 'total 2\ndemo.txt\n' }], isError: false,
|
||||
callView: { card: 'terminal', title: 'ls -la', description: 'List files' },
|
||||
resultView: { card: 'terminal', output: 'total 2\ndemo.txt\n', exitCode: 0 },
|
||||
...over,
|
||||
})
|
||||
|
||||
/** Test-owned AppFrame role: declares and renders the resident conversation area. */
|
||||
type AppRootProps = PropsRenderSlots<'conversation' | 'details'>
|
||||
function AppRoot({ renderSlot }: AppRootProps) {
|
||||
return <>{renderSlot('conversation', {})}</>
|
||||
}
|
||||
|
||||
const LAYOUT_CHILDREN = {
|
||||
'conversation': { kind: 'single', scope: 'session-maybe' },
|
||||
'details': { kind: 'single', scope: 'session' },
|
||||
} as const
|
||||
|
||||
async function bench(nodes: ToolResultNode[], opts?: { blank?: boolean }) {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
runtime.provide('locale', new LocaleService(runtime.ctx))
|
||||
await runtime.sessions.add({
|
||||
id: SID,
|
||||
summary: { title: 'S', displayTitle: 'S', cwd: '/proj' },
|
||||
snapshot: {
|
||||
nodes,
|
||||
...(opts?.blank === true ? { blank: true, composerPhase: 'blank' as const } : {}),
|
||||
},
|
||||
session: {
|
||||
loadOlder: vi.fn<ISession['loadOlder']>(),
|
||||
prompt: vi.fn<ISession['prompt']>(async () => ({ ok: true, value: { accepted: true } })),
|
||||
},
|
||||
})
|
||||
await runtime.root.declare(LAYOUT_CHILDREN, AppRoot)
|
||||
await runtime.mount({ inject: [...inject], apply })
|
||||
return runtime
|
||||
}
|
||||
|
||||
describe('todo_write assembly (product registrations, no outlet twins)', () => {
|
||||
it('reaches the keyed toolview row and the dock plan strip, and the strip follows projection retirement', async () => {
|
||||
const runtime = await bench([todoResult(3)])
|
||||
// The dock strip reads the host-computed 'todos' projection.
|
||||
runtime.sessions.behavior(SID).projections.set('todos', TODOS)
|
||||
const view = runtime.renderRoot()
|
||||
|
||||
// Keyed toolview registration took the row (summary derived from args).
|
||||
const row = view.container.querySelector('[data-sample="todo-row"]')
|
||||
expect(row).not.toBeNull()
|
||||
expect(row!.textContent).toContain('1/3 已完成 · 实现 fixture 样本')
|
||||
|
||||
// The plan strip sits in the input dock, fed by the projection
|
||||
// (default-collapsed: the header summary shows; rows appear on expand).
|
||||
const panel = view.container.querySelector('[data-testid="todo-panel"]')
|
||||
expect(panel).not.toBeNull()
|
||||
expect(panel!.textContent).toContain('1/3 tasks · 1 in progress')
|
||||
fireEvent.click(panel!.querySelector('button')!)
|
||||
expect([...panel!.querySelectorAll('li')].map(li => li.getAttribute('data-status')))
|
||||
.toEqual(['completed', 'in_progress', 'pending'])
|
||||
|
||||
// Next turn retires the standing plan (host pushes null): the strip
|
||||
// clears while the historical row stays in the flow.
|
||||
await runtime.flush()
|
||||
runtime.sessions.behavior(SID).projections.set('todos', null)
|
||||
await waitFor(() => {
|
||||
expect(view.container.querySelector('[data-testid="todo-panel"]')).toBeNull()
|
||||
})
|
||||
expect(view.container.querySelector('[data-sample="todo-row"]')).not.toBeNull()
|
||||
await runtime.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('terminal card assembly', () => {
|
||||
it('the keyed bash row carries a resident terminal card; the fallback row reaches one through expand', async () => {
|
||||
const runtime = await bench([
|
||||
bashResult(3, 'c-keyed'),
|
||||
// An unregistered tool with terminal views: GenericToolCard fallback.
|
||||
bashResult(4, 'c-fallback', { call: { name: 'fx-bash', argsRaw: '{"command":"ls -la"}' } }),
|
||||
])
|
||||
const view = runtime.renderRoot()
|
||||
|
||||
// Keyed BashRow renders the card residently (no expand gesture).
|
||||
const keyed = view.container.querySelector('[data-sample="bash-global"]')?.parentElement
|
||||
expect(keyed?.querySelector('[data-terminal]')).not.toBeNull()
|
||||
|
||||
// Fallback row: card appears only after its expand control.
|
||||
const fallback = view.container.querySelector('[data-tool="fx-bash"]')
|
||||
expect(fallback).not.toBeNull()
|
||||
expect(fallback!.querySelector('[data-terminal]')).toBeNull()
|
||||
fireEvent.click(fallback!.querySelector('button[aria-expanded]')!)
|
||||
await waitFor(() => {
|
||||
expect(fallback!.querySelector('[data-terminal]')).not.toBeNull()
|
||||
})
|
||||
await runtime.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resident composer', () => {
|
||||
it('renders the locked view state while no session exists at all', async () => {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
runtime.provide('locale', new LocaleService(runtime.ctx))
|
||||
await runtime.root.declare(LAYOUT_CHILDREN, AppRoot)
|
||||
await runtime.mount({ inject: [...inject], apply })
|
||||
const view = runtime.renderRoot()
|
||||
// No session entity: the inert twin renders (disabled textarea), and the
|
||||
// workspace picker chip is the only live control.
|
||||
const textarea = view.container.querySelector('textarea')
|
||||
expect(textarea).not.toBeNull()
|
||||
expect(textarea!.disabled).toBe(true)
|
||||
expect(view.getByRole('button', { name: 'Choose workspace' })).toBeTruthy()
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
|
||||
it('the textarea survives the blank→active conversion as the same DOM node', async () => {
|
||||
const runtime = await bench([], { blank: true })
|
||||
// The hero renders the LIVE composer only when the blank session's
|
||||
// workspace resolves a chip title; an ownerless blank session shows the
|
||||
// disabled twin instead (deleted-workspace semantics).
|
||||
await runtime.workspaces.update((draft) => {
|
||||
draft.items = [{ workspaceId: 'w1', title: 'Proj', path: '/proj', sessionIds: [SID] }] as never
|
||||
})
|
||||
const view = runtime.renderRoot()
|
||||
const hero = view.container.querySelector('textarea')
|
||||
expect(hero).not.toBeNull()
|
||||
expect(hero!.disabled).toBe(false)
|
||||
|
||||
// First acceptance: the session leaves blank and the composer docks.
|
||||
await runtime.sessions.updateSnapshot(SID, (draft) => {
|
||||
draft.blank = false
|
||||
draft.composerPhase = 'active'
|
||||
})
|
||||
const docked = view.container.querySelector('textarea')
|
||||
expect(docked).toBe(hero)
|
||||
await runtime.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('prompt rejection through the assembled composer', () => {
|
||||
it('renders the promptError alert strip and keeps the draft in the machine', async () => {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
runtime.provide('locale', new LocaleService(runtime.ctx))
|
||||
const prompt = vi.fn<ISession['prompt']>(async () => ({
|
||||
ok: false, error: { code: 'agent-busy', message: 'prompt rejected before acceptance', details: { reason: 'busy' } },
|
||||
}))
|
||||
await runtime.sessions.add({
|
||||
id: SID,
|
||||
summary: { title: 'S', displayTitle: 'S', cwd: '/proj' },
|
||||
session: { prompt, loadOlder: vi.fn<ISession['loadOlder']>() },
|
||||
})
|
||||
await runtime.root.declare(LAYOUT_CHILDREN, AppRoot)
|
||||
await runtime.mount({ inject: [...inject], apply })
|
||||
const view = runtime.renderRoot()
|
||||
|
||||
const composer = view.container.querySelector('textarea')!
|
||||
fireEvent.change(composer, { target: { value: 'do not lose this' } })
|
||||
fireEvent.keyDown(composer, { key: 'Enter' })
|
||||
await waitFor(() => { expect(prompt).toHaveBeenCalledOnce() })
|
||||
|
||||
// The rejection lands in snapshot.promptError (the Session's own path);
|
||||
// the fixture mirrors that hop — the assembled InputBar renders it.
|
||||
await runtime.sessions.updateSnapshot(SID, (draft) => {
|
||||
draft.promptError = {
|
||||
op: 'send',
|
||||
error: { code: 'agent-busy', message: 'prompt rejected before acceptance', details: { reason: 'busy' } },
|
||||
}
|
||||
})
|
||||
const alert = await view.findByRole('alert')
|
||||
expect(alert.textContent).toContain('prompt rejected before acceptance (agent-busy)')
|
||||
// Failure restore: the machine returned the draft to the same textarea.
|
||||
await waitFor(() => {
|
||||
expect((view.container.querySelector('textarea'))!.value).toBe('do not lose this')
|
||||
})
|
||||
await runtime.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('title projection across assembled surfaces', () => {
|
||||
it('one summary update re-labels the breadcrumb and document.title consumers together', async () => {
|
||||
const runtime = await bench([])
|
||||
const view = runtime.renderRoot()
|
||||
// The strict session header breadcrumb reads useSessions ancestry.
|
||||
const crumb = within(view.container.querySelector('[aria-label="Session hierarchy"]') as HTMLElement)
|
||||
expect(crumb.getByText('S')).toBeTruthy()
|
||||
|
||||
await runtime.sessions.updateSummary(SID, { displayTitle: '修订标题', title: '修订标题' })
|
||||
await waitFor(() => { expect(crumb.getByText('修订标题')).toBeTruthy() })
|
||||
expect(crumb.queryByText('S')).toBeNull()
|
||||
await runtime.dispose()
|
||||
})
|
||||
})
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
|
||||
@@ -22,6 +23,7 @@ async function bench() {
|
||||
await runtime.sessions.add(
|
||||
{ id: CHILD, summary: { title: 'C', displayTitle: 'C', parentId: ROOT } }, { current: false })
|
||||
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
runtime.provide('locale', new LocaleService(runtime.ctx))
|
||||
|
||||
// Declared by ui-layout's root entry in production; the test root declares
|
||||
// them here so the contributions land.
|
||||
@@ -84,6 +86,8 @@ describe('apply wiring', () => {
|
||||
// service being present implies the chat entry declared the hole first.
|
||||
const entries = b.slots.entries('conversation.chat.toolview')
|
||||
expect(entries.map(e => e.options.key)).toEqual(['bash', 'todo_write'])
|
||||
// Stats stick with the composer (not inside ChatView).
|
||||
expect(b.slots.entries('conversation.composer.dock').map(e => e.options.id)).toEqual(['stats'])
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
ToolResultNode, WorkspaceListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
|
||||
@@ -125,7 +126,7 @@ async function bench(snapshot: ConversationSnapshot) {
|
||||
}
|
||||
ctx.provide('workspaces', workspaces)
|
||||
ctx.provide('layout', layout)
|
||||
ctx.provide('i18n', { bind: () => (key: string) => key })
|
||||
ctx.provide('locale', new LocaleService(ctx))
|
||||
|
||||
slots.install(createSlotRenderer())
|
||||
slots.register({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @vitest-environment jsdom
|
||||
// StatsLine (rendered inside the chat view body): totals derivation + the RFC
|
||||
// StatsLine (composer.dock entry): totals derivation + the RFC
|
||||
// hard acceptance — zero renders during streaming. Bash sample row: the
|
||||
// canonical sub-agent differential decided INSIDE the component off the
|
||||
// standard useSessions kit (no registry predicates — tool ring dissolved).
|
||||
|
||||
@@ -97,6 +97,11 @@ describe('tool-call-model', () => {
|
||||
expect(toolRowModel('bash', result({ call: null })).body).toBeNull()
|
||||
})
|
||||
|
||||
it('a code row with an empty program falls back to the args JSON envelope', () => {
|
||||
expect(toolRowModel('run_code', running({ name: 'run_code', argsRaw: '{"code":""}' })).body)
|
||||
.toBe('{\n "code": ""\n}')
|
||||
})
|
||||
|
||||
it('gives Cordis lifecycle tools action titles over their generic variants', () => {
|
||||
expect(toolRowModel('cordis_inspect', running({
|
||||
name: 'cordis_inspect',
|
||||
@@ -165,6 +170,22 @@ describe('ToolRow', () => {
|
||||
expect(view.queryByTestId('tool-icon')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('an expandOnRowClick row toggles from Enter and Space, ignoring other keys', () => {
|
||||
const view = render(<ToolRow {...rowProps} expandOnRowClick />)
|
||||
const row = view.getByRole('button')
|
||||
fireEvent.keyDown(row, { key: 'Tab' })
|
||||
expect(row.getAttribute('aria-expanded')).toBe('false')
|
||||
fireEvent.keyDown(row, { key: 'Enter' })
|
||||
expect(row.getAttribute('aria-expanded')).toBe('true')
|
||||
fireEvent.keyDown(row, { key: ' ' })
|
||||
expect(row.getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('a non-expandable expandOnRowClick row exposes no row button', () => {
|
||||
const view = render(<ToolRow {...rowProps} body={null} expandOnRowClick />)
|
||||
expect(view.queryByRole('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('file-path summary opens through onOpenFile; the leading slot is not an expand control', () => {
|
||||
const open = vi.fn()
|
||||
const view = render(
|
||||
|
||||
@@ -15,6 +15,7 @@ import { cleanup, fireEvent } from '@testing-library/react'
|
||||
import type { ISession, SessionId, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
|
||||
@@ -53,6 +54,7 @@ async function bench(nodes: ToolResultNode[]) {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
const layout = { openDetails: vi.fn(), closeDetails: vi.fn() }
|
||||
runtime.provide('layout', layout)
|
||||
runtime.provide('locale', new LocaleService(runtime.ctx))
|
||||
await runtime.sessions.add({
|
||||
id: SID,
|
||||
summary: { title: 'S', displayTitle: 'S' },
|
||||
@@ -180,6 +182,7 @@ describe('registrant load-order seam', () => {
|
||||
it("suspends a registrant on inject: ['slots', 'conversation'] until the service (and the hole) exists", async () => {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
runtime.provide('locale', new LocaleService(runtime.ctx))
|
||||
await runtime.root.declare(LAYOUT_CHILDREN, AppRoot)
|
||||
|
||||
// Third-party posture, mounted BEFORE ui-conversation: real fiber inject
|
||||
|
||||
@@ -378,6 +378,42 @@ describe('ChatView', () => {
|
||||
expect(view.queryByLabelText('回到底部')).toBeNull()
|
||||
})
|
||||
|
||||
it('entering the at-bottom threshold does not snap the remaining scroll distance', () => {
|
||||
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1000, writable: true })
|
||||
Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true })
|
||||
// Inside FOLLOW_THRESHOLD (24) but not flush with the floor — the chrome
|
||||
// re-render from setAtBottom must not force scrollTop to scrollHeight.
|
||||
scroller.scrollTop = 690 // distance-to-bottom = 10
|
||||
fireEvent.scroll(scroller)
|
||||
expect(view.queryByLabelText('回到底部')).toBeNull()
|
||||
expect(scroller.scrollTop).toBe(690)
|
||||
})
|
||||
|
||||
it('under data-conversation-scroll, bottom-follow targets the host scrollport', () => {
|
||||
const host = document.createElement('div')
|
||||
host.setAttribute('data-conversation-scroll', '')
|
||||
Object.defineProperty(host, 'scrollHeight', { value: 2000, writable: true, configurable: true })
|
||||
Object.defineProperty(host, 'clientHeight', { value: 500, writable: true, configurable: true })
|
||||
Object.defineProperty(host, 'scrollTop', { value: 0, writable: true, configurable: true })
|
||||
document.body.appendChild(host)
|
||||
try {
|
||||
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
|
||||
const view = render(<h.ChatView {...h.props} />, { container: host })
|
||||
// Open jump uses the host, not the local .scroll node.
|
||||
expect(host.scrollTop).toBe(2000)
|
||||
host.scrollTop = 100
|
||||
fireEvent.scroll(host)
|
||||
expect(view.getByLabelText('回到底部')).toBeTruthy()
|
||||
fireEvent.click(view.getByLabelText('回到底部'))
|
||||
expect(host.scrollTop).toBe(2000)
|
||||
} finally {
|
||||
host.remove()
|
||||
}
|
||||
})
|
||||
|
||||
it('paging button loads older and shows its busy label', () => {
|
||||
const h = makeHarness({ nodes: [user(5, 'later')], hasMore: true })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
|
||||
@@ -43,6 +43,7 @@ interface BenchOptions {
|
||||
promptError?: ConversationSnapshot['promptError']
|
||||
variant?: 'hero' | 'composer'
|
||||
placeholder?: string
|
||||
translateHint?: (key: string) => string
|
||||
accessory?: React.ReactNode
|
||||
overlay?: React.ReactNode
|
||||
leftItems?: React.ReactNode
|
||||
@@ -111,6 +112,11 @@ function bench(over?: BenchOptions) {
|
||||
useLexicon: bindSnapshotSelector(shell.lexicon),
|
||||
stop,
|
||||
command: () => Promise.resolve(true),
|
||||
// Mirrors the en 'command.hint' locale entries the production apply wires in.
|
||||
translateHint: over?.translateHint ?? ((key: string) => ({
|
||||
'placeholder.default': 'Message the agent',
|
||||
'placeholder.plan': 'describe your task to generate plan',
|
||||
} as Record<string, string>)[key] ?? key),
|
||||
renderSlot,
|
||||
variant: over?.variant ?? 'composer',
|
||||
...(over?.placeholder !== undefined ? { placeholder: over.placeholder } : {}),
|
||||
@@ -238,6 +244,56 @@ describe('running and lock semantics (queue cut 1)', () => {
|
||||
expect((textarea).value).toBe('typed')
|
||||
})
|
||||
|
||||
it('wheel over a non-overflowing textarea forwards to the conversation host', () => {
|
||||
const host = document.createElement('div')
|
||||
host.setAttribute('data-conversation-scroll', '')
|
||||
Object.defineProperty(host, 'scrollTop', { value: 40, writable: true, configurable: true })
|
||||
const { view, textarea } = bench()
|
||||
host.appendChild(view.container)
|
||||
document.body.appendChild(host)
|
||||
try {
|
||||
const wheeled = fireEvent.wheel(textarea, { deltaY: 30 })
|
||||
expect(wheeled).toBe(false) // preventDefault
|
||||
expect(host.scrollTop).toBe(70)
|
||||
} finally {
|
||||
host.remove()
|
||||
}
|
||||
})
|
||||
|
||||
it('wheel chains: long drafts scroll inside the textarea until each edge, then the host', () => {
|
||||
const host = document.createElement('div')
|
||||
host.setAttribute('data-conversation-scroll', '')
|
||||
Object.defineProperty(host, 'scrollTop', { value: 40, writable: true, configurable: true })
|
||||
const { view, textarea } = bench()
|
||||
host.appendChild(view.container)
|
||||
document.body.appendChild(host)
|
||||
Object.defineProperty(textarea, 'clientHeight', { value: 100, configurable: true })
|
||||
Object.defineProperty(textarea, 'scrollHeight', { value: 400, configurable: true })
|
||||
let scrollTop = 150
|
||||
Object.defineProperty(textarea, 'scrollTop', {
|
||||
configurable: true,
|
||||
get: () => scrollTop,
|
||||
set: (value: number) => { scrollTop = value },
|
||||
})
|
||||
try {
|
||||
// Mid-draft: both directions stay local — host must not move.
|
||||
expect(fireEvent.wheel(textarea, { deltaY: 30 })).toBe(true)
|
||||
expect(fireEvent.wheel(textarea, { deltaY: -30 })).toBe(true)
|
||||
expect(host.scrollTop).toBe(40)
|
||||
// At the bottom edge, further down-scroll forwards to the host.
|
||||
scrollTop = 300
|
||||
expect(fireEvent.wheel(textarea, { deltaY: 30 })).toBe(false)
|
||||
expect(host.scrollTop).toBe(70)
|
||||
// At the top edge, further up-scroll forwards to the host.
|
||||
scrollTop = 0
|
||||
host.scrollTop = 70
|
||||
expect(fireEvent.wheel(textarea, { deltaY: -20 })).toBe(false)
|
||||
expect(host.scrollTop).toBe(50)
|
||||
} finally {
|
||||
host.remove()
|
||||
}
|
||||
})
|
||||
|
||||
it('disabled state shows the unavailable placeholder; custom placeholder wins', () => {
|
||||
const { textarea } = bench({ disabled: true })
|
||||
expect(textarea.placeholder).toBe('Session unavailable')
|
||||
@@ -303,6 +359,19 @@ describe('decorations', () => {
|
||||
expect(view.container.querySelector('[data-decoration="token"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('a locale entry for the claimed command overrides the raw claim hint (trailing-space token)', () => {
|
||||
const dict: Record<string, string> = { goal: '输入目标,智能体将持续执行' }
|
||||
const { view, shell } = bench({ translateHint: key => dict[key] ?? key })
|
||||
act(() => {
|
||||
shell.setDraft('/goal ')
|
||||
shell.beginCommand(
|
||||
{ token: '/goal ', hint: '[<objective>|clear|edit <objective>|pause|resume]', submit: () => Promise.resolve({ kind: 'success' as const }) },
|
||||
{ start: 0, end: 6, draftRev: shell.snapshot.draftRev },
|
||||
)
|
||||
})
|
||||
expect(view.container.querySelector('[data-decoration="hint"]')?.textContent).toBe('输入目标,智能体将持续执行')
|
||||
})
|
||||
|
||||
it('an inserted reference renders as a chip at its placeholder offset', () => {
|
||||
const { view, shell } = bench()
|
||||
act(() => {
|
||||
@@ -482,7 +551,7 @@ describe('placeholder chrome and control seats', () => {
|
||||
const { view, slotCalls } = bench()
|
||||
expect(view.getByLabelText('Add attachment')).toBeTruthy()
|
||||
// Capability absent (no projection value): the chip renders nothing.
|
||||
expect(view.queryByLabelText('Access mode')).toBeNull()
|
||||
expect(view.queryByLabelText(/^Access mode/)).toBeNull()
|
||||
// Both seats dispatched, nothing rendered.
|
||||
expect(slotCalls.map(c => c.key)).toEqual(['conversation.input.plan', 'conversation.input.model'])
|
||||
expect(view.queryByLabelText('Plan mode')).toBeNull()
|
||||
@@ -498,15 +567,19 @@ describe('placeholder chrome and control seats', () => {
|
||||
currentValue: 'workspace-write',
|
||||
}
|
||||
const { view } = bench({ permissions })
|
||||
const select = view.getByLabelText('Access mode') as HTMLSelectElement
|
||||
expect(select.value).toBe('workspace-write')
|
||||
// Title-case display is presentation only; the option values stay machine names.
|
||||
expect([...select.options].map(o => o.textContent)).toEqual(['Workspace Write', 'Danger Full Access'])
|
||||
fireEvent.change(select, { target: { value: 'danger-full-access' } })
|
||||
const trigger = view.getByLabelText(/^Access mode/) as HTMLButtonElement
|
||||
// Title-case display is presentation only; the menu ids stay machine names.
|
||||
expect(trigger.textContent).toBe('Workspace Write')
|
||||
fireEvent.click(trigger)
|
||||
const items = view.getAllByRole('menuitem')
|
||||
expect(items.map(o => o.textContent)).toEqual(['Workspace Write', 'Danger Full Access'])
|
||||
fireEvent.click(items[1]!)
|
||||
// Optimistic pick + disable until admission resolves (command stub resolves true).
|
||||
expect(select.disabled).toBe(true)
|
||||
const busy = view.getByLabelText(/^Access mode/) as HTMLButtonElement
|
||||
expect(busy.textContent).toBe('Danger Full Access')
|
||||
expect(busy.disabled).toBe(true)
|
||||
await act(async () => {})
|
||||
expect(select.disabled).toBe(false)
|
||||
expect((view.getByLabelText(/^Access mode/) as HTMLButtonElement).disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('a registered entry fills its seat and receives the locked owner prop', () => {
|
||||
@@ -528,9 +601,9 @@ describe('placeholder chrome and control seats', () => {
|
||||
const permissions = { options: [{ value: 'workspace-write', name: 'workspace-write' }], currentValue: 'workspace-write' }
|
||||
const { view } = bench({ disabled: true, permissions })
|
||||
expect((view.getByLabelText('Add attachment') as HTMLButtonElement).disabled).toBe(true)
|
||||
expect((view.getByLabelText('Access mode') as HTMLSelectElement).disabled).toBe(true)
|
||||
expect((view.getByLabelText(/^Access mode/) as HTMLButtonElement).disabled).toBe(true)
|
||||
cleanup()
|
||||
const live = bench({ running: true, permissions })
|
||||
expect((live.view.getByLabelText('Access mode') as HTMLSelectElement).disabled).toBe(false)
|
||||
expect((live.view.getByLabelText(/^Access mode/) as HTMLButtonElement).disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -260,10 +260,10 @@ describe('input-machine: insert-ref and the occurrence table', () => {
|
||||
m.dispatch({ type: 'insert-ref', reference: refOf('alpha'), span: spanOf(m, 0, 4) })
|
||||
m.dispatch({ type: 'draft-changed', draft: `${P} and /alp`, editRange: { start: 1, end: 1, insertedLength: 9 } })
|
||||
m.dispatch({ type: 'insert-ref', reference: refOf('alpha'), span: spanOf(m, 6, 10) })
|
||||
expect(m.state.draft).toBe(`${P} and ${P}`)
|
||||
expect(m.state.draft).toBe(`${P} and ${P} `)
|
||||
expect(m.state.occurrences.map(o => o.occurrenceId)).toEqual([1, 2])
|
||||
// Delete the first chip whole; the second survives with its own identity.
|
||||
m.dispatch({ type: 'draft-changed', draft: ` and ${P}`, editRange: { start: 0, end: 1, insertedLength: 0 } })
|
||||
m.dispatch({ type: 'draft-changed', draft: ` and ${P} `, editRange: { start: 0, end: 1, insertedLength: 0 } })
|
||||
expect(m.state.occurrences).toEqual([expect.objectContaining({ occurrenceId: 2, offset: 5 })])
|
||||
})
|
||||
|
||||
@@ -273,7 +273,7 @@ describe('input-machine: insert-ref and the occurrence table', () => {
|
||||
m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) })
|
||||
m.dispatch({ type: 'draft-changed', draft: '/goal ask @wor' })
|
||||
m.dispatch({ type: 'insert-ref', reference: refOf('worker-1', 'subagent'), span: spanOf(m, 10, 14) })
|
||||
expect(m.state.draft).toBe(`/goal ask ${P}`)
|
||||
expect(m.state.draft).toBe(`/goal ask ${P} `)
|
||||
expect(m.state.phase).toBe('claimed')
|
||||
expect(m.state.occurrences).toHaveLength(1)
|
||||
})
|
||||
@@ -350,10 +350,10 @@ describe('input-machine: newline transaction (F1)', () => {
|
||||
m.dispatch({ type: 'draft-changed', draft: 'ab @wor' })
|
||||
m.dispatch({ type: 'insert-ref', reference: refOf('w'), span: spanOf(m, 3, 7) })
|
||||
m.dispatch({ type: 'newline', selection: { start: 2, end: 2 } })
|
||||
expect(m.state.draft).toBe(`ab\n ${P}`)
|
||||
expect(m.state.draft).toBe(`ab\n ${P} `)
|
||||
expect(m.state.occurrences[0]?.offset).toBe(4)
|
||||
m.dispatch({ type: 'undo' })
|
||||
expect(m.state.draft).toBe(`ab ${P}`)
|
||||
expect(m.state.draft).toBe(`ab ${P} `)
|
||||
})
|
||||
|
||||
it('replaces a selection, breaks the claim prefix when leading, and rejects out-of-bounds', () => {
|
||||
@@ -410,7 +410,7 @@ describe('input-machine: consume-token guards', () => {
|
||||
m.dispatch({ type: 'draft-changed', draft: '/model @wor' })
|
||||
m.dispatch({ type: 'insert-ref', reference: refOf('w'), span: spanOf(m, 7, 11) })
|
||||
m.dispatch({ type: 'consume-token', guard: { kind: 'span', span: spanOf(m, 0, 7) } })
|
||||
expect(m.state.draft).toBe(P)
|
||||
expect(m.state.draft).toBe(`${P} `)
|
||||
expect(m.state.occurrences[0]?.offset).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -490,7 +490,7 @@ describe('input-machine: undo / redo', () => {
|
||||
m.dispatch({ type: 'draft-changed', draft: '', editRange: { start: 0, end: 1, insertedLength: 0 } })
|
||||
expect(m.state.occurrences).toEqual([])
|
||||
m.dispatch({ type: 'undo' })
|
||||
expect(m.state.draft).toBe(P)
|
||||
expect(m.state.draft).toBe(`${P} `)
|
||||
expect(m.state.occurrences).toHaveLength(1)
|
||||
})
|
||||
|
||||
@@ -555,9 +555,9 @@ describe('input-machine: paste plane', () => {
|
||||
m.dispatch({ type: 'paste-upgrade', attemptId: 1, span: spanOf(m, 0, 6), reference: refOf('alpha') })
|
||||
expect(m.state.paste?.insertedRange).toEqual({ start: 0, end: 7 })
|
||||
m.dispatch({ type: 'paste-upgrade', attemptId: 1, span: spanOf(m, 2, 7), reference: refOf('beta') })
|
||||
expect(m.state.draft).toBe(`${P} ${P}`)
|
||||
expect(m.state.draft).toBe(`${P} ${P} `)
|
||||
expect(m.state.occurrences.map(o => o.ref)).toEqual(['alpha', 'beta'])
|
||||
expect(m.state.paste?.insertedRange).toEqual({ start: 0, end: 3 })
|
||||
expect(m.state.paste?.insertedRange).toEqual({ start: 0, end: 4 })
|
||||
})
|
||||
|
||||
it('a stale span CAS drops one upgrade without ending the attempt', () => {
|
||||
@@ -634,8 +634,8 @@ describe('input-machine: projectClipboard', () => {
|
||||
m.dispatch({ type: 'insert-ref', reference: refOf('alpha'), span: spanOf(m, 4, 8) })
|
||||
m.dispatch({ type: 'draft-changed', draft: `use ${P} then /bet`, editRange: { start: 5, end: 5, insertedLength: 10 } })
|
||||
m.dispatch({ type: 'insert-ref', reference: refOf('beta'), span: spanOf(m, 11, 15) })
|
||||
expect(m.state.draft).toBe(`use ${P} then ${P}`)
|
||||
expect(projectClipboard(m.state)).toBe('use /alpha then /beta')
|
||||
expect(m.state.draft).toBe(`use ${P} then ${P} `)
|
||||
expect(projectClipboard(m.state)).toBe('use /alpha then /beta ')
|
||||
})
|
||||
|
||||
it('is the identity on a chip-free draft', () => {
|
||||
|
||||
@@ -51,6 +51,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
|
||||
renderSlot: (() => null) as InputBarProps['renderSlot'],
|
||||
stop: vi.fn(),
|
||||
command: () => Promise.resolve(true),
|
||||
translateHint: (key: string) => key,
|
||||
variant: 'composer',
|
||||
}
|
||||
return render(<InputBar {...props} />)
|
||||
|
||||
@@ -137,6 +137,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
|
||||
renderSlot: (() => null) as InputBarProps['renderSlot'],
|
||||
stop: vi.fn(),
|
||||
command: () => Promise.resolve(true),
|
||||
translateHint: (key: string) => key,
|
||||
variant: 'composer',
|
||||
}
|
||||
const view = render(<InputBar {...barProps} />)
|
||||
|
||||
@@ -17,7 +17,9 @@ import { ConversationRoot } from '../src/client/skeleton/ConversationRoot.tsx'
|
||||
import { ConversationSession } from '../src/client/skeleton/ConversationSession.tsx'
|
||||
import { InputBar } from '../src/client/skeleton/InputBar.tsx'
|
||||
import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
|
||||
import type { ComposerBarOwnerProps } from '../src/client/contract/slots.ts'
|
||||
import type {
|
||||
ComposerBarOwnerProps,
|
||||
} from '../src/client/contract/slots.ts'
|
||||
|
||||
/** Machine-backed wiring over a sink spy. */
|
||||
function fakeWiring() {
|
||||
@@ -59,6 +61,8 @@ function mount(
|
||||
snapshot: ConversationSnapshot,
|
||||
workspaceRows: WorkspaceView[] = [{ ...workspace('one'), sessionIds: [SID] }],
|
||||
retargetWorkspace = vi.fn(async (_workspaceId: WorkspaceId) => {}),
|
||||
/** When true, mimic overlay:true chain siblings (hidden fallback + takeover). */
|
||||
overlayTakeover = false,
|
||||
) {
|
||||
const root = sid('root')
|
||||
const sessions = createSnapshotStore<SessionListState>({
|
||||
@@ -99,10 +103,18 @@ function mount(
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
renderSlot={renderSlot as never}
|
||||
views={{ list: () => [{ id: 'chat', label: 'Chat' }], subscribe: () => () => {}, version: () => 1 }}
|
||||
views={{
|
||||
list: () => [
|
||||
{ id: 'chat', label: 'Chat' },
|
||||
{ id: 'trajectory', label: 'Trajectory' },
|
||||
],
|
||||
subscribe: () => () => {},
|
||||
version: () => 1,
|
||||
}}
|
||||
releaseSessionImages={vi.fn()}
|
||||
bindDraftMirror={write => wiring.bindMirror(write)}
|
||||
open={open}
|
||||
{...owner}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -128,6 +140,7 @@ function mount(
|
||||
useLexicon={bindSnapshotSelector(wiring.lexicon)}
|
||||
stop={stop}
|
||||
command={() => Promise.resolve(true)}
|
||||
translateHint={(key: string) => key}
|
||||
renderSlot={(() => null) as InputBarProps['renderSlot']}
|
||||
{...bar}
|
||||
/>
|
||||
@@ -135,7 +148,18 @@ function mount(
|
||||
}
|
||||
return <div data-testid={`view-${opts?.only ?? key}`} />
|
||||
}) as ConversationRootProps['renderSlot']
|
||||
const renderSlotChain = ((_key, _owner, opts) => opts?.fallback ?? null) as ConversationRootProps['renderSlotChain']
|
||||
const renderSlotChain = ((_key, _owner, opts) => (
|
||||
overlayTakeover
|
||||
? (
|
||||
<>
|
||||
<div data-chain-overlay-fallback="conversation.composer" style={{ display: 'none' }}>
|
||||
{opts?.fallback ?? null}
|
||||
</div>
|
||||
<div data-testid="composer-takeover">TAKEOVER</div>
|
||||
</>
|
||||
)
|
||||
: (opts?.fallback ?? null)
|
||||
)) as ConversationRootProps['renderSlotChain']
|
||||
const props: ConversationRootProps = {
|
||||
sessionId: SID,
|
||||
SessionProvider: ({ children }) => children(SID),
|
||||
@@ -170,6 +194,30 @@ describe('ConversationRoot resident composer', () => {
|
||||
expect(b.open).toHaveBeenCalledWith(sid('root'))
|
||||
})
|
||||
|
||||
it('active phase: fixed header outside the scrollport; sticky composer seat inside it', () => {
|
||||
const b = mount(conversationSnapshot())
|
||||
const host = b.view.container.querySelector('[data-conversation-scroll]')
|
||||
const seat = b.view.container.querySelector('[data-composer-seat]')
|
||||
const header = b.view.container.querySelector('header')
|
||||
const textarea = b.view.container.querySelector('textarea')
|
||||
expect(host).not.toBeNull()
|
||||
expect(seat).not.toBeNull()
|
||||
expect(header).not.toBeNull()
|
||||
// Header is column chrome above the scrollport; the seat sticks inside it.
|
||||
expect(host?.contains(header)).toBe(false)
|
||||
expect(host?.contains(seat)).toBe(true)
|
||||
expect(seat?.contains(textarea)).toBe(true)
|
||||
})
|
||||
|
||||
it('sticky composer seat wraps the whole overlay chain, not only the fallback stack', () => {
|
||||
const b = mount(conversationSnapshot(), undefined, undefined, true)
|
||||
const seat = b.view.container.querySelector('[data-composer-seat]')
|
||||
const takeover = b.view.getByTestId('composer-takeover')
|
||||
const fallback = b.view.container.querySelector('[data-chain-overlay-fallback="conversation.composer"]')
|
||||
expect(seat?.contains(takeover)).toBe(true)
|
||||
expect(seat?.contains(fallback)).toBe(true)
|
||||
})
|
||||
|
||||
it('hero phase: same textarea, hero chrome, no header, picker switches the workspace', () => {
|
||||
const b = mount(
|
||||
conversationSnapshot({ composerPhase: 'blank', blank: true }),
|
||||
@@ -178,13 +226,19 @@ describe('ConversationRoot resident composer', () => {
|
||||
{ ...workspace('second'), title: 'Selected Folder' },
|
||||
],
|
||||
)
|
||||
// Hero chrome present, view ring absent.
|
||||
// Hero chrome present, view ring absent; scroll host already wraps the
|
||||
// resident composer so the blank → active flip does not remount it.
|
||||
const host = b.view.container.querySelector('[data-conversation-scroll]')
|
||||
const header = b.view.container.querySelector('header')
|
||||
expect(host).not.toBeNull()
|
||||
expect(header?.getAttribute('aria-hidden')).toBe('true')
|
||||
expect(b.view.getByText("Let's start building")).toBeTruthy()
|
||||
expect(b.view.queryByTestId('view-chat')).toBeNull()
|
||||
// The same machine-backed textarea is live in the hero, and the
|
||||
// persistence mirror stays bound (ConversationSession mounts chrome-less
|
||||
// persistence mirror stays bound (ConversationSession mounts chrome-hidden
|
||||
// for blank sessions): hero typing reaches the chat store.
|
||||
const box = b.view.getByRole('textbox')
|
||||
expect(host?.contains(box)).toBe(true)
|
||||
fireEvent.change(box, { target: { value: 'draft in hero' } })
|
||||
expect(b.chat.store.getSnapshot().draft).toBe('draft in hero')
|
||||
// Picker: open through the chip; a pick switches to the other
|
||||
@@ -197,20 +251,31 @@ describe('ConversationRoot resident composer', () => {
|
||||
expect(b.view.getByText('Selected Folder')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('textarea DOM identity survives the hero → active flip', () => {
|
||||
it('same textarea DOM node survives the hero → active flip into the sticky scrollport', () => {
|
||||
const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true }))
|
||||
const before = b.view.getByRole('textbox')
|
||||
fireEvent.change(before, { target: { value: 'kept across flip' } })
|
||||
// First message landed: content exists, phase leaves blank.
|
||||
// First message landed: content exists, phase leaves blank. Composer
|
||||
// already sat in the Session scrollport during hero, so the textarea
|
||||
// node and InputHub draft both survive.
|
||||
b.session.set(conversationSnapshot({ composerPhase: 'active', blank: false }))
|
||||
b.rerender()
|
||||
const after = b.view.getByRole('textbox')
|
||||
const after = b.view.getByRole('textbox') as HTMLTextAreaElement
|
||||
expect(after).toBe(before)
|
||||
expect((after as HTMLTextAreaElement).value).toBe('kept across flip')
|
||||
expect(after.value).toBe('kept across flip')
|
||||
expect(b.chat.store.getSnapshot().draft).toBe('kept across flip')
|
||||
expect(b.view.container.querySelector('[data-conversation-scroll]')?.contains(after)).toBe(true)
|
||||
expect(b.view.queryByText("Let's start building")).toBeNull()
|
||||
expect(b.view.getByTestId('view-chat')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps pending takeover interaction accessible outside the Chat view', () => {
|
||||
const b = mount(conversationSnapshot({ pending: [{} as never] }))
|
||||
act(() => { b.chat.actions.setView('trajectory') })
|
||||
expect(b.view.getByTestId('view-trajectory')).toBeTruthy()
|
||||
expect(b.view.getByRole('textbox')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('rolls the pending workspace label back when switching fails', async () => {
|
||||
const selectWorkspace = vi.fn(async () => { throw new Error('connect failed') })
|
||||
const b = mount(
|
||||
|
||||
600
packages/client/ui-conversation/tests/terminal-card.spec.tsx
Normal file
600
packages/client/ui-conversation/tests/terminal-card.spec.tsx
Normal file
@@ -0,0 +1,600 @@
|
||||
// @vitest-environment jsdom
|
||||
// The terminal render intent on the web side: the pure terminalCardModel
|
||||
// derivation over callView/resultView, and both conversation render sites that
|
||||
// consume it — the chat tool row's expanded body (GenericToolCard / BashRow)
|
||||
// and the details panel's Output section.
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SelectionTarget, ToolRowOwnerProps, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { CHAT_TERMINAL_MAX_LINES, terminalCardModel } from '../src/client/contract/terminal-card-model.ts'
|
||||
import { createChatStore } from '../src/client/stores.ts'
|
||||
import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
|
||||
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
|
||||
import { BashRow } from '../src/client/toolviews/bash-sample.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
/**
|
||||
* Match an output line with its interior whitespace intact: the column
|
||||
* alignment this card exists to preserve is exactly what the default
|
||||
* whitespace-collapsing matcher would hide.
|
||||
*/
|
||||
const RAW = { normalizer: (text: string) => text }
|
||||
|
||||
/** The rendered card's run-state dot state, so a render site cannot silently drop it. */
|
||||
function runStateOf(container: HTMLElement): string | null {
|
||||
return container.querySelector('[data-terminal] [data-state]')?.getAttribute('data-state') ?? null
|
||||
}
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
|
||||
const ARGS = '{"command":"ls -la","description":"List files"}'
|
||||
|
||||
/** The bash tool's own call view for a foreground command. */
|
||||
const callTerminal = (over?: Partial<Extract<ToolCallView, { card: 'terminal' }>>): ToolCallView => ({
|
||||
card: 'terminal', title: 'ls -la', description: 'List files', ...over,
|
||||
})
|
||||
|
||||
/** The bash tool's own result view for a settled foreground command. */
|
||||
const resultTerminal = (over?: Partial<Extract<ToolResultView, { card: 'terminal' }>>): ToolResultView => ({
|
||||
card: 'terminal', output: 'a.ts b.ts\nc.ts d.ts\n', exitCode: 0, ...over,
|
||||
})
|
||||
|
||||
const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({
|
||||
callId: 'c1', name: 'bash', argsRaw: ARGS,
|
||||
turn: 1, step: 1, time: 1_000, callView: callTerminal(), ...over,
|
||||
})
|
||||
|
||||
const settled = (over?: Partial<ToolResultNode>): ToolResultNode => ({
|
||||
kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1',
|
||||
call: { name: 'bash', argsRaw: ARGS },
|
||||
callTime: 1_000,
|
||||
content: [{ type: 'text', text: 'a.ts b.ts\nc.ts d.ts\n' }], isError: false,
|
||||
callView: callTerminal(), resultView: resultTerminal(), ...over,
|
||||
})
|
||||
|
||||
describe('terminalCardModel', () => {
|
||||
it('derives a running card from the call view alone', () => {
|
||||
expect(terminalCardModel(running({ callView: callTerminal({ cwd: '/projects/app' }) }))).toEqual({
|
||||
description: 'List files',
|
||||
card: {
|
||||
command: 'ls -la', cwd: '/projects/app', output: undefined,
|
||||
exitCode: undefined, signal: undefined, running: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('derives a settled card from both sides, carrying the exit status', () => {
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: '/projects/app' }),
|
||||
resultView: resultTerminal({ output: 'boom\n', exitCode: 2 }),
|
||||
}))).toEqual({
|
||||
description: 'List files',
|
||||
card: {
|
||||
command: 'ls -la', cwd: '/projects/app', output: 'boom\n',
|
||||
exitCode: 2, signal: undefined, running: false,
|
||||
},
|
||||
})
|
||||
expect(terminalCardModel(settled({
|
||||
resultView: { card: 'terminal', output: '', signal: 'SIGTERM' },
|
||||
}))?.card.signal).toBe('SIGTERM')
|
||||
})
|
||||
|
||||
it('takes the result view\'s replacement title over the pending one', () => {
|
||||
// The presentation contract defines a result title as REPLACING the pending
|
||||
// title, so a tool that rewrites it at settle time must win here.
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ title: 'pnpm run check' }),
|
||||
resultView: resultTerminal({ title: 'pnpm run check --filter web' }),
|
||||
}))?.card.command).toBe('pnpm run check --filter web')
|
||||
// Without one, the call's title is what the card keeps.
|
||||
expect(terminalCardModel(settled())?.card.command).toBe('ls -la')
|
||||
})
|
||||
|
||||
it('resolves the cwd against the session workspace the way the bridge must', () => {
|
||||
// Omitted workdir — the common bash call — IS the session workspace.
|
||||
expect(terminalCardModel(settled(), '/w/app')?.card.cwd).toBe('/w/app')
|
||||
// A relative workdir joins under it.
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: 'packages/ui' }),
|
||||
}), '/w/app')?.card.cwd).toBe('/w/app/packages/ui')
|
||||
// An absolute one is used as-is.
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: '/srv/other' }),
|
||||
}), '/w/app')?.card.cwd).toBe('/srv/other')
|
||||
// With no session cwd there is nothing to resolve against: a relative path
|
||||
// stays as authored and an omitted one stays absent (a bare `$` prompt).
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: 'packages/ui' }),
|
||||
}))?.card.cwd).toBe('packages/ui')
|
||||
expect(terminalCardModel(settled())?.card.cwd).toBeUndefined()
|
||||
// The running arm resolves identically.
|
||||
expect(terminalCardModel(running(), '/w/app')?.card.cwd).toBe('/w/app')
|
||||
})
|
||||
|
||||
it('normalizes a relative workdir so the label names the directory actually used', () => {
|
||||
// The bash executor resolves the workdir before running, so `..` against
|
||||
// /w/app runs in /w — the card must say `w`, not `..`.
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: '..' }),
|
||||
}), '/w/app')?.card.cwd).toBe('/w')
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: '.' }),
|
||||
}), '/w/app')?.card.cwd).toBe('/w/app')
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: '../sibling' }),
|
||||
}), '/w/app')?.card.cwd).toBe('/w/sibling')
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: './nested/../other' }),
|
||||
}), '/w/app')?.card.cwd).toBe('/w/app/other')
|
||||
// A `..` that would climb past the root is dropped, as a filesystem does.
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: '../../..' }),
|
||||
}), '/w')?.card.cwd).toBe('/')
|
||||
// An absolute path carrying segments normalizes too.
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: '/srv/./app/../other' }),
|
||||
}), '/w/app')?.card.cwd).toBe('/srv/other')
|
||||
// A Windows path keeps its separators.
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: 'C:\\ws\\app\\..' }),
|
||||
}), '/w')?.card.cwd).toBe('C:\\ws')
|
||||
// Without a session cwd a relative `..` has nothing to resolve against, so
|
||||
// it survives as authored rather than being silently dropped.
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: '../elsewhere' }),
|
||||
}))?.card.cwd).toBe('../elsewhere')
|
||||
})
|
||||
|
||||
it('keeps a UNC server and share as an unpoppable root', () => {
|
||||
// Windows cannot climb above a share, so `..` from the share root stays put.
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: '..' }),
|
||||
}), '\\\\server\\share')?.card.cwd).toBe('\\\\server\\share')
|
||||
// Below the share it pops normally, keeping the UNC separators.
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: '..' }),
|
||||
}), '\\\\server\\share\\app')?.card.cwd).toBe('\\\\server\\share')
|
||||
// Several `..` cannot escape the root either.
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: '../../..' }),
|
||||
}), '\\\\server\\share\\app')?.card.cwd).toBe('\\\\server\\share')
|
||||
})
|
||||
|
||||
it('draws a bare $ when the window dropped the call head, rather than guessing', () => {
|
||||
// A truncated call carries no cwd anywhere: the result view has none, and
|
||||
// the original call may have used an explicit workdir. Falling back to the
|
||||
// session workspace here would name a directory the card cannot know.
|
||||
expect(terminalCardModel(settled({
|
||||
call: null, callView: null, resultView: resultTerminal({ title: 'ls -la' }),
|
||||
}), '/w/app')?.card.cwd).toBeUndefined()
|
||||
// A present call view that omits its cwd still means the workspace.
|
||||
expect(terminalCardModel(settled(), '/w/app')?.card.cwd).toBe('/w/app')
|
||||
})
|
||||
|
||||
it('carries the call view\'s description, which the contract renders above the card', () => {
|
||||
expect(terminalCardModel(settled())?.description).toBe('List files')
|
||||
expect(terminalCardModel(running())?.description).toBe('List files')
|
||||
// A presenter that supplies none, and a window-truncated call side, both
|
||||
// leave it absent so the row keeps its args-derived summary.
|
||||
expect(terminalCardModel(settled({
|
||||
callView: { card: 'terminal', title: 'ls' },
|
||||
}))?.description).toBeUndefined()
|
||||
expect(terminalCardModel(settled({ call: null, callView: null }))?.description).toBeUndefined()
|
||||
})
|
||||
|
||||
it('a window-truncated call side falls back to the result title, then to an empty command', () => {
|
||||
// Truncation drops both the call head and its view (conversation.ts).
|
||||
const truncated = { call: null, callView: null }
|
||||
expect(terminalCardModel(settled({
|
||||
...truncated, resultView: resultTerminal({ title: 'ls -la' }),
|
||||
}))?.card).toMatchObject({ command: 'ls -la', cwd: undefined, running: false })
|
||||
expect(terminalCardModel(settled(truncated))?.card).toMatchObject({ command: '', cwd: undefined })
|
||||
})
|
||||
|
||||
it('returns null for every non-terminal call: no views, generic views, unknown cards', () => {
|
||||
expect(terminalCardModel(running({ callView: null }))).toBeNull()
|
||||
expect(terminalCardModel(settled({ callView: null, resultView: null }))).toBeNull()
|
||||
expect(terminalCardModel(running({ callView: { card: 'generic', title: 'read x' } }))).toBeNull()
|
||||
// A generic result settles a terminal call as a generic card (the bash
|
||||
// tool's own execution-error and background paths).
|
||||
expect(terminalCardModel(settled({ resultView: { card: 'generic' } }))).toBeNull()
|
||||
// A card tag this UI version does not know arrives over the wire; the
|
||||
// documented generic-card default takes it, not a crash.
|
||||
const future = { card: 'chart', title: 'plot' } as unknown as ToolCallView
|
||||
expect(terminalCardModel(running({ callView: future }))).toBeNull()
|
||||
expect(terminalCardModel(settled({
|
||||
callView: future, resultView: { card: 'chart' } as unknown as ToolResultView,
|
||||
}))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('chat row terminal body', () => {
|
||||
const ownerProps = (block: RunningToolCall | ToolResultNode): ToolRowOwnerProps => ({
|
||||
callId: 'c1', toolName: 'bash', block, openFile: vi.fn(),
|
||||
})
|
||||
|
||||
it('the expanded body is the command output, capped tighter than the panel', () => {
|
||||
expect(CHAT_TERMINAL_MAX_LINES).toBeLessThan(16)
|
||||
const view = render(<GenericToolCard {...ownerProps(settled())} />)
|
||||
// Collapsed: the one-line summary row only, no output.
|
||||
expect(view.getByText('List files')).toBeTruthy()
|
||||
expect(view.queryByText(/a\.ts/)).toBeNull()
|
||||
fireEvent.click(view.container.querySelector('button')!)
|
||||
expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy()
|
||||
expect(view.getByText('ls -la')).toBeTruthy()
|
||||
// The args JSON body the generic path would have shown is gone.
|
||||
expect(view.queryByText(/"command"/)).toBeNull()
|
||||
})
|
||||
|
||||
it('the cap collapses a long output inside the row, expandable in place', () => {
|
||||
const lines = Array.from({ length: CHAT_TERMINAL_MAX_LINES + 3 }, (_, i) => `line-${i}`)
|
||||
const view = render(<GenericToolCard {...ownerProps(settled({
|
||||
resultView: resultTerminal({ output: `${lines.join('\n')}\n` }),
|
||||
}))} />)
|
||||
fireEvent.click(view.container.querySelector('button')!)
|
||||
expect(view.getByText('… 其余 3 行')).toBeTruthy()
|
||||
expect(view.queryByText('line-5')).toBeNull()
|
||||
fireEvent.click(view.getByRole('button', { name: '展开其余 3 行输出' }))
|
||||
expect(view.getByText('line-5')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders a multi-line command as one prompt row per line', () => {
|
||||
const view = render(<GenericToolCard {...ownerProps(settled({
|
||||
callView: callTerminal({ title: 'ls -la\necho done' }),
|
||||
}))} />)
|
||||
fireEvent.click(view.container.querySelector('button')!)
|
||||
const rows = view.container.querySelectorAll('[class^="_promptLine_"]')
|
||||
expect([...rows].map(row => row.textContent)).toEqual(['$ls -la', '$echo done'])
|
||||
// Still one dot for the call, on the first row.
|
||||
expect(view.container.querySelectorAll('[data-terminal] [data-state]')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('the fallback row shows the presenter description, not the args summary', () => {
|
||||
// Any terminal-declaring tool without its own keyed row lands here, so the
|
||||
// contract's above-card description has to win at this render site as well.
|
||||
const view = render(<GenericToolCard {...ownerProps(settled({
|
||||
callView: callTerminal({ description: 'Terminal 3' }),
|
||||
}))} />)
|
||||
expect(view.getByText('Terminal 3')).toBeTruthy()
|
||||
expect(view.queryByText('List files')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the presenter description visible once the terminal card is expanded', () => {
|
||||
// The contract puts the description ABOVE the card. The collapsed summary is
|
||||
// hidden while a row is open, so an expanded terminal row has to draw it
|
||||
// itself or the description would only ever be visible collapsed.
|
||||
const view = render(<GenericToolCard {...ownerProps(settled({
|
||||
callView: callTerminal({ description: 'Terminal 3' }),
|
||||
}))} />)
|
||||
expect(view.getByText('Terminal 3')).toBeTruthy()
|
||||
fireEvent.click(view.container.querySelector('button')!)
|
||||
expect(view.container.querySelector('[data-terminal]')).not.toBeNull()
|
||||
expect(view.getByText('Terminal 3')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a running terminal call expands to the prompt line with no output yet', () => {
|
||||
const view = render(<GenericToolCard {...ownerProps(running())} />)
|
||||
fireEvent.click(view.container.querySelector('button')!)
|
||||
expect(view.getByText('ls -la')).toBeTruthy()
|
||||
expect(view.queryByText('复制')).toBeNull()
|
||||
// The card states its own run state: a running command reads as running
|
||||
// even though it has no output yet to distinguish it from an empty settle.
|
||||
expect(runStateOf(view.container)).toBe('ongoing')
|
||||
})
|
||||
|
||||
it('a non-terminal call keeps the args-JSON text body', () => {
|
||||
const view = render(<GenericToolCard {...ownerProps(settled({
|
||||
callView: null, resultView: null,
|
||||
}))} />)
|
||||
fireEvent.click(view.container.querySelector('button')!)
|
||||
expect(view.getByText(/"command"/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a terminal call with no args still expands, through its terminal body alone', () => {
|
||||
// Empty args make the text body null; the terminal material carries the row.
|
||||
const view = render(<GenericToolCard {...ownerProps(settled({
|
||||
call: { name: 'bash', argsRaw: '' },
|
||||
}))} />)
|
||||
fireEvent.click(view.container.querySelector('button')!)
|
||||
expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('BashRow terminal card', () => {
|
||||
const list = () => createSnapshotStore<SessionListState>({
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0 } },
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
})
|
||||
|
||||
const rowProps = (block: RunningToolCall | ToolResultNode): ToolRowProps => ({
|
||||
callId: 'c1', toolName: 'bash', block, openFile: vi.fn(),
|
||||
sessionId: SID, useSessions: bindSnapshotSelector(list()),
|
||||
} as unknown as ToolRowProps)
|
||||
|
||||
it('renders the command output under the summary row, without an expand gesture', () => {
|
||||
const view = render(<BashRow {...rowProps(settled())} />)
|
||||
expect(view.getByText('List files')).toBeTruthy()
|
||||
expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy()
|
||||
// The card's controls are the row's only interactions: a bash row is not a
|
||||
// path link and no longer a details-panel target, so nothing here navigates.
|
||||
expect(view.container.querySelector('[data-clickable]')).toBeNull()
|
||||
expect(view.getByText('复制')).toBeTruthy()
|
||||
})
|
||||
|
||||
// The row's leading StateDot and the card's run-state dot describe the same
|
||||
// command, so a running row whose card claimed 'done' would be a contradiction
|
||||
// the reader sees on one line.
|
||||
it('agrees with the summary row about the run state', () => {
|
||||
const runningView = render(<BashRow {...rowProps(running())} />)
|
||||
expect(runningView.container.querySelector('[data-variant="bash"]')?.getAttribute('data-state')).toBe('running')
|
||||
expect(runStateOf(runningView.container)).toBe('ongoing')
|
||||
cleanup()
|
||||
const settledView = render(<BashRow {...rowProps(settled())} />)
|
||||
expect(settledView.container.querySelector('[data-variant="bash"]')?.getAttribute('data-state')).toBe('ok')
|
||||
expect(runStateOf(settledView.container)).toBe('done')
|
||||
})
|
||||
|
||||
it('shows the terminal presenter\'s description instead of the args summary', () => {
|
||||
// `terminal_send`-style presenters author a description the args do not
|
||||
// repeat; the contract puts it above the card, which is this row's summary.
|
||||
const view = render(<BashRow {...rowProps(settled({
|
||||
callView: callTerminal({ description: 'Terminal 3' }),
|
||||
}))} />)
|
||||
expect(view.getByText('Terminal 3')).toBeTruthy()
|
||||
expect(view.queryByText('List files')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the args-derived summary when the presenter authored no description', () => {
|
||||
const view = render(<BashRow {...rowProps(settled({
|
||||
callView: { card: 'terminal', title: 'ls -la' },
|
||||
}))} />)
|
||||
expect(view.getByText('List files')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a non-terminal bash call (background start) renders the summary row alone', () => {
|
||||
const view = render(<BashRow {...rowProps(settled({
|
||||
callView: { card: 'generic', title: 'sleep 30', kind: 'execute' },
|
||||
resultView: { card: 'generic' },
|
||||
}))} />)
|
||||
expect(view.getByText('List files')).toBeTruthy()
|
||||
expect(view.queryByText(/a\.ts/)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DetailsPanel Output section', () => {
|
||||
function mount(snapshot: ConversationSnapshot, selection: SelectionTarget | null, cwd?: string) {
|
||||
localStorage.clear()
|
||||
const chat = createChatStore().create()
|
||||
if (selection !== null) chat.actions.select(selection)
|
||||
const sessions = createSnapshotStore<SessionListState>(cwd === undefined
|
||||
? { ids: [], byId: {}, current: undefined, phase: 'ready' }
|
||||
: {
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd } },
|
||||
current: SID,
|
||||
phase: 'ready',
|
||||
})
|
||||
const workspaces = createSnapshotStore<WorkspaceListState>({
|
||||
items: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
})
|
||||
return render(
|
||||
<DetailsPanel
|
||||
sessionId={SID}
|
||||
useSession={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })}
|
||||
useSessions={bindSnapshotSelector(sessions)}
|
||||
useWorkspaces={bindSnapshotSelector(workspaces)}
|
||||
useInput={(() => { throw new Error('unused') })}
|
||||
inputActions={{ setDraft: () => {}, submit: () => {}, addImages: () => {}, removeImage: () => {}, pruneImages: () => {} }}
|
||||
useProjection={(() => undefined)}
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
closeDetails={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, lastAgentError: null, ...over,
|
||||
}
|
||||
}
|
||||
|
||||
const target: SelectionTarget = { turnSeq: 10, callId: 'c1', toolName: 'bash' }
|
||||
|
||||
// The panel never unmounts between selections, so per-call view state has to
|
||||
// be keyed off the selected call or it leaks into the next one.
|
||||
it('resets the card\'s expand state when the selected call changes', () => {
|
||||
const long = Array.from({ length: 20 }, (_, i) => `row-${i}`)
|
||||
const view = mount(snapshot({
|
||||
nodes: [settled({ resultView: resultTerminal({ output: `${long.join('\n')}\n` }) })],
|
||||
}), target)
|
||||
fireEvent.click(view.getByRole('button', { name: '展开其余 4 行输出' }))
|
||||
expect(view.getByRole('button', { name: '收起输出' })).toBeTruthy()
|
||||
// A second call, selected without unmounting the panel, starts collapsed.
|
||||
cleanup()
|
||||
const second = mount(snapshot({
|
||||
nodes: [settled({
|
||||
callId: 'c2', resultView: resultTerminal({ output: `${long.join('\n')}\n` }),
|
||||
})],
|
||||
}), { turnSeq: 10, callId: 'c2', toolName: 'bash' })
|
||||
expect(second.getByRole('button', { name: '展开其余 4 行输出' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders the presenter description above the card', () => {
|
||||
const view = mount(snapshot({
|
||||
nodes: [settled({ callView: callTerminal({ description: 'Terminal 3' }) })],
|
||||
}), target)
|
||||
const description = view.getByText('Terminal 3')
|
||||
const card = view.container.querySelector('[data-terminal]')
|
||||
expect(card).not.toBeNull()
|
||||
// Above, not below: document order is what places it as the card's heading.
|
||||
expect(description.compareDocumentPosition(card!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
})
|
||||
|
||||
it('resolves the prompt cwd against the session workspace', () => {
|
||||
const view = mount(snapshot({ nodes: [settled()] }), target, '/w/app')
|
||||
// No workdir in the call view: the prompt label is the workspace basename.
|
||||
expect(view.getByText('app')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders the terminal card at full height, keeping the JSON Input section', () => {
|
||||
const long = Array.from({ length: 20 }, (_, i) => `row-${i}`)
|
||||
const view = mount(snapshot({
|
||||
nodes: [settled({ resultView: resultTerminal({ output: `${long.join('\n')}\n` }) })],
|
||||
}), target)
|
||||
expect(view.getByText(/"command"/)).toBeTruthy()
|
||||
expect(view.getByText('ls -la')).toBeTruthy()
|
||||
// The panel takes the primitive's own default cap (16), not the row's.
|
||||
expect(view.getByText(`… 其余 ${20 - 16} 行`)).toBeTruthy()
|
||||
expect(view.getByText('row-0')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a running terminal call shows the prompt line, not the 运行中… placeholder', () => {
|
||||
const view = mount(snapshot({ runningCalls: [running()] }), target)
|
||||
expect(view.getByText('ls -la')).toBeTruthy()
|
||||
expect(view.queryByText('运行中…')).toBeNull()
|
||||
expect(runStateOf(view.container)).toBe('ongoing')
|
||||
})
|
||||
|
||||
it('a running non-terminal call keeps the 运行中… placeholder', () => {
|
||||
const view = mount(snapshot({ runningCalls: [running({ callView: null })] }), target)
|
||||
expect(view.getByText('运行中…')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a non-terminal result keeps the flattened pre with its error styling', () => {
|
||||
const view = mount(snapshot({
|
||||
nodes: [settled({
|
||||
callView: null, resultView: null, isError: true,
|
||||
content: [{ type: 'text', text: 'permission denied' }],
|
||||
})],
|
||||
}), target)
|
||||
const pre = view.container.querySelector('pre[data-error]')
|
||||
expect(pre?.textContent).toBe('permission denied')
|
||||
})
|
||||
|
||||
// The panel resolves a sub-dispatch through the same material as a native
|
||||
// call, so a sub-call that DID carry terminal views would render the card.
|
||||
// The shipped wire cannot produce that yet: `session.ts` folds
|
||||
// `tool/code-dispatch(-start)` with `callView: null`/`resultView: null`, and
|
||||
// the host's `viewFor` only presents top-level `tool/call`/`tool/result`. This
|
||||
// pins the resolution path with views injected directly, and the arm below
|
||||
// pins what the shipped path actually shows today.
|
||||
it('a run_code sub-dispatch resolves to its own terminal card once views reach it', () => {
|
||||
const view = mount(snapshot({
|
||||
codeDispatches: new Map([['p1', [settled({ callId: 'c1' })]]]),
|
||||
}), target)
|
||||
expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a sub-dispatch as the wire actually delivers it (no views) keeps the flattened form', () => {
|
||||
const view = mount(snapshot({
|
||||
codeDispatches: new Map([['p1', [settled({ callId: 'c1', callView: null, resultView: null })]]]),
|
||||
}), target)
|
||||
// No terminal card: the generic path renders the result text in the Output
|
||||
// section's <pre> (the Input section has its own, hence the scoping).
|
||||
expect(view.container.querySelector('[data-terminal]')).toBeNull()
|
||||
const output = view.getByText('Output').closest('section')
|
||||
expect(output?.querySelector('pre')?.textContent).toContain('a.ts b.ts')
|
||||
})
|
||||
|
||||
it('a running run_code sub-dispatch resolves through the running material', () => {
|
||||
const view = mount(snapshot({
|
||||
// The leading non-matching sub-call exercises the scan's skip.
|
||||
codeDispatches: new Map([['p1', [running({ callId: 'other' }), running()]]]),
|
||||
}), target)
|
||||
expect(view.getByText('ls -la')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a window-truncated call head titles the panel by callId and drops the Input section', () => {
|
||||
const view = mount(snapshot({
|
||||
nodes: [settled({ call: null, callView: null, resultView: resultTerminal({ title: 'ls -la' }) })],
|
||||
}), target)
|
||||
expect(view.getByText('c1')).toBeTruthy()
|
||||
expect(view.queryByText('Input')).toBeNull()
|
||||
expect(view.getByText('Output')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('scans past other nodes and other calls before reporting the call out of window', () => {
|
||||
const view = mount(snapshot({
|
||||
nodes: [
|
||||
{ kind: 'assistant', seq: 1, time: 1_000, turn: 1, step: 1, blocks: [] },
|
||||
settled({ callId: 'elsewhere' }),
|
||||
],
|
||||
runningCalls: [running({ callId: 'also-elsewhere' })],
|
||||
}), target)
|
||||
expect(view.getByText('该调用不在当前窗口内')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('no selection at all renders the guidance line and the default title', () => {
|
||||
const view = mount(snapshot(), null)
|
||||
expect(view.getByText('详情')).toBeTruthy()
|
||||
expect(view.getByText('点击消息流中的工具行查看详情')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a step selection without a callId renders the guidance line too', () => {
|
||||
const view = mount(snapshot(), { turnSeq: 3, stepSeq: 1 })
|
||||
expect(view.getByText('点击消息流中的工具行查看详情')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('the close button reaches closeDetails', () => {
|
||||
localStorage.clear()
|
||||
const chat = createChatStore().create()
|
||||
const closeDetails = vi.fn()
|
||||
const snap = snapshot()
|
||||
const view = render(
|
||||
<DetailsPanel
|
||||
sessionId={SID}
|
||||
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} })}
|
||||
useSessions={bindSnapshotSelector(createSnapshotStore<SessionListState>(
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready' }))}
|
||||
useWorkspaces={bindSnapshotSelector(createSnapshotStore<WorkspaceListState>({
|
||||
items: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
}))}
|
||||
useInput={(() => { throw new Error('unused') })}
|
||||
inputActions={{ setDraft: () => {}, submit: () => {}, addImages: () => {}, removeImage: () => {}, pruneImages: () => {} }}
|
||||
useProjection={(() => undefined)}
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
closeDetails={closeDetails}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: '关闭详情' }))
|
||||
expect(closeDetails).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('a non-text result block renders as JSON, and an empty result falls back to its error', () => {
|
||||
const nonText = mount(snapshot({
|
||||
nodes: [settled({
|
||||
callView: null, resultView: null,
|
||||
content: [{ type: 'reasoning', text: 'why' }],
|
||||
})],
|
||||
}), target)
|
||||
// Scope to the Output section: the Input section's CodeBlock renders a
|
||||
// <pre> of its own, and it comes first in document order.
|
||||
expect(nonText.getByText('Output').closest('section')?.querySelector('pre')?.textContent)
|
||||
.toBe('{\n "type": "reasoning",\n "text": "why"\n}')
|
||||
cleanup()
|
||||
const empty = mount(snapshot({
|
||||
nodes: [settled({
|
||||
callView: null, resultView: null, content: [], isError: true,
|
||||
error: { name: 'ToolError', code: 'interrupted' },
|
||||
})],
|
||||
}), target)
|
||||
expect(empty.getByText('ToolError: interrupted')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -31,11 +31,18 @@ describe('TodoPanel', () => {
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('shows progress, one row per item with its status glyph', () => {
|
||||
it('starts collapsed with the progress summary visible', () => {
|
||||
render(<TodoPanel todos={LIST} />)
|
||||
expect(screen.getByTestId('todo-panel')).toBeTruthy()
|
||||
expect(screen.getByText('To-dos')).toBeTruthy()
|
||||
expect(screen.getByText('1/3 tasks · 1 in progress')).toBeTruthy()
|
||||
expect(screen.getByRole('button', { expanded: false })).toBeTruthy()
|
||||
expect(screen.queryByRole('list')).toBeNull()
|
||||
})
|
||||
|
||||
it('expands to show one row per item with its status glyph', () => {
|
||||
render(<TodoPanel todos={LIST} />)
|
||||
fireEvent.click(screen.getByRole('button', { expanded: false }))
|
||||
const items = screen.getAllByRole('listitem')
|
||||
expect(items.map(li => li.getAttribute('data-status'))).toEqual(['completed', 'in_progress', 'pending'])
|
||||
expect(screen.getByText('搭骨架')).toBeTruthy()
|
||||
@@ -44,8 +51,9 @@ describe('TodoPanel', () => {
|
||||
expect(items.every(li => li.querySelector('svg') !== null)).toBe(true)
|
||||
})
|
||||
|
||||
it('collapse hides the list; expand restores; header keeps the count summary', () => {
|
||||
it('collapse hides an expanded list; expand restores; header keeps the count summary', () => {
|
||||
render(<TodoPanel todos={LIST} />)
|
||||
fireEvent.click(screen.getByRole('button', { expanded: false }))
|
||||
const header = screen.getByRole('button', { expanded: true })
|
||||
fireEvent.click(header)
|
||||
expect(screen.queryByRole('list')).toBeNull()
|
||||
@@ -58,7 +66,7 @@ describe('TodoPanel', () => {
|
||||
|
||||
it('collapsed header still shows zero in-progress when nothing is active', () => {
|
||||
render(<TodoPanel todos={[{ content: '都完了', status: 'completed' }]} />)
|
||||
fireEvent.click(screen.getByRole('button', { expanded: true }))
|
||||
expect(screen.getByRole('button', { expanded: false })).toBeTruthy()
|
||||
expect(screen.queryByText('都完了')).toBeNull()
|
||||
expect(screen.getByText('1/1 tasks · 0 in progress')).toBeTruthy()
|
||||
})
|
||||
|
||||
@@ -32,6 +32,9 @@
|
||||
{
|
||||
"path": "../../plan/plan-mode"
|
||||
},
|
||||
{
|
||||
"path": "../../goal/goal"
|
||||
},
|
||||
{
|
||||
"path": "../../todo/tool-todo"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user