Merge remote-tracking branch 'origin/master' into codex/status-bar-token-metrics
# Conflicts: # apps/web/tests/snapshots/fresh-round-trip/ui.expected.md # docs/architecture.i18n.yaml # docs/event-producer-consumer.md # packages/client/connection/src/client/api.ts # packages/client/runtime/src/client/index.ts # packages/client/runtime/src/client/sessions/manager.ts # packages/client/runtime/src/client/sessions/service.ts # packages/client/ui-conversation/README.i18n.yaml # packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/src/api-proxy.ts
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: 4ffa0538cfc29fc1ddfc2cbb6f2c4933da0ac5fa
|
||||
README.zh.md: 83678d1474d738a9df2b560ec9890131ba35f673
|
||||
README.md: b74127ffe11df40fcad0c97e2fc6896ec79ad9d1
|
||||
README.zh.md: f6dfc3ca61ea80758c9f64ca98f034b0832ee89e
|
||||
|
||||
@@ -8,6 +8,8 @@ The resident conversation shell survives no-session and session transitions. Wit
|
||||
|
||||
The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: <active id>`), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves.
|
||||
|
||||
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.
|
||||
|
||||
Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file with the host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering.
|
||||
|
||||
Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`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).
|
||||
@@ -16,7 +18,7 @@ The todo surfaces are two registrations over that shape, both plain registrant p
|
||||
|
||||
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'` and `'conversation.input.model'`, plus list slots for overlay, dock, left, and right input extensions. InputBar renders the model seat immediately before its pending indicator and send/stop button. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. 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 (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 chat stats line reads durable token counters/current pressure from `ConversationSnapshot.metrics` and joins them only at presentation with the separate connection-local `modelRequestContextWindow`; visible nodes supply only the existing turn/step counts. It renders uncached input, output, and cache reads as separate compact values, computes cache hit as `cacheRead / (uncachedInput + cacheRead)` without cache writes, and shows context occupancy only after the current mux connection observes a model request with capacity. Before that request, after reconnect/restore/new subscription, or after a request without capacity, the percentage is omitted and context is labeled unknown rather than queried ahead or reconstructed from history.
|
||||
|
||||
@@ -36,5 +38,5 @@ None; this package neither assembles nor sends a provider request.
|
||||
- **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.
|
||||
- **Assistant footer extensions (IconActions row, per-message paging) are reserved slots** — drawn in the design, not implemented.
|
||||
- **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.
|
||||
- **Approval cards are display-only placeholders** — question requests answer through the composer chain (ui-question), while web-side approval answering is the P-II approvals project.
|
||||
- **The approval panel's "Always allow this type" is deferred** — durable grants need a grant-storage design; only allow-once/reject answer today.
|
||||
- **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline.
|
||||
|
||||
@@ -12,11 +12,13 @@
|
||||
|
||||
工具行同样是 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>` 命令行。
|
||||
|
||||
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'` 和 `'conversation.input.model'` 声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。InputBar 将模型 seat 渲染在 pending 指示器与发送/停止按钮之前。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。常驻无会话壳使用 `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 任务措辞(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。常驻无会话壳使用 `DisabledInputBar`,因此不会分发任何会话作用域的控件 seat。
|
||||
|
||||
聊天统计行从 `ConversationSnapshot.metrics` 读取持久的 token 计数/当前压力,并且只在展示时把它们与独立的连接本地 `modelRequestContextWindow` 结合;可见节点仅提供既有的轮次和步骤计数。它以相互独立的紧凑值显示未缓存输入、输出与缓存读取,通过 `cacheRead / (uncachedInput + cacheRead)` 计算缓存命中率而不计入缓存写入,并且只有当前 mux 连接观察到带容量的模型请求后才显示上下文占用率。在该请求之前、重连/恢复/新订阅之后,或在请求不带容量之后,系统都会省略百分比,并把上下文标为「未知」,而不会提前查询或根据历史记录重建。
|
||||
|
||||
@@ -36,5 +38,5 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
|
||||
- **详情面板是最小形态**:以原始形式显示已选择调用的参数/结果;Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。
|
||||
- **assistant footer 扩展(IconActions 行、逐消息分页)是预留 slot**:设计中已有图稿,尚未实现。
|
||||
- **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。
|
||||
- **审批卡片只是只读占位符**:问题请求通过编辑器链回答(ui-question),Web 侧审批回答属于 P-II 审批项目。
|
||||
- **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。
|
||||
- **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "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:^",
|
||||
@@ -56,6 +57,9 @@
|
||||
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-permission": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-todo": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0"
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
/** Registers the conversation components, shared store, and service callbacks. */
|
||||
import type { Context } from 'cordis'
|
||||
import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import type { ViewTab } from './contract/views.ts'
|
||||
import type {
|
||||
ChatViewInjected, ComposerBarInjected, ConversationInjected, ConversationSessionInjected, DetailsInjected,
|
||||
ApprovalWait, ChatViewInjected, ComposerBarInjected, ComposerChainProps, ConversationInjected,
|
||||
ConversationSessionInjected, DetailsInjected,
|
||||
} from './contract/slots.ts'
|
||||
import { resolveToolPath } from './contract/tool-call-model.ts'
|
||||
import { createChatStore } from './stores.ts'
|
||||
import { ConversationService } from './service.ts'
|
||||
import type { IConversation } from './service.ts'
|
||||
import { InputHub } from './input/hub.ts'
|
||||
import { InputBar } from './skeleton/InputBar.tsx'
|
||||
import { ChatView } from './chat/ChatView.tsx'
|
||||
import { bashToolviewSample } from './toolviews/bash-sample.tsx'
|
||||
import { ApprovalPanel } from './skeleton/ApprovalPanel.tsx'
|
||||
import { todoToolview } from './toolviews/todo-row.tsx'
|
||||
import { todoDockEntry } from './skeleton/TodoPanel.tsx'
|
||||
import { queueDockEntry } from './queue/QueueDock.tsx'
|
||||
@@ -24,8 +27,8 @@ import { DetailsPanel } from './skeleton/DetailsPanel.tsx'
|
||||
/** Services required by the conversation plugin. */
|
||||
export const inject = ['slots', 'layout', 'sessions', 'workspaces']
|
||||
|
||||
/** Resolve the session-scoped conversation service (scope-addressed send/cancel), failing loud. */
|
||||
function scopedConversation(sessions: SessionsService, id: SessionId): ConversationService {
|
||||
/** Resolve the session-scoped conversation face (scope-addressed send/cancel), failing loud. */
|
||||
function scopedConversation(sessions: ISessions, id: SessionId): IConversation {
|
||||
const scoped = sessions.scope(id)
|
||||
if (scoped === undefined) throw new Error(`ui-conversation: session "${id}" resolved no scope`)
|
||||
const conversation = scoped.get('conversation')
|
||||
@@ -33,6 +36,11 @@ function scopedConversation(sessions: SessionsService, id: SessionId): Conversat
|
||||
return conversation
|
||||
}
|
||||
|
||||
/** Chain routing: claim the composer while an approval wait is pending (pure — owner props only). */
|
||||
function selectApproval({ interactions }: ComposerChainProps): ApprovalWait | null {
|
||||
return interactions.find((i): i is ApprovalWait => i.kind === 'approval') ?? null
|
||||
}
|
||||
|
||||
/** Mounts the conversation plugin.
|
||||
* @param ctx - Client root context.
|
||||
*/
|
||||
@@ -129,8 +137,9 @@ export function apply(ctx: Context): void {
|
||||
// verbs ride this inject (package-internal — hub and bar are one plugin).
|
||||
slots.register({
|
||||
name: 'conversation.composer.bar',
|
||||
// The two named control seats in the bar's tool row (plan left, model
|
||||
// right); empty until their owning plugins register (B ruling).
|
||||
// The two named control seats in the bar's tool row (plan beside the
|
||||
// access control, model right); empty until their owning plugins
|
||||
// register (B ruling).
|
||||
children: {
|
||||
'conversation.input.plan': { kind: 'single', scope: 'session' },
|
||||
'conversation.input.model': { kind: 'single', scope: 'session' },
|
||||
@@ -144,11 +153,27 @@ export function apply(ctx: Context): void {
|
||||
// Stop failure surfaces via snapshot.promptError; nothing to restore.
|
||||
})
|
||||
},
|
||||
command: async (line) => {
|
||||
const session = sessions.binding(sessionId)?.session
|
||||
if (session === undefined) return false
|
||||
const result = await session.command(line)
|
||||
return result.ok && result.value.matched
|
||||
},
|
||||
hooks: { notices: shell.notices, lexicon: shell.lexicon },
|
||||
}
|
||||
},
|
||||
}, InputBar)
|
||||
|
||||
// The approval takeover: a selector-routed entry of the chain this package
|
||||
// just declared (the ui-question registration pattern; the entry lives here
|
||||
// because approval answering is core conversation UX, not an optional tool).
|
||||
// Zero business face — data and verbs both ride the matched carrier.
|
||||
// priority 1: question takeovers (default 0) win when both kinds are
|
||||
// pending — a question is a conversation the model is waiting on, while an
|
||||
// approval only blocks one tool call; answering the question first cannot
|
||||
// strand the approval (it re-elects the moment the question resolves).
|
||||
slots.register({ name: 'conversation.composer', select: selectApproval, priority: 1 }, ApprovalPanel)
|
||||
|
||||
// The chat view: first entry of the ring this package just declared.
|
||||
// Declaring the keyed toolview hole here is claiming it: ChatView is the
|
||||
// only component authorized to render per-tool rows. Shares the chat
|
||||
|
||||
@@ -30,7 +30,6 @@ import { AssistantMarkdown } from './AssistantMarkdown.tsx'
|
||||
import { GenericCommandCard } from './GenericCommandCard.tsx'
|
||||
import { GenericToolCard } from './GenericToolCard.tsx'
|
||||
import { MessageItem } from './MessageItem.tsx'
|
||||
import { PendingCard } from './PendingCard.tsx'
|
||||
import { StatsLine } from './StatsLine.tsx'
|
||||
import css from './ChatView.module.css'
|
||||
|
||||
@@ -229,7 +228,6 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
|
||||
const running = useSession(s => s.running)
|
||||
const runningCalls = useSession(s => s.runningCalls)
|
||||
const codeDispatches = useSession(s => s.codeDispatches)
|
||||
const pending = useSession(s => s.pending)
|
||||
const openState = useSession(s => s.openState)
|
||||
const openErrorMessage = useSession(s => s.openError === null ? null : `${s.openError.message}(${s.openError.code})`)
|
||||
const hasMore = useSession(s => s.hasMore)
|
||||
@@ -375,7 +373,9 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{pending.map(item => <PendingCard key={item.key} item={item} />)}
|
||||
{/* No pending placeholders: questions (ui-question) and approvals
|
||||
(ApprovalPanel) both take over the composer, so a flow card would
|
||||
double-render the same wait. */}
|
||||
{/* Turn-level loading signal: rides the whole running turn (first-token
|
||||
wait, tool execution, streaming) so it never flickers per step. */}
|
||||
{running && <TurnDots />}
|
||||
|
||||
@@ -6,12 +6,12 @@
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
IconApiOutline14, IconBrowseOutline16, IconCodeOutline16, IconEditOutline16, IconSearchOutline16, IconThinkOutline14,
|
||||
IconApiOutline14, IconBrowseOutline16, IconCodeOutline16, IconEditOutline16, IconSearchOutline16, IconSparkle16,
|
||||
IconThinkOutline14,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ToolRowOwnerProps } from '../contract/slots.ts'
|
||||
import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.ts'
|
||||
import { ToolRow } from './ToolRow.tsx'
|
||||
import { IconSparkle16 } from './IconSparkle16.tsx'
|
||||
|
||||
/** Variant leading icons (figma table); all glyphs render at 14 inside the 16px leading box. */
|
||||
const VARIANT_ICONS: Record<ToolRowVariant, ReactNode> = {
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
// Local sparkle icon for the Others tool-row variant (figma 43:31850 leading
|
||||
// glyph is an SF Symbols "sparkles" text glyph — not extractable as vector
|
||||
// data, so this is a hand-authored three-star approximation). Lives here
|
||||
// rather than ui-primitives until the exact glyph is exported and adopted
|
||||
// into the ic_ds_* family.
|
||||
|
||||
export function IconSparkle16({ size = 16, className }: { size?: number; className?: string }) {
|
||||
return (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.1 3.1Q6.6 7.8 11.3 8.3Q6.6 8.8 6.1 13.5Q5.6 8.8 0.9 8.3Q5.6 7.8 6.1 3.1Z" fill="currentColor" />
|
||||
<path d="M11.9 1Q12.2 3.7 14.9 4Q12.2 4.3 11.9 7Q11.6 4.3 8.9 4Q11.6 3.7 11.9 1Z" fill="currentColor" />
|
||||
<path d="M12.5 9.4Q12.7 11.4 14.7 11.6Q12.7 11.8 12.5 13.8Q12.3 11.8 10.3 11.6Q12.3 11.4 12.5 9.4Z" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
/* Amber pending strip (approval waiting = warn semantic, figma state colors). */
|
||||
|
||||
.card {
|
||||
margin: 6px 0;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--dsw-alias-state-warn-secondary);
|
||||
border-radius: 10px;
|
||||
background: var(--dsw-alias-state-warn-tertiary);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: var(--ds-font-family-code);
|
||||
}
|
||||
|
||||
.reason {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
// PendingCard: approval/question placeholder card (visible, not answerable —
|
||||
// the composer-takeover approval panel is a P-II item; wire pending semantics
|
||||
// already exist so the flow must show them).
|
||||
|
||||
import { memo } from 'react'
|
||||
import type { PendingInteraction } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import css from './PendingCard.module.css'
|
||||
|
||||
export interface PendingCardProps {
|
||||
item: PendingInteraction
|
||||
}
|
||||
|
||||
export const PendingCard = memo(function PendingCard({ item }: PendingCardProps) {
|
||||
return (
|
||||
<div className={css.card}>
|
||||
{item.kind === 'approval' ? (
|
||||
<>
|
||||
<div className={css.title}>等待审批:<span className={css.mono}>{item.payload.toolName}</span></div>
|
||||
{item.payload.reason !== undefined && <div className={css.reason}>{item.payload.reason}</div>}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className={css.title}>等待回答({item.payload.questions.length} 题)</div>
|
||||
<JsonBlock label="问题内容" payload={item.payload.questions} />
|
||||
</>
|
||||
)}
|
||||
<div className={css.hint}>请在原客户端处理(web 端作答后续里程碑提供)</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -3,7 +3,7 @@ import type { ReactNode, RefObject } from 'react'
|
||||
import type {
|
||||
InjectFace, MaybeSnapshotSelectorHook, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { CommandNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { CommandNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import type { ComposerKeyboard, InputActions, InputNotice, InputState } from '../input/contract.ts'
|
||||
import type { createChatStore } from '../stores.ts'
|
||||
@@ -83,9 +83,10 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
*/
|
||||
'conversation.composer.bar': { kind: 'single'; scope: 'session'; owner: ComposerBarOwnerProps }
|
||||
/**
|
||||
* The Plan-mode control seat in the composer tool row (left group).
|
||||
* Declared by the composer-bar entry; empty until a plan plugin
|
||||
* registers (B ruling: no placeholder fallback).
|
||||
* The Plan-mode status seat in the composer tool row (left group,
|
||||
* right of the access-mode control). Declared by the composer-bar
|
||||
* entry; empty until a plan plugin registers (B ruling: no placeholder
|
||||
* fallback).
|
||||
*/
|
||||
'conversation.input.plan': { kind: 'single'; scope: 'session'; owner: InputControlOwnerProps }
|
||||
/**
|
||||
@@ -250,6 +251,12 @@ export interface ComposerBarInjected {
|
||||
keyboard: ComposerKeyboard
|
||||
/** Cancel the in-flight turn. */
|
||||
stop: () => void
|
||||
/**
|
||||
* Submit one slash-command line against this session's agent (the chrome
|
||||
* controls' write path — the permission chip submits `/permission <preset>`).
|
||||
* Resolves admission: false = rejected/unmatched/transport failure.
|
||||
*/
|
||||
command: (line: string) => Promise<boolean>
|
||||
/** Registrant hooks compartment: the renderer binds these to useNotices/useLexicon. */
|
||||
hooks: {
|
||||
/** Latest surfaced notice (null after none; seq keys re-render of repeats). */
|
||||
@@ -306,6 +313,68 @@ export type ConversationSessionSlotProps =
|
||||
& PropsStore<ChatStore>
|
||||
& ConversationSessionInjected
|
||||
|
||||
/** The pending approval carrier the owner dispatches into the composer chain. */
|
||||
export type ApprovalWait = PendingWait<'approval'>
|
||||
|
||||
/**
|
||||
* Approval domain face over the carrier (the ui-question PendingQuestion
|
||||
* pattern): render identity and question material forwarded transparently;
|
||||
* answer owns the wire encoding — the ApprovalResponsePayload value shape
|
||||
* with the audit correlation the host reconciles — and turns a rejected
|
||||
* carrier receipt into a thrown error. Minted per carrier via useMemo.
|
||||
*/
|
||||
export class PendingApproval {
|
||||
/**
|
||||
* @param wait - the runtime carrier for one pending approval question.
|
||||
*/
|
||||
constructor(private readonly wait: ApprovalWait) {}
|
||||
|
||||
/** Opaque render identity (React key / one-shot latch remount axis), forwarded from the carrier. */
|
||||
get key(): string {
|
||||
return this.wait.key
|
||||
}
|
||||
|
||||
/** The tool the question is about (headline fallback), forwarded from the carrier payload. */
|
||||
get toolName(): string {
|
||||
return this.wait.payload.toolName
|
||||
}
|
||||
|
||||
/** The asker's human-readable WHY (headline when present), forwarded from the carrier payload. */
|
||||
get reason(): string | undefined {
|
||||
return this.wait.payload.reason
|
||||
}
|
||||
|
||||
/** The paired tool call's id when the ask names one (command-line lookup key), forwarded from the carrier payload. */
|
||||
get callId(): string | undefined {
|
||||
return this.wait.payload.callId
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver the user's decision; a rejected carrier receipt throws. Panel
|
||||
* removal stays frame-driven: the broadcast `approval/resolved` settles the
|
||||
* wait and drops it from the pending list.
|
||||
* @param outcome - the only two client-answerable outcomes.
|
||||
*/
|
||||
async answer(outcome: 'allowed-once' | 'rejected'): Promise<void> {
|
||||
const receipt = await this.wait.respond({
|
||||
ok: true,
|
||||
value: { sessionId: this.wait.sessionId, approvalId: this.wait.payload.approvalId, outcome },
|
||||
})
|
||||
if (!receipt.accepted) {
|
||||
throw new Error(`approval response rejected: ${receipt.reason}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full approval-composer props: the framework runtime share (chain currency +
|
||||
* session/global standard kit) plus the chain `matched` share — the entry's
|
||||
* selector result, already narrowed to the approval carrier. No injected
|
||||
* share: the carrier plus the domain face above carry the whole behavior
|
||||
* surface; the paired command line derives from useSession in-component.
|
||||
*/
|
||||
export type ApprovalComposerProps = PropsRuntime<'conversation.composer'> & { matched: ApprovalWait }
|
||||
|
||||
/**
|
||||
* Injected share of the chat view entry: the two callbacks whose targets live
|
||||
* outside the view (layout orchestration; the session object layer).
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
* between the independently implemented skeleton and chat domains; `apply.ts`
|
||||
* owns their slot assembly.
|
||||
*/
|
||||
import type { ConversationService } from './service.ts'
|
||||
|
||||
export { apply, inject } from './apply.ts'
|
||||
export { ConversationService } from './service.ts'
|
||||
export type { IConversation } from './service.ts'
|
||||
|
||||
export type {
|
||||
CallId, ChatStoreState, SelectionTarget, ViewTab,
|
||||
@@ -22,6 +21,7 @@ export type {
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
conversation: ConversationService
|
||||
/** The outward face only; the concrete service stays inside this plugin. */
|
||||
conversation: import('./service.ts').IConversation
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,8 @@
|
||||
* bail events) and owns the default-sink choreography: every session is a
|
||||
* real host entity, so the sink is one unconditional prompt path.
|
||||
*/
|
||||
import type { ClientContext, Session, SessionBinding, SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SlashController, SlashServiceContract } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import type { ClientContext, ISessions, SessionBinding, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SlashController } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import { queueReadFaceOf } from '../queue/store.ts'
|
||||
import type { ComposerKeyboard, InputService, SessionInput } from './contract.ts'
|
||||
import type { PopupDismissFace } from './facade.ts'
|
||||
@@ -113,7 +112,7 @@ export class InputHub implements InputService {
|
||||
* exactly one path; a failed first prompt is an ordinary prompt failure
|
||||
* (error strip via promptError, draft restored only while untouched).
|
||||
*/
|
||||
private sink(session: Session, text: string, mode: 'queue' | 'steer'): void {
|
||||
private sink(session: SessionFace, text: string, mode: 'queue' | 'steer'): void {
|
||||
if (text === '') return
|
||||
const shell = this.shells.get(session.sessionId)
|
||||
// Commit, not an editable clear: undo must not resurrect sent content.
|
||||
@@ -129,7 +128,7 @@ export class InputHub implements InputService {
|
||||
}
|
||||
|
||||
private controller(actx: ClientContext): SlashController | undefined {
|
||||
const slash = this.rootCtx.get('slash') as SlashServiceContract | undefined
|
||||
const slash = this.rootCtx.get('slash')
|
||||
return slash?.sessionOf(actx)
|
||||
}
|
||||
|
||||
@@ -138,7 +137,7 @@ export class InputHub implements InputService {
|
||||
return command?.popupFor(actx)
|
||||
}
|
||||
|
||||
private sessions(): SessionsService {
|
||||
private sessions(): ISessions {
|
||||
const sessions = this.rootCtx.get('sessions')
|
||||
if (sessions === undefined) throw new Error('conversation.input: sessions service unavailable')
|
||||
return sessions
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* reference-stable across unrelated snapshot swaps, so this is a pure
|
||||
* projection — no second store, no copy.
|
||||
*/
|
||||
import type { ObservableSnapshot, Session } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ObservableSnapshot, SessionFace } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { QueuedMessage } from '../input/contract.ts'
|
||||
|
||||
/**
|
||||
@@ -13,10 +13,10 @@ import type { QueuedMessage } from '../input/contract.ts'
|
||||
* The wiring layer (T5) overlays this onto InputState.queue; the runtime
|
||||
* QueuedMessage and the input-contract QueuedMessage are structurally the
|
||||
* same frozen shape ({key, preview}).
|
||||
* @param session - the resident session instance.
|
||||
* @param session - the resident session face.
|
||||
* @returns the queue read face (snapshot reference stable while the queue is unchanged).
|
||||
*/
|
||||
export function queueReadFaceOf(session: Session): ObservableSnapshot<readonly QueuedMessage[]> {
|
||||
export function queueReadFaceOf(session: SessionFace): ObservableSnapshot<readonly QueuedMessage[]> {
|
||||
return {
|
||||
getSnapshot: () => session.getSnapshot().queue,
|
||||
subscribe: fn => session.subscribe(fn),
|
||||
|
||||
@@ -12,24 +12,50 @@ import type { Context } from 'cordis'
|
||||
// Type-only imports: a plugin-to-plugin value import is a bundle purity
|
||||
// error, so scope resolution goes through the sessions service (scopeOf
|
||||
// method) instead of the standalone helper.
|
||||
import type { Session, SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { InputHub } from './input/hub.ts'
|
||||
import type { ISessions, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { InputService } from './input/contract.ts'
|
||||
|
||||
/**
|
||||
* The outward conversation face (`ctx.conversation`): the scope-addressed
|
||||
* verbs and the input registry other plugins may reach — and exactly what a
|
||||
* test fake must supply.
|
||||
*/
|
||||
export interface IConversation {
|
||||
/** The per-session input machine registry (InputService face). */
|
||||
readonly input: InputService
|
||||
/**
|
||||
* Send a prompt into the caller scope's session.
|
||||
* @param text - prompt text, sent verbatim as one text block.
|
||||
* @param mode - queue after the current turn, or steer into it.
|
||||
* @returns completion; business failures reject (and land in promptError).
|
||||
*/
|
||||
send(text: string, mode: 'queue' | 'steer'): Promise<void>
|
||||
/**
|
||||
* Cancel the scoped session's in-flight turn.
|
||||
* @returns completion; failures reject as in send.
|
||||
*/
|
||||
cancel(): Promise<void>
|
||||
/**
|
||||
* Pull one older history page for the scoped session.
|
||||
* @returns completion of the page pull.
|
||||
*/
|
||||
loadOlder(): Promise<void>
|
||||
}
|
||||
|
||||
/** Scope-addressed conversation service (root singleton, provided as `conversation`). */
|
||||
export class ConversationService extends Service {
|
||||
export class ConversationService extends Service implements IConversation {
|
||||
/** The per-session input machine registry (InputService face, design §5.2). */
|
||||
readonly input: InputHub
|
||||
readonly input: InputService
|
||||
|
||||
/**
|
||||
* @param ctx - owning root context (the plugin apply context; the service
|
||||
* registers itself and follows that fiber's lifetime).
|
||||
* @param config - the shared InputHub constructed by the plugin apply
|
||||
* (shared with the slot inject factories); absent = own instance
|
||||
* (object-layer tests that never touch slots).
|
||||
* @param config - carries the InputService instance constructed by the
|
||||
* plugin apply (the same InputHub the slot inject factories close over).
|
||||
*/
|
||||
constructor(ctx: Context, config?: { input?: InputHub }) {
|
||||
constructor(ctx: Context, config: { input: InputService }) {
|
||||
super(ctx, 'conversation')
|
||||
this.input = config?.input ?? new InputHub(ctx)
|
||||
this.input = config.input
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,8 +83,8 @@ export class ConversationService extends Service {
|
||||
await this.scopedSession('loadOlder').loadOlder()
|
||||
}
|
||||
|
||||
/** Resolve the caller scope's Session or throw on root contexts. */
|
||||
private scopedSession(op: string): Session {
|
||||
/** Resolve the caller scope's session face or throw on root contexts. */
|
||||
private scopedSession(op: string): SessionFace {
|
||||
const id = this.scopeId(op)
|
||||
const binding = this.requireSessions().binding(id)
|
||||
if (binding === undefined) throw new Error(`conversation.${op}: session "${id}" resolved no binding`)
|
||||
@@ -74,10 +100,9 @@ export class ConversationService extends Service {
|
||||
return id
|
||||
}
|
||||
|
||||
private requireSessions(): SessionsService {
|
||||
// ctx.get instead of ctx.sessions: the typed Context merge is suspended
|
||||
// while the client/host `sessions` declaration collision awaits
|
||||
// arbitration (see the runtime package's Context merge note).
|
||||
private requireSessions(): ISessions {
|
||||
// Strict ctx.get, not the injection proxy: the scope-addressed pattern
|
||||
// reads the service off whatever context the tracker rebound.
|
||||
const sessions = this.ctx.get('sessions')
|
||||
if (sessions === undefined) throw new Error('conversation: sessions service unavailable')
|
||||
return sessions
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/* Composer-takeover approval panel (draft approval.png): the same floating
|
||||
capsule footprint as the InputBar card, with an amber header band, the
|
||||
justification headline, a muted command line, and right-aligned actions.
|
||||
Warn semantics ride the alias state tokens; no hardcoded colors. */
|
||||
|
||||
/* Mirrors InputBar .root so the takeover is a content swap, not a layout jump. */
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 8px 32px 12px;
|
||||
}
|
||||
|
||||
.card {
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
max-width: 776px;
|
||||
border: 1px solid var(--dsw-alias-state-warn-secondary);
|
||||
border-radius: 20px;
|
||||
background: var(--dsw-specific-input-major);
|
||||
box-shadow: var(--dsw-shadow-lv2);
|
||||
}
|
||||
|
||||
/* Tinted full-width header band. */
|
||||
.strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
background: var(--dsw-alias-state-warn-tertiary);
|
||||
color: var(--dsw-alias-state-warn-primary);
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--dsw-alias-state-warn-primary);
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 12px 16px 14px;
|
||||
}
|
||||
|
||||
/* The model's justification is the panel's message, not a footnote. */
|
||||
.headline {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.command {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-family: var(--ds-font-family-code);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.actionRow {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.allow,
|
||||
.reject {
|
||||
padding: 6px 16px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.allow:disabled,
|
||||
.reject:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Primary action: filled ink (draft's rightmost emphasis, minus the dropped
|
||||
always-allow button). */
|
||||
.allow {
|
||||
border: none;
|
||||
background: var(--dsw-alias-label-primary);
|
||||
color: var(--dsw-alias-label-primary-foreground);
|
||||
}
|
||||
|
||||
/* Secondary: quiet outline. */
|
||||
.reject {
|
||||
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.reject:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover-danger);
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
border-color: transparent;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// ApprovalPanel: the composer-takeover approval prompt (designer draft
|
||||
// approval.png), registered as a selector-routed entry of the
|
||||
// conversation-declared composer chain. While an approval question is
|
||||
// pending, this panel occupies the composer slot in place of the InputBar:
|
||||
// an amber "Waiting for approval" strip on the card top, the model's
|
||||
// justification as the headline, the paired command in muted code text, and
|
||||
// a right-aligned refuse/allow action row. One-shot: the buttons disable
|
||||
// after a click and the panel leaves (the InputBar returns) on the broadcast
|
||||
// resolved frame. The draft's "Always allow this type" is deferred with
|
||||
// grant storage.
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { RunningToolCall } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { PendingApproval, type ApprovalComposerProps } from '../contract/slots.ts'
|
||||
import css from './ApprovalPanel.module.css'
|
||||
|
||||
/** Extract the shell command from an approval's paired running call (bash-family args carry `command`); undefined hides the line. */
|
||||
export function commandOf(call: RunningToolCall | undefined): string | undefined {
|
||||
if (call === undefined) return undefined
|
||||
try {
|
||||
const args = JSON.parse(call.argsRaw) as Record<string, unknown>
|
||||
return typeof args.command === 'string' ? args.command : undefined
|
||||
} catch {
|
||||
// Unparseable model args: the panel still renders, just without the command line.
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Composer takeover boundary: mints the domain face on the carrier's stable
|
||||
* identity and remounts the flow per request key, so the one-shot answered
|
||||
* latch never leaks to the next pending approval.
|
||||
* @param props - the selector-matched pending approval carrier plus the framework standard kit.
|
||||
* @returns The approval prompt for this request.
|
||||
*/
|
||||
export function ApprovalPanel(props: ApprovalComposerProps) {
|
||||
const approval = useMemo(() => new PendingApproval(props.matched), [props.matched])
|
||||
const command = props.useSession(s => commandOf(
|
||||
approval.callId === undefined ? undefined : s.runningCalls.find(call => call.callId === approval.callId)))
|
||||
return <ApprovalFlow key={approval.key} pending={approval} {...command === undefined ? {} : { command }} />
|
||||
}
|
||||
|
||||
function ApprovalFlow({ pending, command }: { pending: PendingApproval; command?: string }) {
|
||||
// Local one-shot latch: the panel leaves only when the resolved frame
|
||||
// lands; until then the buttons must not re-fire. An answer failure
|
||||
// (rejected receipt / transport) re-arms them for retry.
|
||||
const [answered, setAnswered] = useState(false)
|
||||
const answer = (outcome: 'allowed-once' | 'rejected'): void => {
|
||||
setAnswered(true)
|
||||
void pending.answer(outcome).catch(() => { setAnswered(false) })
|
||||
}
|
||||
return (
|
||||
<div className={css.root} data-approval-key={pending.key}>
|
||||
<div className={css.card}>
|
||||
<div className={css.strip}><span className={css.dot} />等待审批</div>
|
||||
<div className={css.body}>
|
||||
<div className={css.headline}>{pending.reason ?? `工具 ${pending.toolName} 请求越权执行`}</div>
|
||||
{command !== undefined && <div className={css.command}>{command}</div>}
|
||||
<div className={css.actionRow}>
|
||||
<button type="button" className={css.reject} disabled={answered} onClick={() => { answer('rejected') }}>
|
||||
拒绝
|
||||
</button>
|
||||
<button type="button" className={css.allow} disabled={answered} onClick={() => { answer('allowed-once') }}>
|
||||
允许一次
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,12 +6,16 @@
|
||||
* region-slot content) ride the owner props. Session facts
|
||||
* (running/removed/promptError) are self-selected via useSession. */
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
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'
|
||||
import type { ComposerBarProps } from '../contract/slots.ts'
|
||||
import { deriveDecorations } from '../input/decorations.ts'
|
||||
import { PermissionSelect } from './PermissionSelect.tsx'
|
||||
import css from './InputBar.module.css'
|
||||
|
||||
/** Prompt failure surface (derived from promptError). */
|
||||
@@ -22,13 +26,8 @@ export interface InputBarError {
|
||||
|
||||
export type InputBarProps = ComposerBarProps
|
||||
|
||||
const READONLY_OPTIONS: readonly { id: string; label: string }[] = [
|
||||
{ id: 'readonly', label: 'Read-only' },
|
||||
{ id: 'readwrite', label: 'Read-write' },
|
||||
]
|
||||
|
||||
export function InputBar({
|
||||
useSession, useInput, inputActions, keyboard, stop, renderSlot, useNotices, useLexicon,
|
||||
useSession, useInput, inputActions, keyboard, stop, command, renderSlot, useNotices, useLexicon, useProjection,
|
||||
variant, placeholder, accessory, overlay, leftItems, rightItems, onAdd, addLabel = 'Add attachment',
|
||||
}: InputBarProps) {
|
||||
const input = useInput(s => s)
|
||||
@@ -37,6 +36,9 @@ export function InputBar({
|
||||
const promptError = useSession(s => s.promptError)
|
||||
const running = useSession(s => s.running)
|
||||
const disabled = useSession(s => s.removed)
|
||||
// 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))
|
||||
// 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.
|
||||
@@ -58,9 +60,9 @@ export function InputBar({
|
||||
}, 10)
|
||||
}
|
||||
|
||||
// Placeholder chrome: Access selection stays local until its seam lands
|
||||
// (plan/model are real seats now — the named single slots below).
|
||||
const [readonlyId, setReadonlyId] = useState('readonly')
|
||||
// The Access seat's data: the host-computed permissions projection
|
||||
// (undefined = capability absent → the chip renders nothing).
|
||||
const permissions = useProjection('permissions')
|
||||
|
||||
// Queue cut 1: running input stays free; locked = session disabled only.
|
||||
// The transient machine locks (adjudicating pending / submitting) render
|
||||
@@ -223,19 +225,10 @@ export function InputBar({
|
||||
if (!empty && !disabled && !machineBusy) inputActions.submit('queue')
|
||||
}
|
||||
|
||||
// Access placeholder select (the one remaining local-chrome control).
|
||||
// The Access seat: the projection-fed permission chip (renders nothing
|
||||
// while the permissions key is absent — permission-less host or Draft).
|
||||
const accessSelect: ReactNode = (
|
||||
<select
|
||||
className={css.select}
|
||||
aria-label="Access mode"
|
||||
value={readonlyId}
|
||||
disabled={locked}
|
||||
onChange={(e: ChangeEvent<HTMLSelectElement>) => { setReadonlyId(e.target.value) }}
|
||||
>
|
||||
{READONLY_OPTIONS.map(opt => (
|
||||
<option key={opt.id} value={opt.id}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<PermissionSelect value={permissions} locked={locked} command={command} />
|
||||
)
|
||||
|
||||
// Mirror-layer decorations: a visible backdrop with transparent text. The
|
||||
@@ -334,7 +327,9 @@ export function InputBar({
|
||||
disabled={locked}
|
||||
readOnly={machineBusy}
|
||||
data-phase={input.phase}
|
||||
placeholder={placeholder ?? (disabled ? 'Session unavailable' : 'Message the agent')}
|
||||
placeholder={placeholder ?? (disabled
|
||||
? 'Session unavailable'
|
||||
: planActive ? 'describe your task to generate plan' : 'Message the agent')}
|
||||
rows={2}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
@@ -361,15 +356,15 @@ export function InputBar({
|
||||
<IconPlusOutline16 size={14} />
|
||||
</button>
|
||||
<div className={css.modes}>
|
||||
{renderSlot('conversation.input.plan', { locked })}
|
||||
{accessSelect}
|
||||
{renderSlot('conversation.input.plan', { locked })}
|
||||
</div>
|
||||
{leftItems}
|
||||
</div>
|
||||
<div className={css.trailing}>
|
||||
{rightItems}
|
||||
{renderSlot('conversation.input.model', { locked })}
|
||||
{machineBusy && <span className={css.pending} data-input-pending aria-label="处理中" />}
|
||||
{/* {machineBusy && <span className={css.pending} data-input-pending aria-label="处理中" />} */}
|
||||
<button
|
||||
type="button"
|
||||
className={css.primary}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/* 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 {
|
||||
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;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.select:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.root:has(.select:disabled) .chip {
|
||||
opacity: 0.5;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// 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 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.
|
||||
*/
|
||||
function displayName(name: string): string {
|
||||
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) return name
|
||||
return name.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
|
||||
}
|
||||
|
||||
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)
|
||||
if (value === undefined) return null
|
||||
|
||||
const currentValue = pick ?? value.currentValue
|
||||
const current = value.options.find(option => option.value === currentValue)
|
||||
|
||||
const onChange = (next: string): void => {
|
||||
if (next === value.currentValue) return
|
||||
setPick(next)
|
||||
void command(`/permission ${next}`)
|
||||
.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>
|
||||
)
|
||||
}
|
||||
@@ -1,144 +1,73 @@
|
||||
// @vitest-environment jsdom
|
||||
// apply inject factories exercised end to end against the terminal thin
|
||||
// shape: the conversation surface (views triple, send choreography incl.
|
||||
// optimistic clear + failure restore THROUGH the declared store actions,
|
||||
// openDetails = select action + layout orchestration, sessions.open
|
||||
// navigation), and the closeDetails details surface. Complements
|
||||
// chat-apply.spec.tsx (registration)
|
||||
// and selection-survival.spec.ts (store axis). History opening is NOT an
|
||||
// inject concern anymore — the runtime sessions service opens on watch
|
||||
// (sessions-service.spec.ts owns that behavior).
|
||||
// shape: the strict session surface (views triple, draft mirror), the
|
||||
// provide-channel input face (machine-sink submit choreography incl.
|
||||
// optimistic clear + failure restore), the resident surface (selectWorkspace
|
||||
// draft carrying), the composer-bar stop face, openDetails = select action +
|
||||
// layout orchestration, and the closeDetails details surface. Complements
|
||||
// chat-apply.spec.tsx (registration) and selection-survival.spec.tsx (store
|
||||
// axis). History opening is NOT an inject concern — the runtime sessions
|
||||
// service opens on watch (sessions-service.spec.ts owns that behavior).
|
||||
//
|
||||
// The inject surfaces are read off the ledger entries deliberately (typed at
|
||||
// this spec's own contract): these cases pin factory choreography the UI
|
||||
// guards would mask. Rendering-path acceptance lives in
|
||||
// chat-toolview-slot.spec.tsx.
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup } from '@testing-library/react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { SlotsService, scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
SessionId, SessionListState, WorkspaceListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SlotRendererHost } from '@deepseek-ai/dsh-client-web-react'
|
||||
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 type { ISession, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type {
|
||||
ChatViewInjected, ComposerBarInjected, ConversationInjected, ConversationSessionInjected, DetailsInjected,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { createChatStore } from '../src/client/stores.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const ROOT = 'root-1' as SessionId
|
||||
|
||||
type ChatInstance = ReturnType<ReturnType<typeof createChatStore>['create']>
|
||||
type ChatActions = ChatInstance['actions']
|
||||
|
||||
const SCOPE_TAG: symbol = (() => {
|
||||
const recorded: (string | symbol)[] = []
|
||||
const spy = new Proxy(new Context(), {
|
||||
get(target, prop, receiver) {
|
||||
recorded.push(prop)
|
||||
// Reflect.get is typed any; the probe only records property names.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return Reflect.get(target, prop, receiver)
|
||||
},
|
||||
})
|
||||
void scopeOf(spy)
|
||||
const symbol = recorded.find((p): p is symbol => typeof p === 'symbol')
|
||||
if (symbol === undefined) throw new Error('scopeOf probe recorded no symbol read')
|
||||
return symbol
|
||||
})()
|
||||
/** ISession verb mocks, typed against the production face (['prompt'] etc. keep vitest mock ergonomics). */
|
||||
function sessionFakeFor() {
|
||||
return {
|
||||
open: vi.fn(() => Promise.resolve()),
|
||||
loadOlder: vi.fn<ISession['loadOlder']>(() => Promise.resolve()),
|
||||
prompt: vi.fn<ISession['prompt']>(() => Promise.resolve({ ok: true, value: { accepted: true } })),
|
||||
cancel: vi.fn<ISession['cancel']>(() => Promise.resolve({ ok: true, value: { accepted: true } })),
|
||||
} satisfies SessionBehaviorOverrides
|
||||
}
|
||||
|
||||
async function bench() {
|
||||
const ctx = new Context()
|
||||
const slotsFiber = ctx.plugin(SlotsService)
|
||||
await slotsFiber.await()
|
||||
const slots = ctx.get('slots') as SlotsService
|
||||
|
||||
const listStore = createSnapshotStore<SessionListState>({
|
||||
ids: [ROOT],
|
||||
byId: { [ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', cwd: '/proj', running: false, blank: false, updatedAt: 1 } },
|
||||
current: ROOT,
|
||||
phase: 'ready',
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
const sessionFake = sessionFakeFor()
|
||||
await runtime.sessions.add({
|
||||
id: ROOT,
|
||||
summary: { title: 'R', displayTitle: 'R', cwd: '/proj' },
|
||||
session: sessionFake,
|
||||
})
|
||||
const sessionFake = {
|
||||
sessionId: ROOT,
|
||||
open: vi.fn(() => Promise.resolve()),
|
||||
loadOlder: vi.fn(() => Promise.resolve()),
|
||||
prompt: vi.fn<() => Promise<{ ok: boolean; value?: object; error?: { code: string; message: string } }>>(
|
||||
() => Promise.resolve({ ok: true, value: { accepted: true } })),
|
||||
cancel: vi.fn<() => Promise<{ ok: boolean; value?: object; error?: { code: string; message: string } }>>(
|
||||
() => Promise.resolve({ ok: true, value: { accepted: true } })),
|
||||
// Observable face (the input machine's queue read face rides it).
|
||||
getSnapshot: () => ({ queue: [] }),
|
||||
subscribe: () => () => {},
|
||||
}
|
||||
const scopes = new Map<SessionId, Context>()
|
||||
const mint = (id: SessionId): Context => {
|
||||
let scoped = scopes.get(id)
|
||||
if (scoped === undefined) {
|
||||
scoped = ctx.plugin(() => {}).ctx.extend({ [SCOPE_TAG]: id })
|
||||
scopes.set(id, scoped)
|
||||
}
|
||||
return scoped
|
||||
}
|
||||
type TestProvider = {
|
||||
resolve(binding: { sessionId: SessionId; session: typeof sessionFake; ctx: Context }): {
|
||||
hooks?: Record<string, unknown>
|
||||
props?: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
const providers: TestProvider[] = []
|
||||
const absentInfo = { sessionId: undefined, hooks: {}, props: {} }
|
||||
const sessionsFake = {
|
||||
list: listStore,
|
||||
binding: (id: SessionId) => ({ sessionId: id, session: sessionFake, ctx: mint(id) }),
|
||||
scope: (id: SessionId) => mint(id),
|
||||
provideInfo: () => undefined,
|
||||
currentProvideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} },
|
||||
provide: (descriptor: TestProvider) => { providers.push(descriptor); return () => {} },
|
||||
scopeOf,
|
||||
sessionOf: (actx: Context) => (scopeOf(actx) === undefined ? undefined : sessionFake),
|
||||
open: vi.fn(),
|
||||
}
|
||||
ctx.provide('sessions', sessionsFake)
|
||||
const workspaceStore = createSnapshotStore<WorkspaceListState>({
|
||||
items: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
})
|
||||
const workspacesFake = {
|
||||
list: workspaceStore,
|
||||
connectWorkspace: vi.fn(async () => ROOT),
|
||||
openPath: vi.fn(async () => {}),
|
||||
}
|
||||
ctx.provide('workspaces', workspacesFake)
|
||||
const layoutFake = { openDetails: vi.fn(), closeDetails: vi.fn() }
|
||||
ctx.provide('layout', layoutFake)
|
||||
ctx.provide('locale', { bind: () => (key: string) => key })
|
||||
runtime.provide('layout', layoutFake)
|
||||
|
||||
// The AppFrame role: the three conversation-package slots must be declared
|
||||
// by a live entry before apply can contribute into them (the stand-in
|
||||
// consumes renderSlot to satisfy the declare-means-render check).
|
||||
slots.register({
|
||||
name: 'root',
|
||||
children: {
|
||||
'conversation': { kind: 'single', scope: 'session-maybe' },
|
||||
'details': { kind: 'single', scope: 'session' },
|
||||
},
|
||||
// The AppFrame role: the conversation-package slots must be declared by a
|
||||
// live entry before apply can contribute into them.
|
||||
await runtime.root.declare({
|
||||
'conversation': { kind: 'single', scope: 'session-maybe' },
|
||||
'details': { kind: 'single', scope: 'session' },
|
||||
}, (_p: { renderSlot?: unknown }) => null)
|
||||
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
const feature = await runtime.mount({ inject: [...inject], apply })
|
||||
|
||||
// Reach the render-side entry view (inject + store handle) the way the
|
||||
// renderer does: through the host face.
|
||||
let host: SlotRendererHost | undefined
|
||||
slots.install({ renderRoot: (h) => { host = h; return null } })
|
||||
slots.renderSlot('root', {})
|
||||
const hostFace = host!
|
||||
const entryOf = (key: 'conversation' | 'conversation.session' | 'conversation.composer.bar' | 'conversation.view' | 'details') => hostFace.entriesOf(key)[0]!
|
||||
// The host face (store resolution) exists only inside the installed
|
||||
// renderer, so materialize it the way the shell does.
|
||||
runtime.renderRoot()
|
||||
const entryOf = (key: 'conversation' | 'conversation.session' | 'conversation.composer.bar' | 'conversation.view' | 'details') =>
|
||||
runtime.slots.entries(key)[0]!
|
||||
/** Resolve store instance + call the inject the way the outlet would. */
|
||||
const conversationSurface = (id: SessionId) => {
|
||||
const entry = entryOf('conversation.session')
|
||||
const instance = hostFace.storeOf(entry, id) as ChatInstance
|
||||
const instance = runtime.storeOf('conversation.session', id) as ChatInstance
|
||||
const injected = (entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ConversationSessionInjected)(
|
||||
id, instance.actions)
|
||||
return { instance, injected }
|
||||
@@ -154,27 +83,28 @@ async function bench() {
|
||||
/** Same resolution for the chat entry riding the view ring. */
|
||||
const chatViewSurface = (id: SessionId) => {
|
||||
const entry = entryOf('conversation.view')
|
||||
const instance = hostFace.storeOf(entry, id) as ChatInstance
|
||||
const instance = runtime.storeOf('conversation.view', id) as ChatInstance
|
||||
const injected = (entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ChatViewInjected)(
|
||||
id, instance.actions)
|
||||
return { instance, injected }
|
||||
}
|
||||
/** Materialize the input provide contribution the way the runtime does. */
|
||||
const inputSurface = (id: SessionId) => {
|
||||
const contribution = providers[0]!.resolve(sessionsFake.binding(id))
|
||||
const state = contribution.hooks!['input'] as {
|
||||
const info = runtime.sessions.provideInfo(id)!
|
||||
const state = info.hooks['input'] as {
|
||||
getSnapshot: () => { draft: string }
|
||||
subscribe: (fn: () => void) => () => void
|
||||
}
|
||||
const actions = contribution.props!['inputActions'] as {
|
||||
const actions = info.props['inputActions'] as {
|
||||
setDraft: (text: string) => void
|
||||
submit: (mode?: 'queue' | 'steer') => void
|
||||
}
|
||||
return { state, actions }
|
||||
}
|
||||
return {
|
||||
ctx, slots, hostFace, entryOf, conversationSurface, residentSurface, composerSurface, chatViewSurface, inputSurface,
|
||||
sessionFake, sessionsFake, workspacesFake, layoutFake, mint,
|
||||
runtime, feature, slots: runtime.slots, entryOf,
|
||||
conversationSurface, residentSurface, composerSurface, chatViewSurface, inputSurface,
|
||||
sessionFake, layoutFake,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,6 +120,7 @@ describe('conversation slot inject surface', () => {
|
||||
const chatView = b.chatViewSurface(ROOT)
|
||||
chatView.injected.loadOlder()
|
||||
expect(b.sessionFake.loadOlder).toHaveBeenCalledTimes(1)
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('the provide-channel input face submits through the machine sink: trim, optimistic clear, failure restore without clobber', async () => {
|
||||
@@ -208,14 +139,14 @@ describe('conversation slot inject surface', () => {
|
||||
await Promise.resolve()
|
||||
expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'queue')
|
||||
// Failure: restored (draft still empty when the rejection lands).
|
||||
b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b' } })
|
||||
b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b', details: { reason: 'b' } } })
|
||||
actions.setDraft('retry me')
|
||||
actions.submit('queue')
|
||||
await vi.waitFor(() => {
|
||||
expect(state.getSnapshot().draft).toBe('retry me')
|
||||
})
|
||||
// Failure landing after new typing: no clobber (restore fills empty only).
|
||||
b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b' } })
|
||||
b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b', details: { reason: 'b' } } })
|
||||
actions.submit('queue')
|
||||
actions.setDraft('typed during flight')
|
||||
await new Promise(r => setTimeout(r, 0))
|
||||
@@ -229,23 +160,25 @@ describe('conversation slot inject surface', () => {
|
||||
expect(mirrored).toEqual(['mirrored text'])
|
||||
unbind()
|
||||
// Stop failure is swallowed (promptError owns the surface).
|
||||
b.sessionFake.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'x' } })
|
||||
b.sessionFake.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'x', details: {} } })
|
||||
b.composerSurface(ROOT).stop()
|
||||
await new Promise(r => setTimeout(r, 0))
|
||||
expect(b.sessionFake.cancel).toHaveBeenCalledTimes(1)
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('inject fails loud when the session resolves no scope or the scope lacks the service', async () => {
|
||||
it('inject fails loud when the session resolves no binding or the scope lacks the service', async () => {
|
||||
const b = await bench()
|
||||
const entry = b.entryOf('conversation.composer.bar')
|
||||
const injectFn = entry.inject as unknown as (sessionId: SessionId) => ComposerBarInjected
|
||||
// Unknown session: sessions.scope answers nothing.
|
||||
;(b.sessionsFake.scope as unknown) = () => undefined
|
||||
expect(() => { injectFn(ROOT).stop() }).toThrow(/resolved no scope/)
|
||||
// A scope minted outside the service tree: no conversation service on it.
|
||||
const foreign = new Context()
|
||||
;(b.sessionsFake.scope as unknown) = () => foreign.plugin(() => {}).ctx.extend({})
|
||||
expect(() => { injectFn(ROOT).stop() }).toThrow(/unavailable through the session scope/)
|
||||
// Unknown session: the keyboard face's binding resolution answers nothing.
|
||||
expect(() => { injectFn('ghost' as SessionId).stop() }).toThrow(/resolved no binding/)
|
||||
// A scope whose service tree lost 'conversation' (the feature fiber
|
||||
// unloaded while a retained inject closure re-runs): fails loud too.
|
||||
const stop = injectFn(ROOT).stop
|
||||
await b.feature.dispose()
|
||||
expect(() => { stop() }).toThrow(/unavailable through the session scope/)
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('openDetails (chat view face) writes the selection through the store actions and opens the panel', async () => {
|
||||
@@ -258,6 +191,7 @@ describe('conversation slot inject surface', () => {
|
||||
// writes land where the skeleton and details read.
|
||||
const conv = b.conversationSurface(ROOT)
|
||||
expect(conv.instance).toBe(instance)
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('openFile (chat view face) resolves against session cwd and calls workspaces.openPath', async () => {
|
||||
@@ -265,8 +199,9 @@ describe('conversation slot inject surface', () => {
|
||||
const { injected } = b.chatViewSurface(ROOT)
|
||||
injected.openFile('src/a.ts')
|
||||
await vi.waitFor(() => {
|
||||
expect(b.workspacesFake.openPath).toHaveBeenCalledWith('/proj/src/a.ts')
|
||||
expect(b.runtime.workspaces.calls).toContainEqual({ method: 'openPath', args: ['/proj/src/a.ts'] })
|
||||
})
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('routes navigation and workspace switching through the runtime owners, carrying the draft', async () => {
|
||||
@@ -274,23 +209,73 @@ describe('conversation slot inject surface', () => {
|
||||
const { injected } = b.conversationSurface(ROOT)
|
||||
const resident = b.residentSurface(ROOT)
|
||||
injected.open(ROOT)
|
||||
expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)
|
||||
expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [ROOT] })
|
||||
// Same-session connect (the picked workspace resolves to this session):
|
||||
// no draft movement, plain re-open.
|
||||
b.runtime.workspaces.stub('connectWorkspace', () => Promise.resolve(ROOT))
|
||||
const { state, actions } = b.inputSurface(ROOT)
|
||||
actions.setDraft('carry me')
|
||||
void resident.selectWorkspace('workspace-1' as never)
|
||||
await vi.waitFor(() => { expect(b.sessionsFake.open).toHaveBeenCalledTimes(2) })
|
||||
expect(b.workspacesFake.connectWorkspace).toHaveBeenCalledWith('workspace-1')
|
||||
await vi.waitFor(() => {
|
||||
expect(b.runtime.sessions.calls.filter(c => c.method === 'open')).toHaveLength(2)
|
||||
})
|
||||
expect(b.runtime.workspaces.calls).toContainEqual({ method: 'connectWorkspace', args: ['workspace-1'] })
|
||||
expect(state.getSnapshot().draft).toBe('carry me')
|
||||
// Cross-session connect: the draft MOVES — the old machine empties, the
|
||||
// new session's machine receives the text, then navigation lands there.
|
||||
const OTHER = 'other-1' as SessionId
|
||||
b.workspacesFake.connectWorkspace.mockResolvedValueOnce(OTHER)
|
||||
await b.runtime.sessions.add({ id: OTHER }, { current: false })
|
||||
b.runtime.workspaces.stub('connectWorkspace', () => Promise.resolve(OTHER))
|
||||
void resident.selectWorkspace('workspace-2' as never)
|
||||
await vi.waitFor(() => { expect(b.sessionsFake.open).toHaveBeenCalledWith(OTHER) })
|
||||
await vi.waitFor(() => {
|
||||
expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [OTHER] })
|
||||
})
|
||||
expect(state.getSnapshot().draft).toBe('')
|
||||
expect(b.inputSurface(OTHER).state.getSnapshot().draft).toBe('carry me')
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('selectWorkspace edge arms: no-session resident, empty-draft move, connect failure retryable', async () => {
|
||||
const b = await bench()
|
||||
// No-session resident (hero before any session): connect resolves and
|
||||
// navigation proceeds without any draft choreography.
|
||||
const noSession = b.residentSurface(undefined)
|
||||
b.runtime.workspaces.stub('connectWorkspace', () => Promise.resolve(ROOT))
|
||||
void noSession.selectWorkspace('workspace-0' as never)
|
||||
await vi.waitFor(() => {
|
||||
expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [ROOT] })
|
||||
})
|
||||
|
||||
// Cross-session connect with an EMPTY draft: no move, no clearing.
|
||||
const OTHER = 'b9-other' as SessionId
|
||||
await b.runtime.sessions.add({ id: OTHER }, { current: false })
|
||||
const resident = b.residentSurface(ROOT)
|
||||
const { state } = b.inputSurface(ROOT)
|
||||
expect(state.getSnapshot().draft).toBe('')
|
||||
b.runtime.workspaces.stub('connectWorkspace', () => Promise.resolve(OTHER))
|
||||
void resident.selectWorkspace('workspace-3' as never)
|
||||
await vi.waitFor(() => {
|
||||
expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [OTHER] })
|
||||
})
|
||||
expect(b.inputSurface(OTHER).state.getSnapshot().draft).toBe('')
|
||||
|
||||
// Connect failure: the rejection propagates to the caller (the view owns
|
||||
// the rollback) and no further navigation happens.
|
||||
const opens = b.runtime.sessions.calls.filter(c => c.method === 'open').length
|
||||
b.runtime.workspaces.stub('connectWorkspace', () => Promise.reject(new Error('offline')))
|
||||
await expect(resident.selectWorkspace('workspace-4' as never)).rejects.toThrow('offline')
|
||||
expect(b.runtime.sessions.calls.filter(c => c.method === 'open')).toHaveLength(opens)
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('scopedConversation fails loud when the session resolves no scope', async () => {
|
||||
const b = await bench()
|
||||
// The chat-view inject resolves the scoped conversation service at inject
|
||||
// time: an unlisted session hits the scope() === undefined throw directly.
|
||||
const entry = b.entryOf('conversation.view')
|
||||
const injectFn = entry.inject as unknown as (sessionId: SessionId, actions: unknown) => unknown
|
||||
expect(() => injectFn('never-listed' as SessionId, {})).toThrow(/resolved no scope/)
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('views read face projects the ring ledger (subscribe/version through ctx.slots)', async () => {
|
||||
@@ -313,6 +298,7 @@ describe('conversation slot inject surface', () => {
|
||||
off()
|
||||
off2()
|
||||
unsub()
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -325,9 +311,9 @@ describe('details inject surface', () => {
|
||||
injected.closeDetails()
|
||||
expect(b.layoutFake.closeDetails).toHaveBeenCalledTimes(1)
|
||||
// The shared handle: details resolves the SAME instance conversation writes.
|
||||
const conv = b.hostFace.storeOf(b.entryOf('conversation.session'), ROOT)
|
||||
const details = b.hostFace.storeOf(entry, ROOT)
|
||||
const conv = b.runtime.storeOf('conversation.session', ROOT)
|
||||
const details = b.runtime.storeOf('details', ROOT)
|
||||
expect(details).toBe(conv)
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -1,89 +1,53 @@
|
||||
// @vitest-environment jsdom
|
||||
// apply wiring: the conversation service provided, the chat view registered
|
||||
// as the first 'conversation.view' ring entry declaring the keyed toolview
|
||||
// hole, the three slot registrations land against a root entry's children
|
||||
// declarations (the AppFrame role), the shared store handle rides all session
|
||||
// entries, and the bash sample mounts through the load-order seam as a keyed
|
||||
// entry. Full-chain rendering belongs to the machinery spec
|
||||
// (chat-toolview-slot.spec.tsx) and the shell e2e; this spec stops at the
|
||||
// assembly surface.
|
||||
// hole, the slot registrations land against a root entry's children
|
||||
// declarations (the AppFrame role), the shared store handle rides all strict
|
||||
// session entries, and the bash sample + todo row mount through the
|
||||
// load-order seam as keyed entries. Full-chain rendering belongs to the
|
||||
// machinery spec (chat-toolview-slot.spec.tsx) and the shell e2e; this spec
|
||||
// stops at the assembly surface.
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
|
||||
const ROOT = 'root-1' as SessionId
|
||||
const CHILD = 'child-1' as SessionId
|
||||
|
||||
async function bench() {
|
||||
const ctx = new Context()
|
||||
const slotsFiber = ctx.plugin(SlotsService)
|
||||
await slotsFiber.await()
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
await runtime.sessions.add({ id: ROOT, summary: { title: 'R', displayTitle: 'R' } }, { current: false })
|
||||
await runtime.sessions.add(
|
||||
{ id: CHILD, summary: { title: 'C', displayTitle: 'C', parentId: ROOT } }, { current: false })
|
||||
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
|
||||
const listStore = createSnapshotStore<SessionListState>({
|
||||
ids: [ROOT, CHILD],
|
||||
byId: {
|
||||
[ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', running: false, blank: false, updatedAt: 1 },
|
||||
[CHILD]: { id: CHILD, title: 'C', displayTitle: 'C', parentId: ROOT, running: false, blank: false, updatedAt: 2 },
|
||||
},
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
})
|
||||
const absentInfo = { sessionId: undefined, hooks: {}, props: {} }
|
||||
const sessionsFake = {
|
||||
list: listStore,
|
||||
binding: vi.fn(),
|
||||
scope: () => undefined,
|
||||
provideInfo: () => undefined,
|
||||
currentProvideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} },
|
||||
provide: vi.fn(() => () => {}),
|
||||
create: vi.fn(),
|
||||
open: vi.fn(),
|
||||
updateIntent: vi.fn(),
|
||||
}
|
||||
ctx.provide('sessions', sessionsFake)
|
||||
ctx.provide('workspaces', {
|
||||
startSession: vi.fn(),
|
||||
sendSession: vi.fn(),
|
||||
openPath: vi.fn(async () => {}),
|
||||
})
|
||||
ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
ctx.provide('locale', { bind: () => (key: string) => key })
|
||||
|
||||
// Declared by ui-layout's root entry in production; a stand-in root
|
||||
// occupant declares them here so the contributions land (it consumes
|
||||
// renderSlot to satisfy the declare-means-render check).
|
||||
const slots = ctx.get('slots') as SlotsService
|
||||
slots.register({
|
||||
name: 'root',
|
||||
children: {
|
||||
'conversation': { kind: 'single', scope: 'session-maybe' },
|
||||
'details': { kind: 'single', scope: 'session' },
|
||||
},
|
||||
// Declared by ui-layout's root entry in production; the test root declares
|
||||
// them here so the contributions land.
|
||||
await runtime.root.declare({
|
||||
'conversation': { kind: 'single', scope: 'session-maybe' },
|
||||
'details': { kind: 'single', scope: 'session' },
|
||||
}, (_p: { renderSlot?: unknown }) => null)
|
||||
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
return { ctx, fiber, slots }
|
||||
const feature = await runtime.mount({ inject: [...inject], apply })
|
||||
return { runtime, feature, slots: runtime.slots }
|
||||
}
|
||||
|
||||
/** First stored entry for a key (inject/store live directly on StoredEntry). */
|
||||
function renderEntryOf(slots: SlotsService, key: 'conversation' | 'conversation.session' | 'conversation.view' | 'details') {
|
||||
function renderEntryOf(slots: Awaited<ReturnType<typeof bench>>['slots'], key: 'conversation' | 'conversation.session' | 'conversation.view' | 'details') {
|
||||
return slots.entries(key)[0] as undefined | { inject?: unknown; store?: unknown }
|
||||
}
|
||||
|
||||
describe('apply wiring', () => {
|
||||
it('provides the conversation service', async () => {
|
||||
const b = await bench()
|
||||
await b.fiber.await()
|
||||
expect(b.ctx.get('conversation')).toBeDefined()
|
||||
expect(b.runtime.ctx.get('conversation')).toBeDefined()
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('registers the chat view as the first ring entry, declaring the keyed toolview hole', async () => {
|
||||
const b = await bench()
|
||||
await b.fiber.await()
|
||||
const entries = b.slots.entries('conversation.view')
|
||||
expect(entries.map(e => e.options.id)).toEqual(['chat'])
|
||||
expect(entries[0]?.options.label).toBe('Chat')
|
||||
@@ -91,11 +55,11 @@ describe('apply wiring', () => {
|
||||
// Declaring is claiming: the chat entry's registration put the hole on
|
||||
// the ledger with the contract's kind/scope.
|
||||
expect(b.slots.spec('conversation.chat.toolview')).toEqual({ kind: 'keyed', scope: 'session' })
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('occupies the slots + the ring; session entries share one store handle', async () => {
|
||||
const b = await bench()
|
||||
await b.fiber.await()
|
||||
const conversation = renderEntryOf(b.slots, 'conversation')
|
||||
const conversationSession = renderEntryOf(b.slots, 'conversation.session')
|
||||
const chatView = renderEntryOf(b.slots, 'conversation.view')
|
||||
@@ -111,21 +75,21 @@ describe('apply wiring', () => {
|
||||
// The hero workspace picker hole rides the conversation entry's children
|
||||
// declaration (the empty-state occupant is gone).
|
||||
expect(b.slots.spec('conversation.hero.workspace')).toEqual({ kind: 'single', scope: 'root' })
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('mounts the bash sample and the todo row as keyed entries through the load-order seam', async () => {
|
||||
const b = await bench()
|
||||
await b.fiber.await()
|
||||
// Both registrant plugins' inject: ['slots', 'conversation'] resolved — the
|
||||
// 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'])
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('plugin fiber disposal collects every registration (unload cascade, ring and hole included)', async () => {
|
||||
const b = await bench()
|
||||
await b.fiber.await()
|
||||
await b.fiber.dispose()
|
||||
await b.feature.dispose()
|
||||
expect(b.slots.entries('conversation')).toHaveLength(0)
|
||||
// The declared ring collapses with its declaring entry, and the chat
|
||||
// entry's keyed hole (with the sample's registration) collapses with it.
|
||||
@@ -133,6 +97,7 @@ describe('apply wiring', () => {
|
||||
expect(b.slots.entries('conversation.chat.toolview')).toHaveLength(0)
|
||||
expect(b.slots.spec('conversation.chat.toolview')).toBeUndefined()
|
||||
expect(b.slots.entries('details')).toHaveLength(0)
|
||||
expect(b.ctx.get('conversation')).toBeUndefined()
|
||||
expect(b.runtime.ctx.get('conversation')).toBeUndefined()
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
// @vitest-environment jsdom
|
||||
// Remaining chat branch tails: MessageItem context/unknown/steering arms,
|
||||
// user IconActions, StatsLine no-cache join, PendingCard reason strip,
|
||||
// user IconActions, StatsLine no-cache join,
|
||||
// AssistantMarkdown single-line reasoning. (Tool-row dispatch tails live
|
||||
// with the keyed-slot machinery specs since the tool ring dissolved into
|
||||
// renderSlot.)
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { MessageItem } from '../src/client/chat/MessageItem.tsx'
|
||||
import { PendingCard } from '../src/client/chat/PendingCard.tsx'
|
||||
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
|
||||
import { StatsLine, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
|
||||
|
||||
@@ -114,13 +110,6 @@ describe('MessageItem arms', () => {
|
||||
})
|
||||
|
||||
describe('small branch tails', () => {
|
||||
it('PendingCard approval reason renders when present', () => {
|
||||
const view = render(
|
||||
<PendingCard item={new PendingWait('approval', RpcId('r1'), 's1' as SessionId, { approvalId: 'a1', toolName: 'rm', reason: 'careful' } as PendingWait<'approval'>['payload'], vi.fn())} />,
|
||||
)
|
||||
expect(view.getByText('careful')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('AssistantMarkdown single-line reasoning summary skips the newline cut', () => {
|
||||
const view = render(
|
||||
<AssistantMarkdown blocks={[{ kind: 'reasoning', text: 'one-liner' }]} streaming={false} />,
|
||||
|
||||
@@ -78,7 +78,7 @@ async function bench(snapshot: ConversationSnapshot) {
|
||||
const session = createSnapshotStore<ConversationSnapshot>(snapshot)
|
||||
const list = createSnapshotStore<SessionListState>({
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, blank: false, updatedAt: 1 } },
|
||||
byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, waitingApproval: false, blank: false, updatedAt: 1 } },
|
||||
current: SID,
|
||||
phase: 'ready',
|
||||
})
|
||||
|
||||
@@ -212,8 +212,8 @@ describe('bash sample row', () => {
|
||||
return createSnapshotStore<SessionListState>({
|
||||
ids: [ROOT, CHILD],
|
||||
byId: {
|
||||
[ROOT]: { id: ROOT, title: 'r', displayTitle: 'r', running: false, blank: false, updatedAt: 0 },
|
||||
[CHILD]: { id: CHILD, title: 'c', displayTitle: 'c', parentId: ROOT, running: false, blank: false, updatedAt: 0 },
|
||||
[ROOT]: { id: ROOT, title: 'r', displayTitle: 'r', running: false, waitingApproval: false, blank: false, updatedAt: 0 },
|
||||
[CHILD]: { id: CHILD, title: 'c', displayTitle: 'c', parentId: ROOT, running: false, waitingApproval: false, blank: false, updatedAt: 0 },
|
||||
},
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
@@ -247,7 +247,7 @@ describe('bash sample row', () => {
|
||||
const orphan = 'late-child' as SessionId
|
||||
store.update((d) => {
|
||||
d.ids.push(orphan)
|
||||
d.byId[orphan] = { id: orphan, title: 'l', displayTitle: 'l', running: false, blank: false, updatedAt: 0 }
|
||||
d.byId[orphan] = { id: orphan, title: 'l', displayTitle: 'l', running: false, waitingApproval: false, blank: false, updatedAt: 0 }
|
||||
})
|
||||
const view = render(<BashRow {...rowProps(orphan, { store })} />)
|
||||
expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
// The dissolved tool ring's acceptance chain on the REAL machinery stack:
|
||||
// cordis Context + SlotsService ledger + the web-react renderer + this
|
||||
// package's own apply — no outlet twins. Proves the keyed
|
||||
// SlotTestRuntime (cordis Context + SlotsService ledger + the web-react
|
||||
// renderer) + this package's own apply — no outlet twins. Proves the keyed
|
||||
// 'conversation.chat.toolview' hole end to end: registered rows dispatch by
|
||||
// entryKey (the bash sample lands through its plugin), unregistered tools
|
||||
// fall back to GenericToolCard at the render site, live registration/unload
|
||||
@@ -10,23 +10,16 @@
|
||||
// inject: ['slots', 'conversation'] load-order seam suspends on real fiber
|
||||
// semantics until the service (and with it the hole declaration) is present.
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { createSnapshotStore, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
ConversationSnapshot, SessionId, SessionListState, ToolResultNode, WorkspaceListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react'
|
||||
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 { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
|
||||
/** Identity-stable no-session bundle (uSES getSnapshot contract). */
|
||||
const ABSENT_INFO = { sessionId: undefined, hooks: {}, props: {} }
|
||||
|
||||
afterEach(cleanup)
|
||||
// The chat store persists under its declared key; clear between cases.
|
||||
beforeEach(() => {
|
||||
@@ -40,117 +33,38 @@ const toolResult = (seq: number, callId: string, name: string, args = '{"command
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
})
|
||||
|
||||
function snapshotWith(nodes: ToolResultNode[]): 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, metrics: null,
|
||||
}
|
||||
}
|
||||
|
||||
/** 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
|
||||
|
||||
/**
|
||||
* Real-stack bench: SlotsService plugin, renderer installed, sessions/layout
|
||||
* fakes at the service seams only (external boundaries), the package apply on
|
||||
* its own fiber, and the test AppFrame occupying 'root'.
|
||||
* Real-stack bench: SlotTestRuntime with the session/layout doubles at the
|
||||
* service seams only (external boundaries), the package apply on its own
|
||||
* fiber, and the test AppFrame occupying 'root'.
|
||||
*/
|
||||
async function bench(nodes: ToolResultNode[]) {
|
||||
const ctx = new Context()
|
||||
const slotsFiber = ctx.plugin(SlotsService)
|
||||
await slotsFiber.await()
|
||||
const slots = ctx.get('slots') as SlotsService
|
||||
|
||||
const session = createSnapshotStore<ConversationSnapshot>(snapshotWith(nodes))
|
||||
const list = createSnapshotStore<SessionListState>({
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, blank: false, updatedAt: 1 } },
|
||||
current: SID,
|
||||
phase: 'ready',
|
||||
})
|
||||
// Identity-stable provide bundle: the renderer caches hooks per source and
|
||||
// inject results per bundle, both by object identity. Registered providers
|
||||
// (the package's input contribution) materialize into it lazily, once.
|
||||
const providers: ((binding: object) => { hooks?: object; props?: object })[] = []
|
||||
let info: { sessionId: SessionId; hooks: object; props: object } | undefined
|
||||
const scoped = { send: vi.fn(async () => {}), cancel: vi.fn(async () => {}) }
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
const layout = { openDetails: vi.fn(), closeDetails: vi.fn() }
|
||||
const actxFake = { get: () => scoped, effect: () => {}, on: () => () => {} }
|
||||
const bindingOf = (id: SessionId) => ({
|
||||
sessionId: id,
|
||||
ctx: actxFake,
|
||||
runtime.provide('layout', layout)
|
||||
await runtime.sessions.add({
|
||||
id: SID,
|
||||
summary: { title: 'S', displayTitle: 'S' },
|
||||
snapshot: { nodes },
|
||||
session: {
|
||||
sessionId: id,
|
||||
loadOlder: vi.fn(),
|
||||
prompt: vi.fn(async () => ({ ok: true, value: { accepted: true } })),
|
||||
// Observable face for the input machine's queue read face.
|
||||
getSnapshot: () => session.getSnapshot(),
|
||||
subscribe: (fn: () => void) => session.subscribe(fn),
|
||||
loadOlder: vi.fn<ISession['loadOlder']>(),
|
||||
prompt: vi.fn<ISession['prompt']>(async () => ({ ok: true, value: { accepted: true } })),
|
||||
},
|
||||
})
|
||||
const provideInfo = (id: string) => {
|
||||
if (id !== SID) return undefined
|
||||
if (info === undefined) {
|
||||
const hooks: Record<string, unknown> = { session }
|
||||
const props: Record<string, unknown> = {}
|
||||
for (const provider of providers) {
|
||||
const c = provider(bindingOf(SID))
|
||||
Object.assign(hooks, c.hooks ?? {})
|
||||
Object.assign(props, c.props ?? {})
|
||||
}
|
||||
info = { sessionId: SID, hooks, props }
|
||||
}
|
||||
return info
|
||||
}
|
||||
ctx.provide('sessions', {
|
||||
list,
|
||||
binding: bindingOf,
|
||||
scope: () => actxFake,
|
||||
provideInfo,
|
||||
currentProvideInfo: {
|
||||
getSnapshot: () => provideInfo(SID),
|
||||
subscribe: () => () => {},
|
||||
},
|
||||
provide: (d: { resolve: (typeof providers)[number] }) => { providers.push(d.resolve); return () => {} },
|
||||
scopeOf: () => SID,
|
||||
create: vi.fn(),
|
||||
open: vi.fn(),
|
||||
updateIntent: vi.fn(),
|
||||
})
|
||||
const workspaces = {
|
||||
list: createSnapshotStore<WorkspaceListState>({
|
||||
items: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
}),
|
||||
startSession: vi.fn(),
|
||||
sendSession: vi.fn(),
|
||||
openPath: vi.fn(async () => {}),
|
||||
}
|
||||
ctx.provide('workspaces', workspaces)
|
||||
ctx.provide('layout', layout)
|
||||
ctx.provide('locale', { bind: () => (key: string) => key })
|
||||
|
||||
slots.install(createSlotRenderer())
|
||||
slots.register({
|
||||
name: 'root',
|
||||
children: {
|
||||
'conversation': { kind: 'single', scope: 'session-maybe' },
|
||||
'details': { kind: 'single', scope: 'session' },
|
||||
},
|
||||
}, AppRoot)
|
||||
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
return { ctx, slots, fiber, session, list, layout, workspaces }
|
||||
}
|
||||
|
||||
/** Render the whole tree through the ctx-level root seam (the shell's own entry). */
|
||||
function mountApp(slots: SlotsService) {
|
||||
return render(<>{slots.renderSlot('root', {})}</>)
|
||||
await runtime.root.declare(LAYOUT_CHILDREN, AppRoot)
|
||||
const feature = await runtime.mount({ inject: [...inject], apply })
|
||||
return { runtime, slots: runtime.slots, feature, layout }
|
||||
}
|
||||
|
||||
describe('keyed toolview hole through the real machinery', () => {
|
||||
@@ -159,7 +73,7 @@ describe('keyed toolview hole through the real machinery', () => {
|
||||
toolResult(3, 'c1', 'bash'),
|
||||
toolResult(4, 'c2', 'mystery', '{"n":1}'),
|
||||
])
|
||||
const view = mountApp(b.slots)
|
||||
const view = b.runtime.renderRoot()
|
||||
// bash: the sample plugin's keyed registration took the row (root
|
||||
// session → global arm, decided inside the component off useSessions).
|
||||
expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
|
||||
@@ -167,6 +81,7 @@ describe('keyed toolview hole through the real machinery', () => {
|
||||
expect(view.getByText('Build')).toBeTruthy()
|
||||
// mystery: no registration under that key → render-site fallback.
|
||||
expect(view.getByText('Tool call')).toBeTruthy()
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('renders top-level Cordis calls with lifecycle titles over the generic variants', async () => {
|
||||
@@ -176,7 +91,7 @@ describe('keyed toolview hole through the real machinery', () => {
|
||||
toolResult(4, 'cordis-2', 'cordis_mount', JSON.stringify({ code })),
|
||||
toolResult(5, 'cordis-3', 'cordis_unmount', '{"id":"dyn-2"}'),
|
||||
])
|
||||
const view = mountApp(b.slots)
|
||||
const view = b.runtime.renderRoot()
|
||||
|
||||
expect(view.container.querySelector('[data-tool="cordis_inspect"]')?.textContent).toContain('Inspect')
|
||||
const mounted = view.container.querySelector('[data-variant="code"]')
|
||||
@@ -186,42 +101,46 @@ describe('keyed toolview hole through the real machinery', () => {
|
||||
|
||||
fireEvent.click(mounted!.querySelector('button[aria-expanded]')!)
|
||||
expect(mounted!.querySelector('pre.shiki')?.textContent).toBe(code)
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('file-path clicks travel owner openFile → chat inject → workspaces.openPath', async () => {
|
||||
const b = await bench([toolResult(3, 'c1', 'read', '{"path":"src/a.ts"}')])
|
||||
const view = mountApp(b.slots)
|
||||
const view = b.runtime.renderRoot()
|
||||
view.getByText('src/a.ts').click()
|
||||
expect(b.layout.openDetails).not.toHaveBeenCalled()
|
||||
await vi.waitFor(() => {
|
||||
expect(b.workspaces.openPath).toHaveBeenCalledWith('src/a.ts')
|
||||
expect(b.runtime.workspaces.calls).toContainEqual({ method: 'openPath', args: ['src/a.ts'] })
|
||||
})
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('bash summary clicks do not open details or host paths', async () => {
|
||||
const b = await bench([toolResult(3, 'c1', 'bash')])
|
||||
const view = mountApp(b.slots)
|
||||
const view = b.runtime.renderRoot()
|
||||
view.getByText('Build').click()
|
||||
expect(b.layout.openDetails).not.toHaveBeenCalled()
|
||||
expect(b.workspaces.openPath).not.toHaveBeenCalled()
|
||||
expect(b.runtime.workspaces.calls.some(c => c.method === 'openPath')).toBe(false)
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('a live keyed registration takes over its tool row and unload reverts to the fallback', async () => {
|
||||
const b = await bench([toolResult(3, 'c2', 'mystery', '{"n":1}')])
|
||||
const view = mountApp(b.slots)
|
||||
const view = b.runtime.renderRoot()
|
||||
expect(view.getByText('Tool call')).toBeTruthy()
|
||||
let dispose = (): void => {}
|
||||
await act(async () => {
|
||||
dispose = b.slots.register(
|
||||
{ name: 'conversation.chat.toolview', key: 'mystery' },
|
||||
() => <div data-testid="mystery-row" />)
|
||||
})
|
||||
dispose = b.slots.register(
|
||||
{ name: 'conversation.chat.toolview', key: 'mystery' },
|
||||
() => <div data-testid="mystery-row" />)
|
||||
await b.runtime.flush()
|
||||
// Per-key version tick: the row flipped without a remount of the view.
|
||||
expect(view.getByTestId('mystery-row')).toBeTruthy()
|
||||
expect(view.queryByText('Tool call')).toBeNull()
|
||||
await act(async () => { dispose() })
|
||||
dispose()
|
||||
await b.runtime.flush()
|
||||
expect(view.queryByTestId('mystery-row')).toBeNull()
|
||||
expect(view.getByText('Tool call')).toBeTruthy()
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('a duplicate key registration fails loud at load', async () => {
|
||||
@@ -230,6 +149,7 @@ describe('keyed toolview hole through the real machinery', () => {
|
||||
{ name: 'conversation.chat.toolview', key: 'bash' },
|
||||
() => null,
|
||||
)).toThrow(/key "bash"/)
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('the inject channel feeds (sessionId) => I into the row component', async () => {
|
||||
@@ -247,66 +167,33 @@ describe('keyed toolview hole through the real machinery', () => {
|
||||
}, ({ mark, poke }: ToolRowProps & { mark: string; poke: () => void }) => (
|
||||
<button data-testid="probe-row" onClick={poke}>{mark}</button>
|
||||
))
|
||||
const view = mountApp(b.slots)
|
||||
const view = b.runtime.renderRoot()
|
||||
const row = view.getByTestId('probe-row')
|
||||
expect(row.textContent).toBe(`for:${SID}`)
|
||||
row.click()
|
||||
expect(poked).toEqual([SID])
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('registrant load-order seam', () => {
|
||||
it("suspends a registrant on inject: ['slots', 'conversation'] until the service (and the hole) exists", async () => {
|
||||
const ctx = new Context()
|
||||
const slotsFiber = ctx.plugin(SlotsService)
|
||||
await slotsFiber.await()
|
||||
const slots = ctx.get('slots') as SlotsService
|
||||
ctx.provide('sessions', {
|
||||
list: createSnapshotStore<SessionListState>({
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready',
|
||||
}),
|
||||
binding: () => undefined,
|
||||
scope: () => undefined,
|
||||
provideInfo: () => undefined,
|
||||
currentProvideInfo: {
|
||||
getSnapshot: () => ABSENT_INFO,
|
||||
subscribe: () => () => {},
|
||||
},
|
||||
provide: () => () => {},
|
||||
create: vi.fn(),
|
||||
open: vi.fn(),
|
||||
updateIntent: vi.fn(),
|
||||
})
|
||||
ctx.provide('workspaces', {
|
||||
list: createSnapshotStore<WorkspaceListState>({
|
||||
items: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
}),
|
||||
startSession: vi.fn(),
|
||||
sendSession: vi.fn(),
|
||||
openPath: vi.fn(async () => {}),
|
||||
})
|
||||
ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
ctx.provide('locale', { bind: () => (key: string) => key })
|
||||
slots.register({
|
||||
name: 'root',
|
||||
children: {
|
||||
'conversation': { kind: 'single', scope: 'session-maybe' },
|
||||
'details': { kind: 'single', scope: 'session' },
|
||||
},
|
||||
}, AppRoot)
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
await runtime.root.declare(LAYOUT_CHILDREN, AppRoot)
|
||||
|
||||
// Third-party posture, mounted BEFORE ui-conversation: real fiber inject
|
||||
// semantics hold it — apply must not run while 'conversation' is absent.
|
||||
// (Plain arrow, not vi.fn: mock functions carry a prototype and trip the
|
||||
// fiber's isConstructor branch.)
|
||||
// Uses ctx.plugin directly (the deliberate-suspension escape hatch; mount()
|
||||
// would fail loud on the missing service). (Plain arrow, not vi.fn: mock
|
||||
// functions carry a prototype and trip the fiber's isConstructor branch.)
|
||||
let applyRuns = 0
|
||||
const registrantApply = (registrantCtx: Context): void => {
|
||||
const registrantApply = (registrantCtx: typeof runtime.ctx): void => {
|
||||
applyRuns += 1
|
||||
registrantCtx.slots.register(
|
||||
{ name: 'conversation.chat.toolview', key: 'late' }, () => null)
|
||||
}
|
||||
const late = ctx.plugin({
|
||||
const late = runtime.ctx.plugin({
|
||||
name: 'late-registrant',
|
||||
inject: ['slots', 'conversation'],
|
||||
apply: registrantApply,
|
||||
@@ -317,11 +204,11 @@ describe('registrant load-order seam', () => {
|
||||
// Mounting the package resolves the seam: service present ⟹ the chat
|
||||
// entry (and its hole declaration) is already on the ledger, so the
|
||||
// suspended registrant lands without an undeclared-slot throw.
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
await runtime.mount({ inject: [...inject], apply })
|
||||
await late.await()
|
||||
expect(applyRuns).toBe(1)
|
||||
expect(slots.entries('conversation.chat.toolview').map(e => e.options.key))
|
||||
expect(runtime.slots.entries('conversation.chat.toolview').map(e => e.options.key))
|
||||
.toEqual(expect.arrayContaining(['bash', 'late']))
|
||||
await runtime.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -392,13 +392,18 @@ describe('ChatView', () => {
|
||||
expect(lv.getByText('载入历史…')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('pending interactions render placeholder cards', () => {
|
||||
it('pending waits leave the flow entirely — questions and approvals both take over the composer', () => {
|
||||
const h = makeHarness({
|
||||
pending: [new PendingWait('approval', RpcId('r1'), SID,
|
||||
{ approvalId: 'ap1', toolName: 'bash' } as PendingWait<'approval'>['payload'], vi.fn())],
|
||||
pending: [
|
||||
new PendingWait('approval', RpcId('r1'), SID,
|
||||
{ approvalId: 'ap1', toolName: 'bash' } as PendingWait<'approval'>['payload'], vi.fn()),
|
||||
new PendingWait('question', RpcId('r2'), SID,
|
||||
{ questions: [{ id: 'q1', question: '选择' }] }, vi.fn()),
|
||||
],
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
expect(view.getByText(/等待审批/)).toBeTruthy()
|
||||
expect(view.queryByText(/等待回答/)).toBeNull()
|
||||
expect(view.queryByText(/等待审批/)).toBeNull()
|
||||
})
|
||||
|
||||
it('renders command nodes as durable rows: settled text, error state, executing spinner, run-less soft-fall', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
// Branch tails the acceptance specs do not reach: ToolRow stopped-state dot,
|
||||
// PendingCard question arm, bash sample state dots, the node-half empty
|
||||
// bash sample state dots, the node-half empty
|
||||
// apply, and AssistantMarkdown reasoning/unknown block arms.
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -8,13 +8,10 @@ import { cleanup, render } from '@testing-library/react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { RunningToolCall, SessionId, SessionListState, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ToolRowOwnerProps, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { apply as nodeApply } from '../src/index.ts'
|
||||
import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
|
||||
import { ToolRow } from '../src/client/chat/ToolRow.tsx'
|
||||
import { PendingCard } from '../src/client/chat/PendingCard.tsx'
|
||||
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
|
||||
import { BashRow } from '../src/client/toolviews/bash-sample.tsx'
|
||||
|
||||
@@ -33,13 +30,6 @@ describe('tails', () => {
|
||||
expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('PendingCard renders the question arm with its count', () => {
|
||||
const view = render(
|
||||
<PendingCard item={new PendingWait('question', RpcId('r1'), 's1' as SessionId, { questions: [{}, {}] } as PendingWait<'question'>['payload'], vi.fn())} />,
|
||||
)
|
||||
expect(view.getByText(/等待回答(2 题)/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('AssistantMarkdown renders reasoning as a Think row and unknown blocks as JSON fallback', () => {
|
||||
const view = render(
|
||||
<AssistantMarkdown
|
||||
@@ -94,7 +84,7 @@ describe('tails', () => {
|
||||
const sid = 'root-1' as SessionId
|
||||
const list = createSnapshotStore<SessionListState>({
|
||||
ids: [sid],
|
||||
byId: { [sid]: { id: sid, title: 'r', displayTitle: 'r', running: false, blank: false, updatedAt: 0 } },
|
||||
byId: { [sid]: { id: sid, title: 'r', displayTitle: 'r', running: false, waitingApproval: false, blank: false, updatedAt: 0 } },
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
})
|
||||
|
||||
@@ -30,9 +30,12 @@ function snapshotOf(overrides: Partial<ConversationSnapshot> = {}): Conversation
|
||||
|
||||
interface BenchOptions {
|
||||
planEntry?: React.ReactNode
|
||||
/** The `plan` projection value the standard-kit useProjection serves. */
|
||||
plan?: { active: boolean; pending: boolean }
|
||||
modelEntry?: React.ReactNode
|
||||
/** Hot text-ref lexicon (injects a minimal slash stub exposing only lexicon()). */
|
||||
lexicon?: ReadonlyMap<'/' | '@', readonly string[]>
|
||||
permissions?: { options: { value: string; name: string; description?: string }[]; currentValue: string }
|
||||
draft?: string
|
||||
running?: boolean
|
||||
disabled?: boolean
|
||||
@@ -88,13 +91,15 @@ function bench(over?: BenchOptions) {
|
||||
items: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
})),
|
||||
useProjection: (() => undefined),
|
||||
useProjection: ((key: string, selector?: (v: unknown) => unknown) =>
|
||||
(selector ?? (v => v))(key === 'permissions' ? over?.permissions : key === 'plan' ? over?.plan : undefined)),
|
||||
useInput: bindSnapshotSelector(shell.state),
|
||||
inputActions: shell.actions,
|
||||
keyboard: shell,
|
||||
useNotices: bindSnapshotSelector(shell.notices),
|
||||
useLexicon: bindSnapshotSelector(shell.lexicon),
|
||||
stop,
|
||||
command: () => Promise.resolve(true),
|
||||
renderSlot,
|
||||
variant: over?.variant ?? 'composer',
|
||||
...(over?.placeholder !== undefined ? { placeholder: over.placeholder } : {}),
|
||||
@@ -230,6 +235,20 @@ describe('running and lock semantics (queue cut 1)', () => {
|
||||
const custom = bench({ placeholder: 'Custom placeholder' })
|
||||
expect(custom.textarea.placeholder).toBe('Custom placeholder')
|
||||
})
|
||||
|
||||
it('the plan projection swaps the placeholder while its effective target is plan mode', () => {
|
||||
const active = bench({ plan: { active: true, pending: false } })
|
||||
expect(active.textarea.placeholder).toBe('describe your task to generate plan')
|
||||
// /plan just ran: pending entry already reads as the plan target.
|
||||
const entering = bench({ plan: { active: false, pending: true } })
|
||||
expect(entering.textarea.placeholder).toBe('describe your task to generate plan')
|
||||
// Pending exit: target is default again.
|
||||
const leaving = bench({ plan: { active: true, pending: true } })
|
||||
expect(leaving.textarea.placeholder).toBe('Message the agent')
|
||||
// Owner placeholder outranks the plan swap.
|
||||
const custom = bench({ plan: { active: true, pending: false }, placeholder: 'Custom placeholder' })
|
||||
expect(custom.textarea.placeholder).toBe('Custom placeholder')
|
||||
})
|
||||
})
|
||||
|
||||
describe('machine pending lock', () => {
|
||||
@@ -250,7 +269,6 @@ describe('machine pending lock', () => {
|
||||
expect(shell.snapshot.phase).toBe('submitting')
|
||||
const textarea = view.container.querySelector('textarea')!
|
||||
expect(textarea.readOnly).toBe(true)
|
||||
expect(view.container.querySelector('[data-input-pending]')).not.toBeNull()
|
||||
expect(view.container.querySelector<HTMLButtonElement>('button[aria-label="Send message"]')!.disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -352,16 +370,37 @@ describe('strips and variants', () => {
|
||||
})
|
||||
|
||||
describe('placeholder chrome and control seats', () => {
|
||||
it('renders attach + Access placeholder; plan/model seats render EMPTY without entries (B ruling)', () => {
|
||||
it('renders attach; the Access chip is absent without the permissions projection; plan/model seats render EMPTY without entries (B ruling)', () => {
|
||||
const { view, slotCalls } = bench()
|
||||
expect(view.getByLabelText('Add attachment')).toBeTruthy()
|
||||
expect((view.getByLabelText('Access mode') as HTMLSelectElement).value).toBe('readonly')
|
||||
// Capability absent (no projection value): the chip renders nothing.
|
||||
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()
|
||||
expect(view.queryByLabelText('Model')).toBeNull()
|
||||
})
|
||||
|
||||
it('the Access chip renders the projection value and submits /permission on pick', async () => {
|
||||
const permissions = {
|
||||
options: [
|
||||
{ value: 'workspace-write', name: 'workspace-write' },
|
||||
{ value: 'danger-full-access', name: 'danger-full-access' },
|
||||
],
|
||||
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' } })
|
||||
// Optimistic pick + disable until admission resolves (command stub resolves true).
|
||||
expect(select.disabled).toBe(true)
|
||||
await act(async () => {})
|
||||
expect(select.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('a registered entry fills its seat and receives the locked owner prop', () => {
|
||||
const { view, slotCalls } = bench({
|
||||
disabled: true,
|
||||
@@ -377,12 +416,13 @@ describe('placeholder chrome and control seats', () => {
|
||||
expect(live.slotCalls.every(c => !(c.owner as { locked: boolean }).locked)).toBe(true)
|
||||
})
|
||||
|
||||
it('disabled locks the Access placeholder and attach control (running does not)', () => {
|
||||
const { view } = bench({ disabled: true })
|
||||
it('disabled locks the Access chip and attach control (running does not)', () => {
|
||||
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)
|
||||
cleanup()
|
||||
const live = bench({ running: true })
|
||||
const live = bench({ running: true, permissions })
|
||||
expect((live.view.getByLabelText('Access mode') as HTMLSelectElement).disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -47,6 +47,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
|
||||
useLexicon: bindSnapshotSelector(shell.lexicon),
|
||||
renderSlot: (() => null) as InputBarProps['renderSlot'],
|
||||
stop: vi.fn(),
|
||||
command: () => Promise.resolve(true),
|
||||
variant: 'composer',
|
||||
}
|
||||
return render(<InputBar {...props} />)
|
||||
@@ -124,13 +125,12 @@ describe('matrix row: claimed', () => {
|
||||
describe('matrix row: submitting', () => {
|
||||
it('locks enter, renders pending + read-only, keeps the claim snapshot on the currency', async () => {
|
||||
const submit = vi.fn(() => new Promise<SubmitOutcome>(() => {})) // never settles
|
||||
const { view, textarea, shell, sink, claim } = bench({ submit })
|
||||
const { textarea, shell, sink, claim } = bench({ submit })
|
||||
claim()
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||
expect(shell.snapshot.phase).toBe('submitting')
|
||||
expect(shell.snapshot.claim).toBeDefined()
|
||||
expect((textarea).readOnly).toBe(true)
|
||||
expect(view.container.querySelector('[data-input-pending]')).not.toBeNull()
|
||||
// Enter is dead inside the lock (submit dispatch is microtask-deferred).
|
||||
await vi.waitFor(() => { expect(submit).toHaveBeenCalledTimes(1) })
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||
|
||||
@@ -133,6 +133,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
|
||||
useLexicon: bindSnapshotSelector(shell.lexicon),
|
||||
renderSlot: (() => null) as InputBarProps['renderSlot'],
|
||||
stop: vi.fn(),
|
||||
command: () => Promise.resolve(true),
|
||||
variant: 'composer',
|
||||
}
|
||||
const view = render(<InputBar {...barProps} />)
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Exercises selection persistence through the real SlotsService store axis;
|
||||
* component stubs cannot prove per-session identity or disposal.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createSnapshotStore, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId, SessionListState, WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createChatStore } from '../src/client/stores.ts'
|
||||
|
||||
const sid = (s: string): SessionId => s as SessionId
|
||||
|
||||
/** Identity-stable no-session bundle (uSES getSnapshot contract). */
|
||||
const ABSENT_INFO = { sessionId: undefined, hooks: {}, props: {} }
|
||||
|
||||
interface Bench {
|
||||
slots: SlotsService
|
||||
chat: ReturnType<typeof createChatStore>
|
||||
}
|
||||
|
||||
function bench(): Bench {
|
||||
const ctx = new Context()
|
||||
ctx.provide('sessions', {
|
||||
list: createSnapshotStore<SessionListState>({
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready',
|
||||
}),
|
||||
provideInfo: () => undefined,
|
||||
currentProvideInfo: {
|
||||
getSnapshot: () => ABSENT_INFO,
|
||||
subscribe: () => () => {},
|
||||
},
|
||||
provide: () => () => {},
|
||||
})
|
||||
ctx.provide('workspaces', {
|
||||
list: createSnapshotStore<WorkspaceListState>({
|
||||
items: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
}),
|
||||
})
|
||||
// Service self-registers as ctx 'slots' (cordis Service constructor).
|
||||
const slots = new SlotsService(ctx)
|
||||
const chat = createChatStore()
|
||||
// The apply.ts shape: one shared handle across both session-slot
|
||||
// registrations. 'conversation'/'details' must first exist in the ledger —
|
||||
// register a root occupant declaring them (the AppFrame role; the stand-in
|
||||
// consumes renderSlot to satisfy the declare-means-render check).
|
||||
slots.register({
|
||||
name: 'root',
|
||||
children: {
|
||||
'conversation': { kind: 'single', scope: 'session-maybe' },
|
||||
'conversation.session': { kind: 'single', scope: 'session' },
|
||||
'details': { kind: 'single', scope: 'session' },
|
||||
},
|
||||
}, (_p: { renderSlot?: unknown }) => null)
|
||||
// apply.ts mounts the shared chat handle only under session-scope slots
|
||||
// (the session-maybe 'conversation' shell carries no store).
|
||||
slots.register({ name: 'conversation.session', store: chat }, () => null)
|
||||
slots.register({ name: 'details', store: chat }, () => null)
|
||||
return { slots, chat }
|
||||
}
|
||||
|
||||
/** Resolve the store instance the renderer would hand a slot's component for a session. */
|
||||
function storeFor(b: Bench, slot: 'conversation.session' | 'details', sessionId: SessionId) {
|
||||
const host = renderHost(b)
|
||||
const entry = host.entriesOf(slot)[0]!
|
||||
return host.storeOf(entry, sessionId)! as ReturnType<ReturnType<typeof createChatStore>['create']>
|
||||
}
|
||||
|
||||
/** The host face is only built at renderSlot time; install a stub renderer once to reach it. */
|
||||
function renderHost(b: Bench): import('@deepseek-ai/dsh-client-ui-slots').SlotRendererHost {
|
||||
const captured = (b as unknown as { _host?: import('@deepseek-ai/dsh-client-ui-slots').SlotRendererHost })
|
||||
if (captured._host === undefined) {
|
||||
b.slots.install({
|
||||
renderRoot: (host) => {
|
||||
captured._host = host
|
||||
return null
|
||||
},
|
||||
})
|
||||
b.slots.renderSlot('root', {})
|
||||
}
|
||||
return captured._host!
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
describe('selection survives on the store seat', () => {
|
||||
it('one session, two slots: conversation writes, details reads the SAME instance', () => {
|
||||
const b = bench()
|
||||
|
||||
const conv = storeFor(b, 'conversation.session', sid('s1'))
|
||||
const details = storeFor(b, 'details', sid('s1'))
|
||||
conv.actions.select({ turnSeq: 3, callId: 'c1' })
|
||||
expect(details.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1' })
|
||||
// Identity, not just value: the shared handle resolves one instance per scope key.
|
||||
expect(details).toBe(conv)
|
||||
})
|
||||
|
||||
it('sessions are isolated: s2 selection never bleeds into s1', () => {
|
||||
const b = bench()
|
||||
|
||||
const one = storeFor(b, 'conversation.session', sid('s1'))
|
||||
const two = storeFor(b, 'conversation.session', sid('s2'))
|
||||
expect(two).not.toBe(one)
|
||||
one.actions.select({ turnSeq: 1, callId: 'a' })
|
||||
two.actions.select({ turnSeq: 9, callId: 'z' })
|
||||
expect(one.store.getSnapshot().selection).toEqual({ turnSeq: 1, callId: 'a' })
|
||||
expect(two.store.getSnapshot().selection).toEqual({ turnSeq: 9, callId: 'z' })
|
||||
})
|
||||
|
||||
it('a list-projection update keeps instance identity and the selection value', () => {
|
||||
const b = bench()
|
||||
const id = sid('s1')
|
||||
const projection = createSnapshotStore({ displayTitle: 's1' })
|
||||
|
||||
const store = storeFor(b, 'conversation.session', id)
|
||||
store.actions.select({ turnSeq: 3, callId: 'c1' })
|
||||
store.actions.setDraft('half-typed')
|
||||
|
||||
projection.set({ displayTitle: 'proj-a' })
|
||||
expect(projection.getSnapshot().displayTitle).toBe('proj-a')
|
||||
|
||||
const after = storeFor(b, 'conversation.session', id)
|
||||
expect(after).toBe(store)
|
||||
expect(after.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1' })
|
||||
expect(after.store.getSnapshot().draft).toBe('half-typed')
|
||||
})
|
||||
|
||||
it('session death buries the instance and its persisted draft', () => {
|
||||
const b = bench()
|
||||
|
||||
const doomed = storeFor(b, 'conversation.session', sid('s1'))
|
||||
doomed.actions.setDraft('to be buried')
|
||||
doomed.actions.select({ turnSeq: 1 })
|
||||
expect(localStorage.getItem('dsh.conversation.chat.s1')).not.toBeNull()
|
||||
|
||||
// SessionsService calls this public slot lifecycle seam when the scope dies.
|
||||
b.slots.pruneStoreScope(sid('s1'))
|
||||
|
||||
// Persisted residue is gone with the session...
|
||||
expect(localStorage.getItem('dsh.conversation.chat.s1')).toBeNull()
|
||||
// ...and a re-created same-id session starts from a FRESH instance.
|
||||
const reborn = storeFor(b, 'conversation.session', sid('s1'))
|
||||
expect(reborn).not.toBe(doomed)
|
||||
expect(reborn.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Exercises selection persistence through the real SlotsService store axis;
|
||||
* component stubs cannot prove per-session identity or disposal.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { createChatStore } from '../src/client/stores.ts'
|
||||
|
||||
const sid = (s: string): SessionId => s as SessionId
|
||||
|
||||
type ChatInstance = ReturnType<ReturnType<typeof createChatStore>['create']>
|
||||
|
||||
async function bench() {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
const chat = createChatStore()
|
||||
// The apply.ts shape: one shared handle across both strict-session slot
|
||||
// registrations ('conversation.session'/'details'); the session-maybe
|
||||
// 'conversation' shell carries no store by design. The slots must first
|
||||
// exist in the ledger — the test root declares them (the AppFrame role).
|
||||
await runtime.root.declare({
|
||||
'conversation': { kind: 'single', scope: 'session-maybe' },
|
||||
'conversation.session': { kind: 'single', scope: 'session' },
|
||||
'details': { kind: 'single', scope: 'session' },
|
||||
}, (_p: { renderSlot?: unknown }) => null)
|
||||
runtime.slots.register({ name: 'conversation.session', store: chat }, () => null)
|
||||
runtime.slots.register({ name: 'details', store: chat }, () => null)
|
||||
runtime.renderRoot() // materializes the host face storeOf resolves through
|
||||
return { runtime, chat }
|
||||
}
|
||||
|
||||
/** Resolve the store instance the renderer would hand a slot's component for a session. */
|
||||
function storeFor(b: Awaited<ReturnType<typeof bench>>, slot: 'conversation.session' | 'details', sessionId: SessionId) {
|
||||
return b.runtime.storeOf(slot, sessionId) as ChatInstance
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
describe('selection survives on the store seat', () => {
|
||||
it('one session, two slots: conversation writes, details reads the SAME instance', async () => {
|
||||
const b = await bench()
|
||||
|
||||
const conv = storeFor(b, 'conversation.session', sid('s1'))
|
||||
const details = storeFor(b, 'details', sid('s1'))
|
||||
conv.actions.select({ turnSeq: 3, callId: 'c1' })
|
||||
expect(details.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1' })
|
||||
// Identity, not just value: the shared handle resolves one instance per scope key.
|
||||
expect(details).toBe(conv)
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('sessions are isolated: s2 selection never bleeds into s1', async () => {
|
||||
const b = await bench()
|
||||
|
||||
const one = storeFor(b, 'conversation.session', sid('s1'))
|
||||
const two = storeFor(b, 'conversation.session', sid('s2'))
|
||||
expect(two).not.toBe(one)
|
||||
one.actions.select({ turnSeq: 1, callId: 'a' })
|
||||
two.actions.select({ turnSeq: 9, callId: 'z' })
|
||||
expect(one.store.getSnapshot().selection).toEqual({ turnSeq: 1, callId: 'a' })
|
||||
expect(two.store.getSnapshot().selection).toEqual({ turnSeq: 9, callId: 'z' })
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('a list-projection update keeps instance identity and the selection value', async () => {
|
||||
const b = await bench()
|
||||
const id = sid('s1')
|
||||
|
||||
const store = storeFor(b, 'conversation.session', id)
|
||||
store.actions.select({ turnSeq: 3, callId: 'c1' })
|
||||
store.actions.setDraft('half-typed')
|
||||
|
||||
// A projection churn elsewhere (list rows re-projected) must not touch
|
||||
// store identity: drive the runtime's own list observable.
|
||||
await b.runtime.sessions.add({ id, summary: { displayTitle: 'proj-a' } })
|
||||
expect(b.runtime.sessions.list.getSnapshot().byId[id]?.displayTitle).toBe('proj-a')
|
||||
|
||||
const after = storeFor(b, 'conversation.session', id)
|
||||
expect(after).toBe(store)
|
||||
expect(after.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1' })
|
||||
expect(after.store.getSnapshot().draft).toBe('half-typed')
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('session death buries the instance and its persisted draft', async () => {
|
||||
const b = await bench()
|
||||
await b.runtime.sessions.add({ id: 's1' })
|
||||
|
||||
const doomed = storeFor(b, 'conversation.session', sid('s1'))
|
||||
doomed.actions.setDraft('to be buried')
|
||||
doomed.actions.select({ turnSeq: 1 })
|
||||
expect(localStorage.getItem('dsh.conversation.chat.s1')).not.toBeNull()
|
||||
|
||||
// TestSessions.remove drives the same public slot lifecycle seam the
|
||||
// production SessionsService calls when the scope dies (pruneStoreScope).
|
||||
await b.runtime.sessions.remove('s1')
|
||||
|
||||
// Persisted residue is gone with the session...
|
||||
expect(localStorage.getItem('dsh.conversation.chat.s1')).toBeNull()
|
||||
// ...and a re-created same-id session starts from a FRESH instance.
|
||||
const reborn = storeFor(b, 'conversation.session', sid('s1'))
|
||||
expect(reborn).not.toBe(doomed)
|
||||
expect(reborn.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null })
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
})
|
||||
@@ -1,39 +1,32 @@
|
||||
// @vitest-environment jsdom
|
||||
// ConversationService scope addressing over the runtime's real scope tag:
|
||||
// TestSessions mints tagged scopes through the production createScope, so the
|
||||
// service's scopeOf/binding path runs against production resolution (no local
|
||||
// tag probe).
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { InputHub } from '../src/client/input/hub.ts'
|
||||
|
||||
const sid = (id: string) => id as SessionId
|
||||
const SCOPE_TAG: symbol = (() => {
|
||||
const reads: (string | symbol)[] = []
|
||||
const proxy = new Proxy(new Context(), {
|
||||
get(target, property, receiver): unknown {
|
||||
reads.push(property)
|
||||
return Reflect.get(target, property, receiver)
|
||||
},
|
||||
})
|
||||
void scopeOf(proxy)
|
||||
return reads.find((value): value is symbol => typeof value === 'symbol')!
|
||||
})()
|
||||
|
||||
async function bench(withSessions = true) {
|
||||
const ctx = new Context()
|
||||
async function bench() {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
const prompt = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } }))
|
||||
const cancel = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } }))
|
||||
const loadOlder = vi.fn(() => Promise.resolve())
|
||||
const sessions = {
|
||||
binding: (sessionId: SessionId) => ({
|
||||
sessionId, session: { prompt, cancel, loadOlder },
|
||||
}),
|
||||
scopeOf,
|
||||
} as unknown as SessionsService
|
||||
if (withSessions) ctx.provide('sessions', sessions)
|
||||
await ctx.plugin(ConversationService).await()
|
||||
const root = ctx.get('conversation') as ConversationService
|
||||
const scoped = ctx.plugin(() => {}).ctx.extend({ [SCOPE_TAG]: sid('s1') }).get('conversation') as ConversationService
|
||||
return { root, scoped, prompt, cancel, loadOlder }
|
||||
await runtime.sessions.add({
|
||||
id: 's1',
|
||||
session: { prompt, cancel, loadOlder },
|
||||
})
|
||||
// config.input is required (the apply shares its hub with the inject
|
||||
// factories); the bench passes its own instance explicitly.
|
||||
const fiber = runtime.ctx.plugin(ConversationService, {
|
||||
input: new InputHub(runtime.ctx),
|
||||
})
|
||||
await fiber.await()
|
||||
const root = runtime.ctx.get('conversation') as ConversationService
|
||||
const scoped = runtime.sessions.scope('s1')!.get('conversation') as ConversationService
|
||||
return { runtime, root, scoped, prompt, cancel, loadOlder }
|
||||
}
|
||||
|
||||
describe('ConversationService', () => {
|
||||
@@ -45,6 +38,7 @@ describe('ConversationService', () => {
|
||||
expect(b.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'steer')
|
||||
expect(b.cancel).toHaveBeenCalledOnce()
|
||||
expect(b.loadOlder).toHaveBeenCalledOnce()
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('folds Session business failures into callback rejections', async () => {
|
||||
@@ -53,12 +47,21 @@ describe('ConversationService', () => {
|
||||
await expect(b.scoped.send('x', 'queue')).rejects.toThrow('conversation.send failed: agent-busy: busy')
|
||||
b.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'nope', details: {} } } as never)
|
||||
await expect(b.scoped.cancel()).rejects.toThrow('conversation.cancel failed: internal: nope')
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('fails loudly from the root scope or without SessionsService', async () => {
|
||||
it('fails loudly from the root scope, on an unbound session, or without SessionsService', async () => {
|
||||
const b = await bench()
|
||||
await expect(b.root.send('x', 'queue')).rejects.toThrow(/requires a session scope/)
|
||||
const missing = await bench(false)
|
||||
await expect(missing.root.send('x', 'queue')).rejects.toThrow(/sessions service unavailable/)
|
||||
await b.runtime.sessions.remove('s1')
|
||||
await expect(b.scoped.send('x', 'queue')).rejects.toThrow(/resolved no binding/)
|
||||
await b.runtime.dispose()
|
||||
// No SessionsService at all: a bare context (the runtime always provides one).
|
||||
const bare = new Context()
|
||||
await bare.plugin(ConversationService, {
|
||||
input: new InputHub(bare),
|
||||
}).await()
|
||||
const orphan = bare.get('conversation') as ConversationService
|
||||
await expect(orphan.send('x', 'queue')).rejects.toThrow(/sessions service unavailable/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -64,8 +64,8 @@ function mount(
|
||||
const sessions = createSnapshotStore<SessionListState>({
|
||||
ids: [root, SID],
|
||||
byId: {
|
||||
[root]: { id: root, displayTitle: 'Root', running: false, blank: false, updatedAt: 1 },
|
||||
[SID]: { id: SID, displayTitle: 'Child', parentId: root, cwd: '/projects/one', running: false, blank: false, updatedAt: 2 },
|
||||
[root]: { id: root, displayTitle: 'Root', running: false, waitingApproval: false, blank: false, updatedAt: 1 },
|
||||
[SID]: { id: SID, displayTitle: 'Child', parentId: root, cwd: '/projects/one', running: false, waitingApproval: false, blank: false, updatedAt: 2 },
|
||||
},
|
||||
current: SID,
|
||||
phase: 'ready',
|
||||
@@ -123,6 +123,7 @@ function mount(
|
||||
useNotices={bindSnapshotSelector(wiring.notices)}
|
||||
useLexicon={bindSnapshotSelector(wiring.lexicon)}
|
||||
stop={stop}
|
||||
command={() => Promise.resolve(true)}
|
||||
renderSlot={(() => null) as InputBarProps['renderSlot']}
|
||||
{...bar}
|
||||
/>
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
{
|
||||
"path": "../../session-projection/session-projection"
|
||||
},
|
||||
{
|
||||
"path": "../../plan/plan-mode"
|
||||
},
|
||||
{
|
||||
"path": "../../todo/tool-todo"
|
||||
},
|
||||
@@ -40,6 +43,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/permission"
|
||||
}
|
||||
],
|
||||
"exclude": [
|
||||
|
||||
Reference in New Issue
Block a user