Merge remote-tracking branch 'github/master' into xtr/trajectory-inspection-ui
# Conflicts: # packages/client/ui-primitives/src/Menu.tsx # packages/client/ui-trajectory/src/client/TrajectoryCell.tsx # packages/client/ui-trajectory/src/client/TrajectoryView.tsx # packages/client/ui-trajectory/tests/layout.spec.tsx # pnpm-lock.yaml # pnpm-workspace.yaml
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
|
||||
README.md: 453922dafd1eb7a617cb2d1c93ac1daa2e7273c6
|
||||
README.zh.md: 88992176165ab11050a30c7df381479796908ba2
|
||||
README.md: 56a445ccfa86e0b11cf5aefc37819a30746f0739
|
||||
README.zh.md: a7c160ecdd74074257c9d149630663dacd05c070
|
||||
|
||||
@@ -4,17 +4,19 @@ English | [中文](README.zh.md)
|
||||
|
||||
Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, stats line, per-tool row slot with a bash sample registrant and the todo row), input dock (queue rows plus the todo plan strip), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares).
|
||||
|
||||
The no-session hero renders the frontend Session Intent from the Session list projection, including its frontend Workspace Intent when no real Workspace exists. It declares `conversation.empty.workspace`, where ui-workspace registers the same picker used by the sidebar. WorkspacesService starts the cross-object flow; each Workspace or Session object owns its own materialization. The Session keeps its identity across publication and retains any prompt that still needs connection or delivery; ConversationRoot reads that `pendingPrompt` from `useSession` and edits or retries it through the scoped ConversationService.
|
||||
The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store.
|
||||
|
||||
The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: <active id>`), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves.
|
||||
|
||||
Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and `Write · <path>` or `Edit · <path>` summary while retaining the shared row-to-details interaction. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged), and the details panel resolves a selected sub-call id to its full logged args and complete output.
|
||||
Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and `Write · <path>` or `Edit · <path>` summary while retaining the shared row-to-details interaction. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged), and the details panel resolves a selected sub-call id to its full logged args and complete output. Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering.
|
||||
|
||||
Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders).
|
||||
|
||||
The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`<done>/<total> 已完成 · <active item>` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1` — above the queue rows — and is the durable plan strip: it selects `todos` off the session snapshot and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and collapses to a header of title plus `"<done>/<total> tasks · <n> in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the persistent list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.
|
||||
|
||||
Per-session UI state (selection, ordinary composer draft, active view) lives in the declared chat store (`stores.ts` `createChatStore`): apply constructs one handle and passes it to the conversation, chat-view, and details registrations, so the session slots share one instance per session (selection written by the chat view, read by details) and the framework owns instance lifecycle and draft persistence. The frontend Session Intent comes from the Session list projection; after publication, any retained prompt comes from that Session's conversation snapshot. Components are pure — the framework standard kit (`useSession`/`sessionId` when session-scoped, plus global `useSessions`/`useWorkspaces`) and the store faces (`useStore`/`actions`) arrive automatically from the registration declaration; inject factories contribute plain data and callbacks for runtime Session actions, send/stop, tabs, details, and paging.
|
||||
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.
|
||||
|
||||
`src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath).
|
||||
|
||||
|
||||
@@ -4,17 +4,19 @@
|
||||
|
||||
会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、统计行、逐工具行 slot 及一个 bash 示例注册方与 todo 行)、输入区 dock(队列行加 todo 计划条)、最小详情面板、按 scope 寻址的 ConversationService。契约:api-contracts v3 §7 加 slot 终端设计(store seat/props share)。
|
||||
|
||||
无会话主视觉区会渲染来自 Session 列表投影的前端 Session Intent;没有真实 Workspace 时,还会包含其前端 Workspace Intent。它声明 `conversation.empty.workspace`,ui-workspace 会在此注册侧边栏所用的同一选择器。WorkspacesService 启动跨对象流程;每个 Workspace 或 Session 对象拥有自身的物化。Session 在发布期间保持身份,并保留任何仍需连接或交付的提示词;ConversationRoot 读取该 `pendingPrompt`,其来源是 `useSession`,再通过 scope 内的 ConversationService 编辑或重试。
|
||||
常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。
|
||||
|
||||
视图环本身就是 slot:会话注册声明 `'conversation.view'` 列表 slot(Session scope),并将其列在 `children` 表中;ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: <active id>`);视图标签页从环账本的注册选项(`id`/`order`/`label`)投影而来。聊天视图是该包自身的环配置项;其他插件(ui-trajectory)通过普通的 `ctx.slots.register` 贡献标签页。先前包内的视图注册表(`registerView`/`ViewEntry`/`ConversationViewMap` 及 chrome 附加表)已退役,逐视图 chrome 则被拆入视图组件自身。
|
||||
|
||||
通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和 `Write · <path>` 或 `Edit · <path>` 摘要,同时保留共享的行到详情交互。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行),details 面板则会根据选中的子调用 id 解析出其完整记录的参数与完整输出。
|
||||
通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和 `Write · <path>` 或 `Edit · <path>` 摘要,同时保留共享的行到详情交互。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行),details 面板则会根据选中的子调用 id 解析出其完整记录的参数与完整输出。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。
|
||||
|
||||
工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。
|
||||
|
||||
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: -1` 占用 `'conversation.input.dock'` 列表 slot(位于队列行之上),是常驻的计划条:它从会话快照中选取 `todos` 并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏,折叠时收成标题加 `"<已完成>/<总数> tasks · <n> in progress"` 的表头(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;常驻列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。
|
||||
|
||||
逐 Session UI 状态(选择、普通编辑器草稿、活跃视图)位于已声明的聊天 store(`stores.ts` `createChatStore`)中:apply 构造一个 handle,并将其传给会话、聊天视图和详情注册,因此 Session slot 每个 Session 共享一个实例(选择由聊天视图写入、详情读取),框架拥有实例生命周期与草稿持久化。前端 Session Intent 来自 Session 列表投影;发布后,任何保留的提示词都来自该 Session 的会话快照。组件保持纯粹:框架标准工具包(Session scope 下的 `useSession`/`sessionId`,以及全局 `useSessions`/`useWorkspaces`)和 store 表层(`useStore`/`actions`)会从注册声明自动到达;inject factory 为运行时 Session 操作、发送/停止、标签页、详情和分页贡献普通数据与回调。
|
||||
逐 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。
|
||||
|
||||
`src/client/` 按未来的包拆分组织:`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明 + 组合后的 slot props,包括工具行契约、`views.ts` 共享原语、`tool-call-model.ts`);`skeleton/`、`chat/` 和 `toolviews/`(示例注册方)领域目录只导入 contract 文件,彼此绝不导入;`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply`/`inject`、两个服务类和 `contract/` 类型家族;实现组件(骨架、聊天行)与 store factory 保持内部状态,只能通过 apply 的 slot 注册到达页面(测试通过 `./src/*` 子路径获取它们)。
|
||||
|
||||
|
||||
@@ -90,20 +90,17 @@ export function apply(ctx: Context): void {
|
||||
'conversation.hero.workspace': { kind: 'single', scope: 'root' },
|
||||
},
|
||||
inject: (sessionId: SessionId | undefined): ConversationInjected => ({
|
||||
selectWorkspace: (workspaceId) => {
|
||||
void workspaces.connectWorkspace(workspaceId).then((nextId) => {
|
||||
if (sessionId !== undefined && nextId !== sessionId) {
|
||||
const from = inputHub.shell(sessionId)
|
||||
const draft = from.snapshot.draft
|
||||
if (draft !== '') {
|
||||
inputHub.shell(nextId).setDraft(draft)
|
||||
from.setDraft('')
|
||||
}
|
||||
selectWorkspace: async (workspaceId) => {
|
||||
const nextId = await workspaces.connectWorkspace(workspaceId)
|
||||
if (sessionId !== undefined && nextId !== sessionId) {
|
||||
const from = inputHub.shell(sessionId)
|
||||
const draft = from.snapshot.draft
|
||||
if (draft !== '') {
|
||||
inputHub.shell(nextId).setDraft(draft)
|
||||
from.setDraft('')
|
||||
}
|
||||
sessions.open(nextId)
|
||||
}).catch(() => {
|
||||
// Failure leaves the current Hero state available to retry.
|
||||
})
|
||||
}
|
||||
sessions.open(nextId)
|
||||
},
|
||||
}),
|
||||
}, ConversationRoot)
|
||||
|
||||
@@ -86,7 +86,8 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq
|
||||
seq: number
|
||||
onOpenDetails: OpenDetails
|
||||
selected: boolean
|
||||
/** `run_code` sub-dispatches in dispatch order (reference-stable per parent; running entries settle in place); undefined for ordinary calls. */
|
||||
/** `run_code` sub-dispatches in dispatch order (reference-stable per
|
||||
* parent; running entries settle in place); undefined for ordinary calls. */
|
||||
subCalls?: readonly CodeSubCall[] | undefined
|
||||
/** The store's selected callId, matched against sub-rows (undefined when no sub-row here is selected). */
|
||||
selectedCallId?: string | undefined
|
||||
@@ -103,7 +104,7 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq
|
||||
})}
|
||||
{subCalls !== undefined && subCalls.length > 0 && (
|
||||
<div className={css.subCalls} data-subcalls>
|
||||
{subCalls.map((node) => (
|
||||
{subCalls.map(node => (
|
||||
<SubCallRow
|
||||
key={node.callId}
|
||||
renderSlot={renderSlot}
|
||||
@@ -130,7 +131,7 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails,
|
||||
}) {
|
||||
return (
|
||||
<div className={css.toolGroup}>
|
||||
{results.map((node) => (
|
||||
{results.map(node => (
|
||||
<CallRow
|
||||
key={node.callId}
|
||||
renderSlot={renderSlot}
|
||||
@@ -154,7 +155,7 @@ function StreamingTail({ useSession, onGrow }: {
|
||||
useSession: UseConversation
|
||||
onGrow: () => void
|
||||
}) {
|
||||
const partial = useSession((s) => s.partial)
|
||||
const partial = useSession(s => s.partial)
|
||||
useLayoutEffect(() => {
|
||||
onGrow()
|
||||
})
|
||||
@@ -162,17 +163,20 @@ function StreamingTail({ useSession, onGrow }: {
|
||||
return <AssistantMarkdown blocks={partial.blocks} streaming />
|
||||
}
|
||||
|
||||
/** The chat view slot entry: pure component over the composed props (tool rows render through the declared keyed hole's renderSlot share). */
|
||||
/**
|
||||
* The chat view slot entry: pure component over the composed props (tool rows
|
||||
* render through the declared keyed hole's renderSlot share).
|
||||
*/
|
||||
export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOlder }: ChatViewSlotProps) {
|
||||
const nodes = useSession((s) => s.nodes)
|
||||
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)
|
||||
const loadingOlder = useSession((s) => s.loadingOlder)
|
||||
const selectedCallId = useStore((s) => s.selection?.callId)
|
||||
const nodes = useSession(s => s.nodes)
|
||||
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)
|
||||
const loadingOlder = useSession(s => s.loadingOlder)
|
||||
const selectedCallId = useStore(s => s.selection?.callId)
|
||||
|
||||
const items = useMemo(() => deriveChatFlow(nodes), [nodes])
|
||||
|
||||
@@ -254,8 +258,8 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl
|
||||
const renderItem = (item: ChatFlowItem): ReactNode => {
|
||||
if (item.kind === 'tool-group') {
|
||||
const inGroup = selectedCallId !== undefined
|
||||
&& item.results.some((r) => r.callId === selectedCallId
|
||||
|| codeDispatches.get(r.callId)?.some((sub) => sub.callId === selectedCallId) === true)
|
||||
&& item.results.some(r => r.callId === selectedCallId
|
||||
|| codeDispatches.get(r.callId)?.some(sub => sub.callId === selectedCallId) === true)
|
||||
return (
|
||||
<ToolGroup
|
||||
key={item.key}
|
||||
@@ -280,36 +284,36 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl
|
||||
<div className={css.root}>
|
||||
<div ref={listRef} className={css.scroll} onScroll={onScroll}>
|
||||
<div className={css.column}>
|
||||
{openState === 'loading' && <div className={css.hint}>载入历史…</div>}
|
||||
{openState === 'error' && <div className={css.openError}>历史加载失败:{openErrorMessage}</div>}
|
||||
{hasMore && (
|
||||
<div className={css.older}>
|
||||
<button type="button" disabled={loadingOlder} onClick={loadOlderAnchored}>
|
||||
{loadingOlder ? '加载中…' : '加载更早'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{items.map(renderItem)}
|
||||
<StreamingTail useSession={useSession} onGrow={onGrow} />
|
||||
{runningCalls.length > 0 && (
|
||||
<div className={css.toolGroup}>
|
||||
{runningCalls.map((call) => (
|
||||
<CallRow
|
||||
key={call.callId}
|
||||
renderSlot={renderSlot}
|
||||
callId={call.callId}
|
||||
toolName={call.name}
|
||||
block={call}
|
||||
seq={call.turn}
|
||||
onOpenDetails={openDetails}
|
||||
selected={call.callId === selectedCallId}
|
||||
subCalls={codeDispatches.get(call.callId)}
|
||||
selectedCallId={selectedCallId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{pending.map((item) => <PendingCard key={item.key} item={item} />)}
|
||||
{openState === 'loading' && <div className={css.hint}>载入历史…</div>}
|
||||
{openState === 'error' && <div className={css.openError}>历史加载失败:{openErrorMessage}</div>}
|
||||
{hasMore && (
|
||||
<div className={css.older}>
|
||||
<button type="button" disabled={loadingOlder} onClick={loadOlderAnchored}>
|
||||
{loadingOlder ? '加载中…' : '加载更早'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{items.map(renderItem)}
|
||||
<StreamingTail useSession={useSession} onGrow={onGrow} />
|
||||
{runningCalls.length > 0 && (
|
||||
<div className={css.toolGroup}>
|
||||
{runningCalls.map(call => (
|
||||
<CallRow
|
||||
key={call.callId}
|
||||
renderSlot={renderSlot}
|
||||
callId={call.callId}
|
||||
toolName={call.name}
|
||||
block={call}
|
||||
seq={call.turn}
|
||||
onOpenDetails={openDetails}
|
||||
selected={call.callId === selectedCallId}
|
||||
subCalls={codeDispatches.get(call.callId)}
|
||||
selectedCallId={selectedCallId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{pending.map(item => <PendingCard key={item.key} item={item} />)}
|
||||
</div>
|
||||
</div>
|
||||
<StatsLine useSession={useSession} />
|
||||
|
||||
@@ -30,6 +30,7 @@ export function GenericToolCard({ toolName, block, openDetails }: ToolRowOwnerPr
|
||||
return (
|
||||
<ToolRow
|
||||
variant={model.variant}
|
||||
toolName={toolName}
|
||||
icon={VARIANT_ICONS[model.variant]}
|
||||
title={model.title}
|
||||
summary={model.summary}
|
||||
|
||||
@@ -32,6 +32,9 @@ function contentText(content: readonly unknown[]): { text: string; rest: unknown
|
||||
|
||||
/** Best-effort clipboard write; rejections stay swallowed (no success chrome). */
|
||||
async function writeClipboard(text: string): Promise<void> {
|
||||
// lib.dom types clipboard non-optional, but insecure contexts omit it —
|
||||
// that runtime gap is exactly what this guard detects.
|
||||
/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
@@ -40,6 +43,9 @@ async function writeClipboard(text: string): Promise<void> {
|
||||
}
|
||||
return
|
||||
}
|
||||
// execCommand('copy') is the only clipboard fallback where the async API
|
||||
// is missing (insecure contexts); deprecated but deliberately retained.
|
||||
/* eslint-disable @typescript-eslint/no-deprecated */
|
||||
const exec = typeof document.execCommand === 'function'
|
||||
? document.execCommand.bind(document)
|
||||
: undefined
|
||||
@@ -56,6 +62,7 @@ async function writeClipboard(text: string): Promise<void> {
|
||||
} catch {
|
||||
// Clipboard unavailable; the button stays idle.
|
||||
}
|
||||
/* eslint-enable @typescript-eslint/no-deprecated */
|
||||
el.remove()
|
||||
}
|
||||
|
||||
@@ -146,7 +153,7 @@ export const MessageItem = memo(function MessageItem({ node }: MessageItemProps)
|
||||
case 'context':
|
||||
return (
|
||||
<div className={css.contextRow}>
|
||||
<JsonBlock label="上下文注入" payload={{ content: node.content, meta: node.meta }} />
|
||||
<JsonBlock label="上下文注入" payload={{ content: node.content, source: node.source }} />
|
||||
</div>
|
||||
)
|
||||
default:
|
||||
|
||||
@@ -53,7 +53,7 @@ export function deriveStats(nodes: ConversationSnapshot['nodes']): UsageTotals {
|
||||
export interface StatsLineProps { useSession: SnapshotSelectorHook<ConversationSnapshot> }
|
||||
|
||||
export const StatsLine = memo(function StatsLine({ useSession }: StatsLineProps) {
|
||||
const nodes = useSession((s) => s.nodes)
|
||||
const nodes = useSession(s => s.nodes)
|
||||
const stats = useMemo(() => deriveStats(nodes), [nodes])
|
||||
if (stats.steps === 0) return null
|
||||
const parts: string[] = []
|
||||
|
||||
@@ -42,6 +42,21 @@
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* Cordis lifecycle tools retain their generic row mechanics while carrying a
|
||||
shared product accent and tool-owned action title. */
|
||||
.root[data-tool^='cordis_'] .leading,
|
||||
.root[data-tool^='cordis_'] .title {
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.root[data-tool^='cordis_'] .title {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.root[data-tool^='cordis_'] .sep {
|
||||
background: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
button.leading {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import css from './ToolRow.module.css'
|
||||
|
||||
export interface ToolRowProps {
|
||||
variant: ToolRowVariant
|
||||
/** Wire tool name for tool-owned styling layered over the generic variant. */
|
||||
toolName?: string | undefined
|
||||
/** Leading 16px tool icon, shown while collapsed and not running/failed. */
|
||||
icon: ReactNode
|
||||
title: string
|
||||
@@ -39,6 +41,7 @@ function leadingFor(state: ToolRowState, icon: ReactNode): ReactNode {
|
||||
|
||||
export function ToolRow({
|
||||
variant,
|
||||
toolName,
|
||||
icon,
|
||||
title,
|
||||
summary,
|
||||
@@ -52,7 +55,7 @@ export function ToolRow({
|
||||
const open = expanded && expandable
|
||||
const rowExpands = expandable && expandOnRowClick
|
||||
const toggleExpand = () => {
|
||||
setExpanded((v) => !v)
|
||||
setExpanded(v => !v)
|
||||
}
|
||||
const toggleFromLeading = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation()
|
||||
@@ -64,7 +67,7 @@ export function ToolRow({
|
||||
toggleExpand()
|
||||
}
|
||||
return (
|
||||
<div className={css.root} data-variant={variant} data-state={state}>
|
||||
<div className={css.root} data-variant={variant} data-tool={toolName} data-state={state}>
|
||||
<div
|
||||
className={css.row}
|
||||
data-clickable={rowExpands || onOpenDetails !== undefined || undefined}
|
||||
|
||||
@@ -146,7 +146,7 @@ export interface ToolRowOwnerProps {
|
||||
/** Frozen call slice: the running call or the settled result node. */
|
||||
block: ToolCallBlock
|
||||
/** Open the details panel for this call (session-level facility, supplied by the view). */
|
||||
openDetails(): void
|
||||
openDetails: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -177,21 +177,21 @@ export interface ConversationInjected {
|
||||
* Connect the selected Workspace and open its reusable/new blank session.
|
||||
* When a blank session is already current, carry its draft to the target.
|
||||
*/
|
||||
selectWorkspace(workspaceId: WorkspaceId): void
|
||||
selectWorkspace: (workspaceId: WorkspaceId) => Promise<void>
|
||||
}
|
||||
|
||||
/** Business callbacks injected into the strict session content seat. */
|
||||
export interface ConversationSessionInjected {
|
||||
/** Views projected from the `conversation.view` slot ledger. */
|
||||
views: {
|
||||
list(): readonly ViewTab[]
|
||||
subscribe(fn: () => void): () => void
|
||||
version(): number
|
||||
list: () => readonly ViewTab[]
|
||||
subscribe: (fn: () => void) => () => void
|
||||
version: () => number
|
||||
}
|
||||
/** Bind the input machine's draft persistence mirror to the session store. */
|
||||
bindDraftMirror(write: (text: string) => void): () => void
|
||||
bindDraftMirror: (write: (text: string) => void) => () => void
|
||||
/** Select a real Session through the runtime navigation owner. */
|
||||
open(sessionId: SessionId): void
|
||||
open: (sessionId: SessionId) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -221,7 +221,7 @@ export interface ComposerBarInjected {
|
||||
/** The InputBar-exclusive keyboard/DOM command face (decision 20 private plane). */
|
||||
keyboard: ComposerKeyboard
|
||||
/** Cancel the in-flight turn. */
|
||||
stop(): void
|
||||
stop: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -277,8 +277,8 @@ export type ConversationSessionSlotProps =
|
||||
*/
|
||||
export interface ChatViewInjected {
|
||||
/** Selection write + details panel opening in one gesture (store action + layout orchestration). */
|
||||
openDetails(target: SelectionTarget): void
|
||||
loadOlder(): void
|
||||
openDetails: (target: SelectionTarget) => void
|
||||
loadOlder: () => void
|
||||
}
|
||||
|
||||
/** Full chat-view component props: runtime share & the declared toolview hole's render share & store share & injected share. */
|
||||
@@ -292,7 +292,7 @@ export type ChatViewSlotProps =
|
||||
*/
|
||||
export interface DetailsInjected {
|
||||
/** Close the details panel (layout geometry stays with ctx.layout). */
|
||||
closeDetails(): void
|
||||
closeDetails: () => void
|
||||
}
|
||||
|
||||
/** Full details-slot component props: selection arrives through the shared store, call material through useSession. */
|
||||
@@ -302,6 +302,6 @@ export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore<ChatStore> &
|
||||
export interface EmptyWorkspaceOwnerProps {
|
||||
open: boolean
|
||||
anchorRef?: RefObject<HTMLElement>
|
||||
onPick(workspaceId: WorkspaceId): void
|
||||
onClose(): void
|
||||
onPick: (workspaceId: WorkspaceId) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
@@ -36,6 +36,16 @@ const TOOL_VARIANTS: Record<string, ToolRowVariant> = {
|
||||
write: 'write',
|
||||
edit: 'edit',
|
||||
run_code: 'code',
|
||||
cordis_inspect: 'read',
|
||||
cordis_mount: 'code',
|
||||
cordis_unmount: 'others',
|
||||
}
|
||||
|
||||
/** Tool-owned titles that refine a generic row variant without replacing it. */
|
||||
const TOOL_TITLES: Record<string, string> = {
|
||||
cordis_inspect: 'Inspect',
|
||||
cordis_mount: 'Mount temporary Plugin',
|
||||
cordis_unmount: 'Unmount temporary Plugin',
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -130,12 +140,15 @@ export function toolRowModel(toolName: string, block: ToolCallBlock): ToolRowMod
|
||||
: block.error?.code === 'interrupted' ? 'stopped'
|
||||
: block.isError ? 'error' : 'ok'
|
||||
const base = argsRaw === '' ? block.callId : deriveSummary(variant, argsRaw)
|
||||
const toolTitle = TOOL_TITLES[toolName]
|
||||
// Others keeps the static "Tool call" title (figma literal); the real tool
|
||||
// name rides the mutable summary slot so no information is lost.
|
||||
const summary = variant === 'others' && toolName !== '' ? `${toolName} · ${base}` : base
|
||||
// name rides the mutable summary slot unless the tool owns a specific title.
|
||||
const summary = variant === 'others' && toolName !== '' && toolTitle === undefined
|
||||
? `${toolName} · ${base}`
|
||||
: base
|
||||
return {
|
||||
variant,
|
||||
title: VARIANT_TITLES[variant],
|
||||
title: toolTitle ?? VARIANT_TITLES[variant],
|
||||
summary,
|
||||
body: deriveBody(variant, argsRaw),
|
||||
state,
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
// chain stay mounted across no-session/session transitions. Only the inert
|
||||
// input body swaps for the strict session InputBar.
|
||||
|
||||
import { useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationSlotProps, InputZone } from '../contract/slots.ts'
|
||||
import { HeroShell, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx'
|
||||
import { DisabledInputBar } from './DisabledInputBar.tsx'
|
||||
@@ -25,8 +26,23 @@ export function ConversationRoot({
|
||||
const workspaces = useWorkspaces(s => s)
|
||||
|
||||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
const [pendingWorkspaceId, setPendingWorkspaceId] = useState<WorkspaceId | undefined>()
|
||||
const pickerAnchor = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const sessionWorkspace = sessionId === undefined
|
||||
? undefined
|
||||
: workspaces.items.find(workspace => workspace.sessionIds.includes(sessionId))
|
||||
const pendingWorkspace = workspaces.items.find(
|
||||
workspace => workspace.workspaceId === pendingWorkspaceId,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingWorkspaceId !== undefined
|
||||
&& sessionWorkspace?.workspaceId === pendingWorkspaceId) {
|
||||
setPendingWorkspaceId(undefined)
|
||||
}
|
||||
}, [pendingWorkspaceId, sessionWorkspace?.workspaceId])
|
||||
|
||||
const hero = sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || openState === 'loading'))
|
||||
const zone: InputZone | undefined =
|
||||
session === undefined || inputState === undefined ? undefined : { session, input: inputState }
|
||||
@@ -36,9 +52,10 @@ export function ConversationRoot({
|
||||
<WorkspaceChip
|
||||
buttonRef={pickerAnchor}
|
||||
label={
|
||||
sessionId === undefined
|
||||
pendingWorkspace?.title
|
||||
?? (sessionId === undefined
|
||||
? workspaceLabel('')
|
||||
: workspaces.items.find(w => w.sessionIds.includes(sessionId))?.title ?? workspaceLabel(cwd ?? '')
|
||||
: sessionWorkspace?.title ?? workspaceLabel(cwd ?? ''))
|
||||
}
|
||||
menuOpen={pickerOpen}
|
||||
onClick={() => { setPickerOpen(open => !open) }}
|
||||
@@ -48,7 +65,10 @@ export function ConversationRoot({
|
||||
anchorRef: pickerAnchor,
|
||||
onPick: (workspaceId) => {
|
||||
setPickerOpen(false)
|
||||
selectWorkspace(workspaceId)
|
||||
setPendingWorkspaceId(workspaceId)
|
||||
void selectWorkspace(workspaceId).catch(() => {
|
||||
setPendingWorkspaceId(current => current === workspaceId ? undefined : current)
|
||||
})
|
||||
},
|
||||
onClose: () => { setPickerOpen(false) },
|
||||
})}
|
||||
@@ -58,12 +78,12 @@ export function ConversationRoot({
|
||||
const inputBar = sessionId === undefined
|
||||
? <DisabledInputBar />
|
||||
: renderSlot('conversation.composer.bar', {
|
||||
variant: hero ? 'hero' : 'composer',
|
||||
...(hero ? { placeholder: 'Describe what you want to build' } : {}),
|
||||
overlay: renderSlot('conversation.input.overlay', {}),
|
||||
leftItems: zone === undefined ? null : renderSlot('conversation.input.left', zone),
|
||||
rightItems: zone === undefined ? null : renderSlot('conversation.input.right', zone),
|
||||
})
|
||||
variant: hero ? 'hero' : 'composer',
|
||||
...(hero ? { placeholder: 'Describe what you want to build' } : {}),
|
||||
overlay: renderSlot('conversation.input.overlay', {}),
|
||||
leftItems: zone === undefined ? null : renderSlot('conversation.input.left', zone),
|
||||
rightItems: zone === undefined ? null : renderSlot('conversation.input.right', zone),
|
||||
})
|
||||
|
||||
const composerBar = (
|
||||
<div className={clsx(css.composerStack, hero && css.composerHero)}>
|
||||
|
||||
@@ -41,8 +41,8 @@ export function ConversationSession({
|
||||
if (inputState.draft === '' && storedDraft !== '') inputActions.setDraft(storedDraft)
|
||||
const unmirror = bindDraftMirror(actions.setDraft)
|
||||
return () => { unmirror() }
|
||||
// Mount-only: later store writes come from the machine mirror.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// Mount-only (deps pinned to inputActions): later store writes come from
|
||||
// the machine mirror, not this seed effect.
|
||||
}, [inputActions])
|
||||
|
||||
const blankHero = blank && composerPhase === 'blank'
|
||||
|
||||
@@ -86,27 +86,27 @@ export function DetailsPanel({ useSession, useStore, closeDetails }: DetailsPane
|
||||
: material === null
|
||||
? <div className={css.empty}>该调用不在当前窗口内</div>
|
||||
: (
|
||||
<>
|
||||
{material.argsRaw !== null && (
|
||||
<section className={css.section}>
|
||||
<div className={css.sectionLabel}>Input</div>
|
||||
<CodeBlock code={pretty(material.argsRaw)} lang="json" />
|
||||
</section>
|
||||
)}
|
||||
<>
|
||||
{material.argsRaw !== null && (
|
||||
<section className={css.section}>
|
||||
<div className={css.sectionLabel}>Output</div>
|
||||
{/* materialFor invariant: result===null ⇔ running (a settled
|
||||
material always carries its result node). */}
|
||||
{material.result === null
|
||||
? <div className={css.empty}>运行中…</div>
|
||||
: (
|
||||
<pre className={css.code} data-error={material.result.isError || undefined}>
|
||||
{renderResult(material.result)}
|
||||
</pre>
|
||||
)}
|
||||
<div className={css.sectionLabel}>Input</div>
|
||||
<CodeBlock code={pretty(material.argsRaw)} lang="json" />
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
)}
|
||||
<section className={css.section}>
|
||||
<div className={css.sectionLabel}>Output</div>
|
||||
{/* materialFor invariant: result===null ⇔ running (a settled
|
||||
material always carries its result node). */}
|
||||
{material.result === null
|
||||
? <div className={css.empty}>运行中…</div>
|
||||
: (
|
||||
<pre className={css.code} data-error={material.result.isError || undefined}>
|
||||
{renderResult(material.result)}
|
||||
</pre>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -31,7 +31,11 @@ export function InputBar({
|
||||
variant, placeholder, accessory, overlay, leftItems, rightItems, onAdd, addLabel = 'Add attachment',
|
||||
}: InputBarProps) {
|
||||
const input = useInput(s => s)
|
||||
const notice = useSyncExternalStore(keyboard.notices.subscribe, keyboard.notices.getSnapshot)
|
||||
const noticeStore = keyboard.notices
|
||||
const notice = useSyncExternalStore(
|
||||
(fn: () => void) => noticeStore.subscribe(fn),
|
||||
() => noticeStore.getSnapshot(),
|
||||
)
|
||||
const promptError = useSession(s => s.promptError)
|
||||
const running = useSession(s => s.running)
|
||||
const disabled = useSession(s => s.removed)
|
||||
@@ -75,6 +79,8 @@ export function InputBar({
|
||||
// Shift+Enter is the native newline UNCONDITIONALLY — decided before the
|
||||
// IME guard so a composition-closing Shift+Enter still breaks the line.
|
||||
if (e.key === 'Enter' && e.shiftKey) return
|
||||
// keyCode 229 is the legacy IME-composition signal engines emit without isComposing.
|
||||
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
||||
const composing = composingRef.current || e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229
|
||||
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||||
if (keyboard.arbitrate(e.key === 'ArrowUp' ? 'up' : 'down', composing) === 'consumed') e.preventDefault()
|
||||
@@ -92,7 +98,7 @@ export function InputBar({
|
||||
// the browser stack cannot represent); never let the native stack run.
|
||||
e.preventDefault()
|
||||
if (machineBusy || locked) return
|
||||
const redo = e.key === 'y' || (e.shiftKey && (e.key === 'z' || e.key === 'Z'))
|
||||
const redo = e.key === 'y' || e.shiftKey
|
||||
if (redo) keyboard.redo()
|
||||
else keyboard.undo()
|
||||
return
|
||||
@@ -134,6 +140,8 @@ export function InputBar({
|
||||
if (machineBusy) return // submitting is the read-only span; adjudicating holds the pending lock
|
||||
const next = e.target.value
|
||||
keyboard.setDraft(next)
|
||||
// selectionStart is number|null in lib.dom; the eslint program narrows it.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
keyboard.track(next, e.target.selectionStart ?? next.length)
|
||||
}
|
||||
|
||||
@@ -145,10 +153,13 @@ export function InputBar({
|
||||
// too (one char = one step). Mouse selection of a chip is handled in the
|
||||
// backdrop click handler below. Undo/redo must NOT reach the browser: the
|
||||
// machine owns the transaction log.
|
||||
// selectionStart/End are number|null in lib.dom; the eslint program narrows them.
|
||||
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
|
||||
const selectionOf = (el: HTMLTextAreaElement) => ({
|
||||
start: el.selectionStart ?? 0,
|
||||
end: el.selectionEnd ?? el.selectionStart ?? 0,
|
||||
})
|
||||
/* eslint-enable @typescript-eslint/no-unnecessary-condition */
|
||||
|
||||
const onCopyOrCut = (e: React.ClipboardEvent<HTMLTextAreaElement>, cut: boolean): void => {
|
||||
const el = e.currentTarget
|
||||
@@ -330,8 +341,8 @@ export function InputBar({
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
onSelect={onSelect}
|
||||
onCopy={e => { onCopyOrCut(e, false) }}
|
||||
onCut={e => { onCopyOrCut(e, true) }}
|
||||
onCopy={(e) => { onCopyOrCut(e, false) }}
|
||||
onCut={(e) => { onCopyOrCut(e, true) }}
|
||||
onPaste={onPaste}
|
||||
onCompositionStart={onCompositionStart}
|
||||
onCompositionEnd={onCompositionEnd}
|
||||
|
||||
@@ -36,10 +36,12 @@ const SCOPE_TAG: 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 as Context)
|
||||
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
|
||||
@@ -73,14 +75,15 @@ async function bench() {
|
||||
const mint = (id: SessionId): Context => {
|
||||
let scoped = scopes.get(id)
|
||||
if (scoped === undefined) {
|
||||
scoped = ctx.plugin(() => {}).ctx.extend({ [SCOPE_TAG]: id }) as Context
|
||||
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>
|
||||
hooks?: Record<string, unknown>
|
||||
props?: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
const providers: TestProvider[] = []
|
||||
@@ -158,10 +161,12 @@ async function bench() {
|
||||
const inputSurface = (id: SessionId) => {
|
||||
const contribution = providers[0]!.resolve(sessionsFake.binding(id))
|
||||
const state = contribution.hooks!['input'] as {
|
||||
getSnapshot(): { draft: string }; subscribe(fn: () => void): () => void
|
||||
getSnapshot: () => { draft: string }
|
||||
subscribe: (fn: () => void) => () => void
|
||||
}
|
||||
const actions = contribution.props!['inputActions'] as {
|
||||
setDraft(text: string): void; submit(mode?: 'queue' | 'steer'): void
|
||||
setDraft: (text: string) => void
|
||||
submit: (mode?: 'queue' | 'steer') => void
|
||||
}
|
||||
return { state, actions }
|
||||
}
|
||||
@@ -234,11 +239,11 @@ describe('conversation slot inject surface', () => {
|
||||
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/)
|
||||
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/)
|
||||
expect(() => { injectFn(ROOT).stop() }).toThrow(/unavailable through the session scope/)
|
||||
})
|
||||
|
||||
it('openDetails (chat view face) writes the selection through the store actions and opens the panel', async () => {
|
||||
@@ -263,7 +268,7 @@ describe('conversation slot inject surface', () => {
|
||||
// no draft movement, plain re-open.
|
||||
const { state, actions } = b.inputSurface(ROOT)
|
||||
actions.setDraft('carry me')
|
||||
resident.selectWorkspace('workspace-1' as never)
|
||||
void resident.selectWorkspace('workspace-1' as never)
|
||||
await vi.waitFor(() => { expect(b.sessionsFake.open).toHaveBeenCalledTimes(2) })
|
||||
expect(b.workspacesFake.connectWorkspace).toHaveBeenCalledWith('workspace-1')
|
||||
expect(state.getSnapshot().draft).toBe('carry me')
|
||||
@@ -271,7 +276,7 @@ describe('conversation slot inject surface', () => {
|
||||
// new session's machine receives the text, then navigation lands there.
|
||||
const OTHER = 'other-1' as SessionId
|
||||
b.workspacesFake.connectWorkspace.mockResolvedValueOnce(OTHER)
|
||||
resident.selectWorkspace('workspace-2' as never)
|
||||
void resident.selectWorkspace('workspace-2' as never)
|
||||
await vi.waitFor(() => { expect(b.sessionsFake.open).toHaveBeenCalledWith(OTHER) })
|
||||
expect(state.getSnapshot().draft).toBe('')
|
||||
expect(b.inputSurface(OTHER).state.getSnapshot().draft).toBe('carry me')
|
||||
|
||||
@@ -31,7 +31,7 @@ async function bench() {
|
||||
},
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
} as SessionListState)
|
||||
})
|
||||
const sessionsFake = {
|
||||
list: listStore,
|
||||
binding: vi.fn(),
|
||||
@@ -83,7 +83,7 @@ describe('apply wiring', () => {
|
||||
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.map(e => e.options.id)).toEqual(['chat'])
|
||||
expect(entries[0]?.options.label).toBe('Chat')
|
||||
expect(entries[0]?.options.order).toBe(0)
|
||||
// Declaring is claiming: the chat entry's registration put the hole on
|
||||
@@ -117,7 +117,7 @@ describe('apply wiring', () => {
|
||||
// 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'])
|
||||
expect(entries.map(e => e.options.key)).toEqual(['bash', 'todo_write'])
|
||||
})
|
||||
|
||||
it('plugin fiber disposal collects every registration (unload cascade, ring and hole included)', async () => {
|
||||
|
||||
@@ -103,7 +103,7 @@ describe('MessageItem arms', () => {
|
||||
|
||||
it('context and unknown nodes render their JSON rows', () => {
|
||||
const ctxView = render(
|
||||
<MessageItem node={{ kind: 'context', seq: 3, content: [], source: null, meta: { k: 1 } } as never} />,
|
||||
<MessageItem node={{ kind: 'context', seq: 3, content: [], source: null } as never} />,
|
||||
)
|
||||
expect(ctxView.getByText(/上下文注入/)).toBeTruthy()
|
||||
const unknownView = render(
|
||||
|
||||
@@ -59,7 +59,7 @@ function snapshotWith(
|
||||
pending: [], queue: [], todos: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
||||
} as ConversationSnapshot
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-owned AppFrame role: declares and renders the resident conversation area. */
|
||||
@@ -167,6 +167,28 @@ describe('run_code sub-calls through the real chat machinery', () => {
|
||||
expect(view.getByText('Tool call')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders Cordis sub-calls with lifecycle titles over the generic variants', async () => {
|
||||
const parent = 'call-cordis'
|
||||
const code = 'return { name: "audit", apply(ctx) {} }'
|
||||
const dispatches = new Map([[parent, [
|
||||
subCall(11, parent, 1, 'cordis_inspect', { what: 'temporary' }, '## Temporary Plugins'),
|
||||
subCall(12, parent, 2, 'cordis_mount', { code }, 'Temporary Plugin dyn-2 is running'),
|
||||
subCall(13, parent, 3, 'cordis_unmount', { id: 'dyn-2' }, 'Temporary Plugin dyn-2 was unmounted and removed.'),
|
||||
]]])
|
||||
const b = await bench(snapshotWith([codeResult(10, parent)], dispatches))
|
||||
const view = mountApp(b.slots)
|
||||
const nest = view.container.querySelector('[data-subcalls]')!
|
||||
|
||||
expect(nest.querySelector('[data-tool="cordis_inspect"]')?.textContent).toContain('Inspect')
|
||||
const mounted = nest.querySelector('[data-variant="code"]')
|
||||
expect(mounted?.textContent).toContain(`Mount temporary Plugin${code}`)
|
||||
expect(nest.querySelector('[data-tool="cordis_unmount"]')?.textContent)
|
||||
.toContain('Unmount temporary Plugindyn-2')
|
||||
|
||||
fireEvent.click(mounted!.querySelector('button[aria-expanded]')!)
|
||||
expect(mounted!.querySelector('pre.shiki')?.textContent).toBe(code)
|
||||
})
|
||||
|
||||
it('expanding the code row reveals the program body verbatim (shiki-tokenized)', async () => {
|
||||
const parent = 'call-64'
|
||||
const b = await bench(snapshotWith([codeResult(10, parent)], new Map()))
|
||||
|
||||
@@ -36,7 +36,7 @@ function makeSource(init?: Partial<ConversationSnapshot>) {
|
||||
let snap: ConversationSnapshot = { ...snapshotBase(), ...init }
|
||||
const subs = new Set<() => void>()
|
||||
return {
|
||||
set(next: Partial<ConversationSnapshot>) {
|
||||
set: (next: Partial<ConversationSnapshot>) => {
|
||||
snap = { ...snap, ...next }
|
||||
for (const fn of [...subs]) fn()
|
||||
},
|
||||
@@ -100,9 +100,9 @@ describe('StatsLine', () => {
|
||||
render(<Counting {...props(source)} />)
|
||||
const before = renders
|
||||
// Chunk frames swap partial only; nodes keeps its reference (object-layer contract).
|
||||
act(() => set({ partial: { turn: 1, step: 2, blocks: [{ kind: 'text', text: 'a' }] } }))
|
||||
act(() => set({ partial: { turn: 1, step: 2, blocks: [{ kind: 'text', text: 'ab' }] } }))
|
||||
act(() => set({ running: true }))
|
||||
act(() => { set({ partial: { turn: 1, step: 2, blocks: [{ kind: 'text', text: 'a' }] } }) })
|
||||
act(() => { set({ partial: { turn: 1, step: 2, blocks: [{ kind: 'text', text: 'ab' }] } }) })
|
||||
act(() => { set({ running: true }) })
|
||||
expect(renders).toBe(before)
|
||||
})
|
||||
})
|
||||
@@ -128,7 +128,7 @@ describe('bash sample row', () => {
|
||||
},
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
} as SessionListState)
|
||||
})
|
||||
}
|
||||
|
||||
const rowProps = (sessionId: SessionId, over?: {
|
||||
|
||||
@@ -31,6 +31,9 @@ describe('tool-call-model', () => {
|
||||
expect(classifyTool('grep')).toBe('search')
|
||||
expect(classifyTool('write')).toBe('write')
|
||||
expect(classifyTool('edit')).toBe('edit')
|
||||
expect(classifyTool('cordis_inspect')).toBe('read')
|
||||
expect(classifyTool('cordis_mount')).toBe('code')
|
||||
expect(classifyTool('cordis_unmount')).toBe('others')
|
||||
expect(classifyTool('todo_write')).toBe('others')
|
||||
})
|
||||
|
||||
@@ -67,6 +70,33 @@ describe('tool-call-model', () => {
|
||||
expect(toolRowModel('bash', running({ argsRaw: '' })).body).toBeNull()
|
||||
expect(toolRowModel('bash', result({ call: null })).body).toBeNull()
|
||||
})
|
||||
|
||||
it('gives Cordis lifecycle tools action titles over their generic variants', () => {
|
||||
expect(toolRowModel('cordis_inspect', running({
|
||||
name: 'cordis_inspect',
|
||||
argsRaw: '{"what":"api","name":"tools"}',
|
||||
}))).toMatchObject({
|
||||
variant: 'read',
|
||||
title: 'Inspect',
|
||||
summary: 'api',
|
||||
})
|
||||
expect(toolRowModel('cordis_mount', running({
|
||||
name: 'cordis_mount',
|
||||
argsRaw: '{"code":"return { name: \\"audit\\", apply(ctx) {} }"}',
|
||||
}))).toMatchObject({
|
||||
variant: 'code',
|
||||
title: 'Mount temporary Plugin',
|
||||
summary: 'return { name: "audit", apply(ctx) {} }',
|
||||
body: 'return { name: "audit", apply(ctx) {} }',
|
||||
})
|
||||
expect(toolRowModel('cordis_unmount', result({
|
||||
call: { name: 'cordis_unmount', argsRaw: '{"id":"dyn-2"}' },
|
||||
}))).toMatchObject({
|
||||
variant: 'others',
|
||||
title: 'Unmount temporary Plugin',
|
||||
summary: 'dyn-2',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('ToolRow', () => {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
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,
|
||||
@@ -42,7 +42,7 @@ function snapshotWith(nodes: ToolResultNode[]): ConversationSnapshot {
|
||||
sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
||||
} as ConversationSnapshot
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-owned AppFrame role: declares and renders the resident conversation area. */
|
||||
@@ -108,6 +108,10 @@ async function bench(nodes: ToolResultNode[]) {
|
||||
return info
|
||||
},
|
||||
maybeProvideInfo(id: string | undefined) {
|
||||
// `this` inside an object-literal method is any under strict lint; the
|
||||
// fake resolves through its own provideInfo above.
|
||||
/* eslint-disable-next-line @typescript-eslint/no-unsafe-return,
|
||||
@typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */
|
||||
return (id === undefined ? undefined : this.provideInfo(id)) ?? { hooks: {}, props: {} }
|
||||
},
|
||||
provide: (d: { resolve: (typeof providers)[number] }) => { providers.push(d.resolve); return () => {} },
|
||||
@@ -162,6 +166,25 @@ describe('keyed toolview hole through the real machinery', () => {
|
||||
expect(view.getByText('Tool call')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders top-level Cordis calls with lifecycle titles over the generic variants', async () => {
|
||||
const code = 'return { name: "audit", apply(ctx) {} }'
|
||||
const b = await bench([
|
||||
toolResult(3, 'cordis-1', 'cordis_inspect', '{"what":"api","name":"tools"}'),
|
||||
toolResult(4, 'cordis-2', 'cordis_mount', JSON.stringify({ code })),
|
||||
toolResult(5, 'cordis-3', 'cordis_unmount', '{"id":"dyn-2"}'),
|
||||
])
|
||||
const view = mountApp(b.slots)
|
||||
|
||||
expect(view.container.querySelector('[data-tool="cordis_inspect"]')?.textContent).toContain('Inspect')
|
||||
const mounted = view.container.querySelector('[data-variant="code"]')
|
||||
expect(mounted?.textContent).toContain(`Mount temporary Plugin${code}`)
|
||||
expect(view.container.querySelector('[data-tool="cordis_unmount"]')?.textContent)
|
||||
.toContain('Unmount temporary Plugindyn-2')
|
||||
|
||||
fireEvent.click(mounted!.querySelector('button[aria-expanded]')!)
|
||||
expect(mounted!.querySelector('pre.shiki')?.textContent).toBe(code)
|
||||
})
|
||||
|
||||
it('row clicks travel owner openDetails → chat inject → layout orchestration', async () => {
|
||||
const b = await bench([toolResult(3, 'c1', 'bash')])
|
||||
const view = mountApp(b.slots)
|
||||
@@ -252,7 +275,7 @@ describe('registrant load-order seam', () => {
|
||||
children: {
|
||||
'conversation': { kind: 'single', scope: 'session-maybe' },
|
||||
'details': { kind: 'single', scope: 'session' },
|
||||
},
|
||||
},
|
||||
}, AppRoot)
|
||||
|
||||
// Third-party posture, mounted BEFORE ui-conversation: real fiber inject
|
||||
|
||||
@@ -7,7 +7,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Profiler } from 'react'
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import type {
|
||||
AssistantMessageNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, UserMessageNode, WorkspaceListState,
|
||||
AssistantMessageNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId,
|
||||
SessionListState, ToolResultNode, UserMessageNode, WorkspaceListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore, PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -39,7 +40,7 @@ function makeSource(init?: Partial<ConversationSnapshot>) {
|
||||
let snap: ConversationSnapshot = { ...snapshotBase(), ...init }
|
||||
const subs = new Set<() => void>()
|
||||
return {
|
||||
set(next: Partial<ConversationSnapshot>) {
|
||||
set: (next: Partial<ConversationSnapshot>) => {
|
||||
snap = { ...snap, ...next }
|
||||
for (const fn of [...subs]) fn()
|
||||
},
|
||||
@@ -104,8 +105,8 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
||||
useSession: bindSnapshotSelector(source),
|
||||
useSessions: emptySessions(),
|
||||
useWorkspaces: emptyWorkspaces(),
|
||||
useInput: (() => { throw new Error('unused') }) as never,
|
||||
inputActions: { setDraft: () => {}, submit: () => {} } as never,
|
||||
useInput: (() => { throw new Error('unused') }),
|
||||
inputActions: { setDraft: () => {}, submit: () => {} },
|
||||
useStore: bindSnapshotSelector(chat),
|
||||
actions: chat.actions,
|
||||
renderSlot,
|
||||
@@ -124,9 +125,9 @@ describe('chat-flow derivation', () => {
|
||||
assistant(5, 'found'), toolResult(6, 'c'),
|
||||
]
|
||||
const items = deriveChatFlow(nodes)
|
||||
expect(items.map((i) => i.kind)).toEqual(['node', 'node', 'tool-group', 'node', 'tool-group'])
|
||||
expect(items.map(i => i.kind)).toEqual(['node', 'node', 'tool-group', 'node', 'tool-group'])
|
||||
const group = items[2]!
|
||||
expect(group.kind === 'tool-group' && group.results.map((r) => r.callId)).toEqual(['a', 'b'])
|
||||
expect(group.kind === 'tool-group' && group.results.map(r => r.callId)).toEqual(['a', 'b'])
|
||||
expect(flowKeys(items)).toBe('n1|n2|g3|n5|g6')
|
||||
expect(flowKeys(deriveChatFlow([...nodes, toolResult(7, 'd')]))).toBe('n1|n2|g3|n5|g6')
|
||||
})
|
||||
@@ -155,7 +156,7 @@ describe('ChatView', () => {
|
||||
fireEvent.scroll(scroller)
|
||||
fireEvent.click(view.getByText('加载更早'))
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1300, writable: true })
|
||||
act(() => h.set({ nodes: [assistant(2, 'older'), user(9, 'late')] }))
|
||||
act(() => { h.set({ nodes: [assistant(2, 'older'), user(9, 'late')] }) })
|
||||
expect(scroller.scrollTop).toBe(550) // 50 + (1300 - 800)
|
||||
})
|
||||
|
||||
@@ -240,10 +241,10 @@ describe('ChatView', () => {
|
||||
// Count renderSlot invocations: the memo boundary holds when CallRow does
|
||||
// not re-render, so the row's renderSlot call count freezes during chunks.
|
||||
let rowRenders = 0
|
||||
h.props.renderSlot = (((_key: string, _owner: object) => {
|
||||
h.props.renderSlot = ((_key: string, _owner: object) => {
|
||||
rowRenders += 1
|
||||
return <div data-testid="counting-row" />
|
||||
}) as unknown as ChatViewSlotProps['renderSlot'])
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
expect(view.getByTestId('counting-row')).toBeTruthy()
|
||||
const afterMount = rowRenders
|
||||
@@ -270,7 +271,7 @@ describe('ChatView', () => {
|
||||
fireEvent.click(view.getByText('run a'))
|
||||
expect(h.openDetails).toHaveBeenCalledWith({ turnSeq: 3, callId: 'a', toolName: 'bash' })
|
||||
expect(view.container.querySelector('[data-selected]')).toBeNull()
|
||||
act(() => h.setSelection({ turnSeq: 3, callId: 'a', toolName: 'bash' }))
|
||||
act(() => { h.setSelection({ turnSeq: 3, callId: 'a', toolName: 'bash' }) })
|
||||
expect(view.container.querySelector('[data-selected]')).not.toBeNull()
|
||||
})
|
||||
|
||||
@@ -284,10 +285,10 @@ describe('ChatView', () => {
|
||||
it('dispatches each tool row through the keyed slot with the tool name as entryKey', () => {
|
||||
const h = makeHarness({ nodes: [toolResult(3, 'a')] })
|
||||
const calls: { key: string; entryKey?: string }[] = []
|
||||
h.props.renderSlot = (((key: string, _owner: object, opts?: { entryKey?: string; fallback?: React.ReactNode }) => {
|
||||
h.props.renderSlot = ((key: string, _owner: object, opts?: { entryKey?: string; fallback?: React.ReactNode }) => {
|
||||
calls.push({ key, ...(opts?.entryKey !== undefined ? { entryKey: opts.entryKey } : {}) })
|
||||
return opts?.fallback ?? null
|
||||
}) as unknown as ChatViewSlotProps['renderSlot'])
|
||||
})
|
||||
render(<h.ChatView {...h.props} />)
|
||||
// Keyed dispatch: slot name is the declared hole, entryKey the wire tool
|
||||
// name, and the fallback (GenericToolCard) renders on an empty ledger.
|
||||
@@ -306,10 +307,10 @@ describe('ChatView', () => {
|
||||
// Arm the paging anchor, then deliver an older page (head seq decreases).
|
||||
fireEvent.click(view.getByText('加载更早'))
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1600, writable: true })
|
||||
act(() => h.set({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a')] }))
|
||||
act(() => { h.set({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a')] }) })
|
||||
expect(scroller.scrollTop).toBe(600) // 0 + (1600 - 1000)
|
||||
// A new trailing user bubble (own words) force-scrolls to the bottom.
|
||||
act(() => h.set({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a'), user(9, 'mine')] }))
|
||||
act(() => { h.set({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a'), user(9, 'mine')] }) })
|
||||
expect(scroller.scrollTop).toBe(1600)
|
||||
})
|
||||
|
||||
@@ -324,7 +325,7 @@ describe('ChatView', () => {
|
||||
const backButton = view.getByLabelText('回到底部')
|
||||
expect(backButton).toBeTruthy()
|
||||
// Streaming growth must NOT drag a scrolled-away reader down.
|
||||
act(() => h.set({ partial: { turn: 1, step: 1, blocks: [{ kind: 'text', text: 'grow' }] } }))
|
||||
act(() => { h.set({ partial: { turn: 1, step: 1, blocks: [{ kind: 'text', text: 'grow' }] } }) })
|
||||
expect(scroller.scrollTop).toBe(100)
|
||||
fireEvent.click(backButton)
|
||||
expect(scroller.scrollTop).toBe(1000)
|
||||
@@ -337,7 +338,7 @@ describe('ChatView', () => {
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
fireEvent.click(view.getByText('加载更早'))
|
||||
expect(h.loadOlder).toHaveBeenCalledTimes(1)
|
||||
act(() => h.set({ loadingOlder: true }))
|
||||
act(() => { h.set({ loadingOlder: true }) })
|
||||
expect(view.getByText('加载中…')).toBeTruthy()
|
||||
})
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ afterEach(cleanup)
|
||||
|
||||
describe('tails', () => {
|
||||
it('node-half apply is an intentional no-op', () => {
|
||||
expect(nodeApply()).toBeUndefined()
|
||||
expect(() => { nodeApply() }).not.toThrow()
|
||||
})
|
||||
|
||||
it('ToolRow stopped state renders the warning dot in the leading slot', () => {
|
||||
@@ -83,7 +83,7 @@ describe('tails', () => {
|
||||
byId: { [sid]: { id: sid, title: 'r', displayTitle: 'r', running: false, blank: false, updatedAt: 0 } },
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
} as SessionListState)
|
||||
})
|
||||
const props = (block: RunningToolCall | ToolResultNode) => ({
|
||||
callId: 'c1', toolName: 'bash', block, openDetails: vi.fn(),
|
||||
sessionId: sid, useSessions: bindSnapshotSelector(list),
|
||||
|
||||
@@ -21,7 +21,7 @@ function snapshotBase(): ConversationSnapshot {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
||||
} as ConversationSnapshot
|
||||
}
|
||||
}
|
||||
|
||||
describe('render branch tails', () => {
|
||||
@@ -73,11 +73,11 @@ describe('render branch tails', () => {
|
||||
const view = render(
|
||||
<DetailsPanel
|
||||
sessionId={SID}
|
||||
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} }) as unknown as UseSession<ConversationSnapshot>}
|
||||
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} })}
|
||||
useSessions={bindSnapshotSelector(emptyList)}
|
||||
useWorkspaces={bindSnapshotSelector(emptyWorkspaces)}
|
||||
useInput={(() => { throw new Error('unused') }) as never}
|
||||
inputActions={{ setDraft: () => {}, submit: () => {} } as never}
|
||||
useInput={(() => { throw new Error('unused') })}
|
||||
inputActions={{ setDraft: () => {}, submit: () => {} }}
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
closeDetails={vi.fn()}
|
||||
@@ -108,11 +108,11 @@ describe('render branch tails', () => {
|
||||
const view = render(
|
||||
<DetailsPanel
|
||||
sessionId={SID}
|
||||
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} }) as unknown as UseSession<ConversationSnapshot>}
|
||||
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} })}
|
||||
useSessions={bindSnapshotSelector(emptyList)}
|
||||
useWorkspaces={bindSnapshotSelector(emptyWorkspaces)}
|
||||
useInput={(() => { throw new Error('unused') }) as never}
|
||||
inputActions={{ setDraft: () => {}, submit: () => {} } as never}
|
||||
useInput={(() => { throw new Error('unused') })}
|
||||
inputActions={{ setDraft: () => {}, submit: () => {} }}
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
closeDetails={vi.fn()}
|
||||
|
||||
@@ -79,11 +79,11 @@ function bench(over?: BenchOptions) {
|
||||
useSession: bindSnapshotSelector(session),
|
||||
useSessions: bindSnapshotSelector(createSnapshotStore({
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready',
|
||||
})) as InputBarProps['useSessions'],
|
||||
})),
|
||||
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
|
||||
items: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
})) as InputBarProps['useWorkspaces'],
|
||||
})),
|
||||
useInput: bindSnapshotSelector(shell.state),
|
||||
inputActions: shell.actions,
|
||||
keyboard: shell,
|
||||
@@ -212,7 +212,7 @@ describe('running and lock semantics (queue cut 1)', () => {
|
||||
const { textarea, wiring } = bench()
|
||||
fireEvent.change(textarea, { target: { value: 'typed' } })
|
||||
expect(wiring.state.getSnapshot().draft).toBe('typed')
|
||||
expect((textarea as HTMLTextAreaElement).value).toBe('typed')
|
||||
expect((textarea).value).toBe('typed')
|
||||
})
|
||||
|
||||
it('disabled state shows the unavailable placeholder; custom placeholder wins', () => {
|
||||
@@ -364,10 +364,10 @@ describe('placeholder chrome and control seats', () => {
|
||||
expect(view.getByTestId('plan-entry')).toBeTruthy()
|
||||
expect(view.getByTestId('model-entry')).toBeTruthy()
|
||||
// The bar hands its chrome disable state to the filling entry.
|
||||
expect(slotCalls.every(c => (c.owner as { locked: boolean }).locked === true)).toBe(true)
|
||||
expect(slotCalls.every(c => (c.owner as { locked: boolean }).locked)).toBe(true)
|
||||
cleanup()
|
||||
const live = bench({ running: true })
|
||||
expect(live.slotCalls.every(c => (c.owner as { locked: boolean }).locked === false)).toBe(true)
|
||||
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)', () => {
|
||||
|
||||
@@ -34,11 +34,11 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
|
||||
useSession: bindSnapshotSelector(session),
|
||||
useSessions: bindSnapshotSelector(createSnapshotStore({
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready',
|
||||
})) as InputBarProps['useSessions'],
|
||||
})),
|
||||
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
|
||||
items: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
})) as InputBarProps['useWorkspaces'],
|
||||
})),
|
||||
useInput: bindSnapshotSelector(shell.state),
|
||||
inputActions: shell.actions,
|
||||
keyboard: shell,
|
||||
@@ -88,7 +88,7 @@ describe('matrix row: claimed', () => {
|
||||
expect(shell.snapshot.claim).toEqual({ token: '/goal ', hint: '目标' })
|
||||
expect(view.container.querySelector('[data-decoration="token"]')?.textContent).toBe('/goal ')
|
||||
expect(view.container.querySelector('[data-decoration="hint"]')?.textContent).toBe('目标')
|
||||
expect((textarea as HTMLTextAreaElement).readOnly).toBe(false)
|
||||
expect((textarea).readOnly).toBe(false)
|
||||
// Free editing beyond the token: hint drops, claim holds.
|
||||
fireEvent.change(textarea, { target: { value: '/goal 发布版本' } })
|
||||
expect(shell.snapshot.phase).toBe('claimed')
|
||||
@@ -104,7 +104,7 @@ describe('matrix row: claimed', () => {
|
||||
expect(sink).not.toHaveBeenCalled()
|
||||
await vi.waitFor(() => { expect(submit).toHaveBeenCalledWith('发布', SCTX) })
|
||||
// Commit: draft cleared, notice surfaced, back to plain.
|
||||
await vi.waitFor(() => { expect((textarea as HTMLTextAreaElement).value).toBe('') })
|
||||
await vi.waitFor(() => { expect((textarea).value).toBe('') })
|
||||
expect(view.getByText('完成')).toBeTruthy()
|
||||
})
|
||||
|
||||
@@ -126,7 +126,7 @@ describe('matrix row: submitting', () => {
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||
expect(shell.snapshot.phase).toBe('submitting')
|
||||
expect(shell.snapshot.claim).toBeDefined()
|
||||
expect((textarea as HTMLTextAreaElement).readOnly).toBe(true)
|
||||
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) })
|
||||
@@ -145,7 +145,7 @@ describe('matrix row: submitting', () => {
|
||||
await vi.waitFor(() => { expect(submit).toHaveBeenCalled() })
|
||||
act(() => { rejectSubmit(new Error('执行失败')) })
|
||||
await vi.waitFor(() => { expect(first.shell.snapshot.phase).toBe('claimed') })
|
||||
expect((first.textarea as HTMLTextAreaElement).value).toBe('/goal ')
|
||||
expect((first.textarea).value).toBe('/goal ')
|
||||
expect(first.view.getByText('执行失败')).toBeTruthy()
|
||||
cleanup()
|
||||
// Drift: typing during flight wins; no restore, plain, notice only.
|
||||
@@ -157,7 +157,7 @@ describe('matrix row: submitting', () => {
|
||||
act(() => { second.shell.setDraft('用户飞行中打的新稿') })
|
||||
act(() => { rejectSubmit(new Error('晚到失败')) })
|
||||
await vi.waitFor(() => { expect(second.shell.snapshot.phase).toBe('plain') })
|
||||
expect((second.textarea as HTMLTextAreaElement).value).toBe('用户飞行中打的新稿')
|
||||
expect((second.textarea).value).toBe('用户飞行中打的新稿')
|
||||
expect(second.view.getByText('晚到失败')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -165,14 +165,14 @@ describe('matrix row: submitting', () => {
|
||||
describe('matrix row: locked (session disabled)', () => {
|
||||
it('disables the textarea and chrome; the machine currency is untouched', () => {
|
||||
const { view, textarea, shell } = bench({ disabled: true })
|
||||
expect((textarea as HTMLTextAreaElement).disabled).toBe(true)
|
||||
expect((textarea).disabled).toBe(true)
|
||||
expect((view.getByLabelText('Add attachment') as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(shell.snapshot.phase).toBe('plain')
|
||||
})
|
||||
|
||||
it('running does NOT lock (queue cut 1): typing and enter-queue stay live', () => {
|
||||
const { textarea, sink } = bench({ running: true })
|
||||
expect((textarea as HTMLTextAreaElement).disabled).toBe(false)
|
||||
expect((textarea).disabled).toBe(false)
|
||||
fireEvent.change(textarea, { target: { value: '排队' } })
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||
expect(sink).toHaveBeenCalledWith('排队', 'queue')
|
||||
|
||||
@@ -12,7 +12,6 @@ import { Context } from 'cordis'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import type { ClientSessionContext, CommandClaim, PickOutcome, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import { FakeApiClient, ok } from '../../runtime/tests/fake-api.ts'
|
||||
@@ -100,7 +99,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
|
||||
await ctx.plugin(SlashService).await()
|
||||
const slash = ctx.get('slash') as SlashService
|
||||
register?.(slash)
|
||||
const actx = sessions.scope(sessionId)! as ClientContext
|
||||
const actx = sessions.scope(sessionId)!
|
||||
const controller = slash.sessionOf(actx)
|
||||
const sink = vi.fn()
|
||||
const shell = new SessionInputShell({ actx, slash: () => controller, defaultSink: sink })
|
||||
@@ -121,11 +120,11 @@ async function scopedBench(register?: (slash: SlashService) => void) {
|
||||
useSession: bindSnapshotSelector(sessionStore),
|
||||
useSessions: bindSnapshotSelector(createSnapshotStore({
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready',
|
||||
})) as InputBarProps['useSessions'],
|
||||
})),
|
||||
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
|
||||
items: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
})) as InputBarProps['useWorkspaces'],
|
||||
})),
|
||||
useInput: bindSnapshotSelector(shell.state),
|
||||
inputActions: shell.actions,
|
||||
keyboard: shell,
|
||||
@@ -134,7 +133,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
|
||||
variant: 'composer',
|
||||
}
|
||||
const view = render(<InputBar {...barProps} />)
|
||||
const textarea = view.container.querySelector('textarea')! as HTMLTextAreaElement
|
||||
const textarea = view.container.querySelector('textarea')!
|
||||
const type = (text: string): void => {
|
||||
fireEvent.change(textarea, { target: { value: text } })
|
||||
}
|
||||
@@ -145,7 +144,7 @@ async function bench(executeImpl?: (line: string) => Promise<SubmitOutcome>) {
|
||||
const execute = vi.fn(executeImpl ?? ((line: string) =>
|
||||
Promise.resolve({ kind: 'success' as const, text: `已执行 ${line}` })))
|
||||
const { source, executed } = commandSource(COMMANDS, execute)
|
||||
const base = await scopedBench((slash) => { slash.registerSource(source as never) })
|
||||
const base = await scopedBench((slash) => { slash.registerSource(source) })
|
||||
return { ...base, execute, executed }
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// hero (blank session) and active phases — same textarea DOM node, machine-
|
||||
// owned draft, and the hero workspace picker (switching = retargetWorkspace).
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
@@ -55,7 +55,11 @@ function conversationSnapshot(overrides: Partial<ConversationSnapshot> = {}): Co
|
||||
}
|
||||
}
|
||||
|
||||
function mount(snapshot: ConversationSnapshot, workspaceRows: WorkspaceView[] = [{ ...workspace('one'), sessionIds: [SID] }]) {
|
||||
function mount(
|
||||
snapshot: ConversationSnapshot,
|
||||
workspaceRows: WorkspaceView[] = [{ ...workspace('one'), sessionIds: [SID] }],
|
||||
retargetWorkspace = vi.fn(async (_workspaceId: WorkspaceId) => {}),
|
||||
) {
|
||||
const root = sid('root')
|
||||
const sessions = createSnapshotStore<SessionListState>({
|
||||
ids: [root, SID],
|
||||
@@ -76,7 +80,6 @@ function mount(snapshot: ConversationSnapshot, workspaceRows: WorkspaceView[] =
|
||||
const inputActions = wiring.actions
|
||||
const stop = vi.fn()
|
||||
const open = vi.fn()
|
||||
const retargetWorkspace = vi.fn()
|
||||
const slotCalls: string[] = []
|
||||
let pickerOwner: unknown
|
||||
const renderSlot = ((key: string, owner: object, opts?: { only?: string }) => {
|
||||
@@ -158,7 +161,13 @@ describe('ConversationRoot resident composer', () => {
|
||||
})
|
||||
|
||||
it('hero phase: same textarea, hero chrome, no header, picker switches the workspace', () => {
|
||||
const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true }))
|
||||
const b = mount(
|
||||
conversationSnapshot({ composerPhase: 'blank', blank: true }),
|
||||
[
|
||||
{ ...workspace('one'), sessionIds: [SID] },
|
||||
{ ...workspace('second'), title: 'Selected Folder' },
|
||||
],
|
||||
)
|
||||
// Hero chrome present, view ring absent.
|
||||
expect(b.view.getByText("Let's start building")).toBeTruthy()
|
||||
expect(b.view.queryByTestId('view-chat')).toBeNull()
|
||||
@@ -173,8 +182,9 @@ describe('ConversationRoot resident composer', () => {
|
||||
fireEvent.click(b.view.getByRole('button', { name: 'Choose workspace' }))
|
||||
const owner = b.pickerOwner() as { open: boolean; onPick(id: WorkspaceId): void }
|
||||
expect(owner.open).toBe(true)
|
||||
owner.onPick(wid('second'))
|
||||
act(() => { owner.onPick(wid('second')) })
|
||||
expect(b.retargetWorkspace).toHaveBeenCalledWith(wid('second'))
|
||||
expect(b.view.getByText('Selected Folder')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('textarea DOM identity survives the hero → active flip', () => {
|
||||
@@ -191,6 +201,24 @@ describe('ConversationRoot resident composer', () => {
|
||||
expect(b.view.getByTestId('view-chat')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('rolls the pending workspace label back when switching fails', async () => {
|
||||
const selectWorkspace = vi.fn(async () => { throw new Error('connect failed') })
|
||||
const b = mount(
|
||||
conversationSnapshot({ composerPhase: 'blank', blank: true }),
|
||||
[
|
||||
{ ...workspace('one'), sessionIds: [SID] },
|
||||
{ ...workspace('second'), title: 'Selected Folder' },
|
||||
],
|
||||
selectWorkspace,
|
||||
)
|
||||
fireEvent.click(b.view.getByRole('button', { name: 'Choose workspace' }))
|
||||
const owner = b.pickerOwner() as { onPick(id: WorkspaceId): void }
|
||||
await act(async () => { owner.onPick(wid('second')); await Promise.resolve() })
|
||||
expect(selectWorkspace).toHaveBeenCalledWith(wid('second'))
|
||||
expect(b.view.queryByText('Selected Folder')).toBeNull()
|
||||
expect(b.view.getByText('one')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('blank session keeps the interactive picker chip (workspace switchable until the first message)', () => {
|
||||
const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true }))
|
||||
const chip = b.view.getByRole('button', { name: 'Choose workspace' })
|
||||
|
||||
Reference in New Issue
Block a user