Merge remote-tracking branch 'origin/master' into worktree/web-background-tasks-display-258f7e
# Conflicts: # docs/subsystems/tasks.i18n.yaml # docs/subsystems/tasks.md # docs/subsystems/tasks.zh.md # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/README.md # packages/host/apiproxy/README.zh.md # packages/host/apiproxy/src/api-proxy.ts # packages/tasks/tasks-local/src/index.ts # packages/tasks/tasks/README.i18n.yaml # packages/tasks/tasks/README.md # packages/tasks/tasks/README.zh.md # packages/tasks/tasks/src/index.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: f684f99c9e80a02e3ccad57c9a4d7246df0b192b
|
||||
README.zh.md: 61c746e35d3a221d34cf9e830a63ce5d8fa0dbfd
|
||||
README.md: 7c4855a75abb982ff55b903808d6a65c42cbc91c
|
||||
README.zh.md: c3a5d7beb2e90289f4340fc251fd3527370e3e23
|
||||
|
||||
@@ -18,7 +18,7 @@ Approvals take over the composer through the chain this package declares: `Appro
|
||||
|
||||
The session header declares and renders the session-scoped `'conversation.session.header.actions'` list beside the title, allowing feature plugins to contribute controls without entering the skeleton. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership.
|
||||
|
||||
Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A source that names no producer shows the role alone. The shared `DisclosureRow` primitive gives this context surface the same compact geometry as other flow rows while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap and synthesizes no tool state or summary ([historical disclosure decision](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md), [producer-label decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). That body follows the form the producer declared on its durable source: `instructions` names the reconciled files above their text, `catalog` lists the entries the source recorded instead of the model-facing prose, and every other value — absent, unknown to this version, or carrying no usable fields — renders the opaque body, which shows the model-facing text with its real line breaks and the remaining source fields. The opaque body is the documented default, not a leftover: a resumed, forked, or foreign log must render whether or not its producer is mounted here. A durable or pending steering bubble carries an `插话` / `Interjection` caption above it, the only thing distinguishing a mid-turn interjection from the turn-opening prompt that shares its bubble.
|
||||
Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A source that names no producer shows the role alone. The shared `DisclosureRow` primitive gives this context surface the same compact geometry as other flow rows while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap and synthesizes no tool state or summary ([historical disclosure decision](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md), [producer-label decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). That body follows the form the producer declared on its durable source: `instructions` names the reconciled files above their text, `catalog` lists the entries the source recorded instead of the model-facing prose, and every other value — absent, unknown to this version, or carrying no usable fields — renders the opaque body, which shows the model-facing text with its real line breaks and the remaining source fields. The opaque body is the documented default, not a leftover: a resumed, forked, or foreign log must render whether or not its producer is mounted here. A durable or pending steering bubble shares the user bubble's presentation unadorned; its mid-turn position in the flow is the only steering signal the transcript shows.
|
||||
|
||||
A Think row stays collapsed by default and exposes live reasoning throughput without expanding the chain of thought: while its reasoning block is the streaming tail, the summary switches from the settled first line to the latest non-blank line and its one-line scrollport follows each delta to the inline end. Expanding the row removes the moving summary and leaves the full reasoning in ordinary page flow, so page reading never fights an internal follower; settlement restores the stable first-line summary at the left edge ([decision](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md)).
|
||||
|
||||
@@ -32,7 +32,7 @@ The chat flow projects consecutive model-retry nodes across retry turns into one
|
||||
|
||||
The Host's placement-aware `session/queue` snapshot also carries pending steering. QueueDock filters it out, while ChatView projects it as a user-style bubble with Copy at the conversation tail; non-user next-step items (injected context) carry the `context` placement instead and render nowhere until claimed. Fork is absent here as on every user-style bubble. The Host delays steering retirement until the durable `user/message` carrying the steering has entered the mux stream. On that accepted live event, the client runtime retires the first matching current steering occurrence before publishing the snapshot; historical events cannot hide later occurrences that reuse the same `MessageId`. The bubble therefore hands off without a gap or duplicate, immediately restores Copy and the clock from the durable node — a steering bubble, like a user bubble, carries no branch action ([decision](../../../.agents/notes/implemented/simplification/2026-08-06-user-bubbles-drop-the-branch-action.md)) — and survives reconnect from the same authority.
|
||||
|
||||
Keyboard message submission resolves delivery from the addressed session's running state and steering capability. While idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While a primary session is running, the browser-persisted General Settings preference assigns plain Enter to `Queue` (the default) or `Steer`, and Cmd/Ctrl+Enter performs the other behavior; Shift+Enter remains a newline. With an empty draft, Cmd/Ctrl+Enter instead steers every still-pending queued message into the running turn in FIFO order (the dock's per-row strict-steer action applied to the whole queue); plain Enter with an empty draft remains a no-op. While this whole-queue gesture is available, the textarea placeholder advertises it; a placeholder supplied by the owning surface still takes precedence. Addressed subagents keep both gestures on their Queue-only continuation transport even while running. The preference affects only the steer-capable busy-state gesture pair, and the send button and non-keyboard submit actions remain Queue. Composer Steer uses the existing best-effort `session.prompt(mode: 'steer')` contract: if the current next-step window closes before acceptance, AgentLoop admits the message as the next waking Queue turn without surfacing a failure or losing the draft transaction.
|
||||
Keyboard message submission resolves delivery from the addressed session's running state and steering capability. While idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While a primary session is running, the Host-backed `ui-conversation.busyEnter` General Settings preference assigns plain Enter to `Queue` (the default) or `Steer`, and Cmd/Ctrl+Enter performs the other behavior; the local settings provider stores it in `$DSH_HOME/settings.yaml`, so the choice follows the same user home across Web ports. Shift+Enter remains a newline. With an empty draft, Cmd/Ctrl+Enter instead steers every still-pending queued message into the running turn in FIFO order (the dock's per-row strict-steer action applied to the whole queue); plain Enter with an empty draft remains a no-op. While this whole-queue gesture is available, the textarea placeholder advertises it; a placeholder supplied by the owning surface still takes precedence. Addressed subagents keep both gestures on their Queue-only continuation transport even while running. The preference affects only the steer-capable busy-state gesture pair, and the send button and non-keyboard submit actions remain Queue. Composer Steer uses the existing best-effort `session.prompt(mode: 'steer')` contract: if the current next-step window closes before acceptance, AgentLoop admits the message as the next waking Queue turn without surfacing a failure or losing the draft transaction. The [Host-backed preferences decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md) owns the persistence boundary.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ Chat 业务行是彼此独立的注册表贡献,不是封闭的内建联合。
|
||||
|
||||
会话页头会在标题旁声明并渲染 Session scope 的 `'conversation.session.header.actions'` 列表,使功能插件无需进入骨架即可贡献控件。编辑器链的 currency 包含当前对话 `session`;ui-subagent 会选取 one-shot 或 parent 不可用的已寻址会话,并按原因显示只读文案,而普通 InputBar 会让所有已寻址 child 仅保留 Send,因为继续执行服务不公开逐 Activation 取消操作,`session.cancel` 也会绕过其所有权。
|
||||
|
||||
已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill(技能)目录、工作区指令文件与被召回的会话。来源未提供生产者名称时只显示角色。共享的 `DisclosureRow` 原子组件让该上下文界面与消息流中的其他紧凑行保持相同几何,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,且不会合成工具状态或摘要([历史展开项决策](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md)、[生产者标签决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。该内容区按生产方在持久来源上声明的形态渲染:`instructions` 在正文之上列出它对账过的文件,`catalog` 列出来源记录的条目而非面向模型的散文,其余取值——未声明、本版本不认识、或字段不可用——一律渲染 opaque 内容区,即按真实换行展示面向模型的文本,并把剩余来源字段列出。opaque 不是兜底剩余物而是有文档的默认:恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处,都必须渲染得出来。持久或待处理的 steering(中途引导)气泡上方带有 `插话` / `Interjection` 标注,这是把中途插话与共用同一气泡的开轮提示区分开的唯一标识。
|
||||
已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill(技能)目录、工作区指令文件与被召回的会话。来源未提供生产者名称时只显示角色。共享的 `DisclosureRow` 原子组件让该上下文界面与消息流中的其他紧凑行保持相同几何,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,且不会合成工具状态或摘要([历史展开项决策](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md)、[生产者标签决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。该内容区按生产方在持久来源上声明的形态渲染:`instructions` 在正文之上列出它对账过的文件,`catalog` 列出来源记录的条目而非面向模型的散文,其余取值——未声明、本版本不认识、或字段不可用——一律渲染 opaque 内容区,即按真实换行展示面向模型的文本,并把剩余来源字段列出。opaque 不是兜底剩余物而是有文档的默认:恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处,都必须渲染得出来。持久或待处理的 steering(中途引导)气泡沿用用户气泡的呈现,不加任何装饰;transcript 中唯一的 steering 信号是它出现在轮次中途的位置。
|
||||
|
||||
Think 行默认保持折叠,并在不展开思维链的情况下暴露实时推理(reasoning)吞吐:当推理块是流式输出尾部时,摘要从结算后的首行切换到最新的非空行,其单行滚动区会随每个 delta 追到行内末端。展开该行会移除移动摘要,让完整推理进入普通页面流,因此页面阅读不会与内部跟随器争夺滚动;结算后恢复左对齐的稳定首行摘要([决策](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md))。
|
||||
|
||||
@@ -32,7 +32,7 @@ Think 行默认保持折叠,并在不展开思维链的情况下暴露实时
|
||||
|
||||
Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。QueueDock 会将其过滤掉,ChatView 则把它投影为会话流末尾带复制操作的用户样式气泡;非用户来源的 next-step 项(注入上下文)改以 `context` placement 广播,领取前不在任何界面渲染。与所有用户样式气泡一样,这里不显示 fork。Host 会等携带该 steering 的持久 `user/message` 进入 mux 流之后再退役 steering。客户端运行时接纳该实时事件时,会在发布快照前退役第一个匹配的当前 steering 单次入队项;历史事件无法隐藏后来复用同一 `MessageId` 的单次入队项。气泡交接时因而不会产生空档或重复,会立即从持久节点恢复复制操作与时钟——steering 气泡与 user 气泡一样不带分支操作([决策](../../../.agents/notes/implemented/simplification/2026-08-06-user-bubbles-drop-the-branch-action.md))——并能在重连后从同一权威恢复。
|
||||
|
||||
键盘消息提交会根据所寻址会话的运行状态和 steering 能力解析投递方式。空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,浏览器持久化的 General Settings 偏好会把普通 Enter 分配为 `Queue`(默认值)或 `Steer`,Cmd/Ctrl+Enter 则执行另一种行为;Shift+Enter 仍然换行。草稿为空时,Cmd/Ctrl+Enter 改为按 FIFO 顺序把仍在排队的消息全部插话进运行中的轮次(把 dock 的逐条严格 steer 操作应用于整个队列);空草稿 + 普通 Enter 仍是无操作。这个整队列手势可用时,文本框 placeholder 会提示该手势;owner 提供的 placeholder 仍然优先。已寻址 subagent 即使正在运行,也会让这两个手势都使用其仅支持 Queue 的继续执行传输。该偏好只影响支持 steering 的繁忙态手势对,发送按钮与非键盘提交操作仍使用 Queue。Composer Steer 复用现有尽力而为的 `session.prompt(mode: 'steer')` 契约:如果当前 next-step 窗口在接纳前关闭,AgentLoop 会把消息接纳为下一条唤醒 Queue 轮次,不显示失败,也不会丢失草稿事务。
|
||||
键盘消息提交会根据所寻址会话的运行状态和 steering 能力解析投递方式。空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,由 Host settings 支撑的 `ui-conversation.busyEnter` General Settings 偏好会把普通 Enter 分配为 `Queue`(默认值)或 `Steer`,Cmd/Ctrl+Enter 则执行另一种行为;本地 settings 提供方将其存入 `$DSH_HOME/settings.yaml`,因此该选择会跟随同一个用户 home 跨越 Web 端口。Shift+Enter 仍然换行。草稿为空时,Cmd/Ctrl+Enter 改为按 FIFO 顺序把仍在排队的消息全部插话进运行中的轮次(把 dock 的逐条严格 steer 操作应用于整个队列);空草稿 + 普通 Enter 仍是无操作。这个整队列手势可用时,文本框 placeholder 会提示该手势;owner 提供的 placeholder 仍然优先。已寻址 subagent 即使正在运行,也会让这两个手势都使用其仅支持 Queue 的继续执行传输。该偏好只影响支持 steering 的繁忙态手势对,发送按钮与非键盘提交操作仍使用 Queue。Composer Steer 复用现有尽力而为的 `session.prompt(mode: 'steer')` 约定:如果当前 next-step 窗口在接纳前关闭,AgentLoop 会把消息接纳为下一条唤醒 Queue 轮次,不显示失败,也不会丢失草稿事务。该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md)拥有。
|
||||
|
||||
逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。
|
||||
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-conversation",
|
||||
"description": "Conversation domain: skeleton, ordered chat flow, composer, and details host",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"description": "Conversation domain: skeleton, ordered chat flow, composer with the Host-backed busy-Enter preference, and details host",
|
||||
"version": "0.0.1-rc.1",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/client/ui-conversation"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
@@ -22,13 +29,16 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-layout"
|
||||
],
|
||||
"platform": "web"
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-layout"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
@@ -36,26 +46,34 @@
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"clsx": "^2.0.0"
|
||||
"@deepseek-ai/dsh-settings": "workspace:^",
|
||||
"clsx": "^2.0.0",
|
||||
"@deepseek-ai/schemastery": "workspace:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-locale": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-slash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
|
||||
"@deepseek-ai/dsh-compact": "^0.0.1",
|
||||
"@deepseek-ai/dsh-commands": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm-retry": "^0.0.1",
|
||||
"@deepseek-ai/dsh-token-meter": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-attachment": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-compact": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-retry": "workspace:^",
|
||||
"@deepseek-ai/dsh-token-meter": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-attachment": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
|
||||
@@ -75,7 +93,7 @@
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-todo": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"files": [
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/** Registers the conversation components, shared store, and service callbacks. */
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { resolveSlotLabel, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { resolveWorkspacePath, type ISessions, type SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
bindSettingsScope, resolveWorkspacePath, type ISessions, type SessionId,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
@@ -13,7 +15,7 @@ import type {
|
||||
} from './contract/slots.ts'
|
||||
import type { InputNotice } from './input/contract.ts'
|
||||
import { createChatStore } from './stores.ts'
|
||||
import { ConversationService } from './service.ts'
|
||||
import { ConversationService, UnsupportedImageMediaTypeError } from './service.ts'
|
||||
import type { IConversation } from './service.ts'
|
||||
import { ComposerBlockRegistry } from './input/blocks.ts'
|
||||
import type { ComposerBlock } from './input/blocks.ts'
|
||||
@@ -33,6 +35,7 @@ import { DetailsPanel } from './skeleton/DetailsPanel.tsx'
|
||||
import { en, NS, zh, type ConversationKey } from './locales.ts'
|
||||
import { registerConversationNodes } from './conversation-nodes/register.ts'
|
||||
import { registerChatNodeRenderers } from './chat/register-node-renderers.ts'
|
||||
import { CONVERSATION_SETTINGS_NAMESPACE, type ConversationSettings } from '../submission-settings.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap {
|
||||
@@ -43,7 +46,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
|
||||
/** Services required by the conversation plugin. */
|
||||
export const inject = [
|
||||
'slots', 'layout', 'sessions', 'workspaces', 'locale',
|
||||
'slots', 'layout', 'sessions', 'workspaces', 'locale', 'connection',
|
||||
'conversationEvents', 'conversationViews',
|
||||
]
|
||||
|
||||
@@ -91,6 +94,13 @@ function scopedConversation(sessions: ISessions, id: SessionId): IConversation {
|
||||
return conversation
|
||||
}
|
||||
|
||||
/** Resolve package-internal attachment operations from the public service registration. */
|
||||
function concreteConversation(ctx: Context): ConversationService {
|
||||
const conversation = ctx.get('conversation') as ConversationService | undefined
|
||||
if (conversation === undefined) throw new Error('ui-conversation: conversation service unavailable')
|
||||
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
|
||||
@@ -117,7 +127,9 @@ export function apply(ctx: Context): void {
|
||||
|
||||
// Apply-time construction keeps store identity bound to this fiber.
|
||||
const chatStore = createChatStore()
|
||||
const submissionPolicy = new ComposerSubmissionPolicy()
|
||||
const submissionPolicy = new ComposerSubmissionPolicy(
|
||||
bindSettingsScope<ConversationSettings>(ctx, { namespace: CONVERSATION_SETTINGS_NAMESPACE }),
|
||||
)
|
||||
|
||||
ctx.slots.inject('settings.general.item', () => ctx.slots.register({
|
||||
name: 'settings.general.item',
|
||||
@@ -201,9 +213,16 @@ export function apply(ctx: Context): void {
|
||||
if (sessionId !== undefined && nextId !== sessionId) {
|
||||
const from = inputHub.shell(sessionId)
|
||||
const draft = from.snapshot.draft
|
||||
if (draft !== '') {
|
||||
inputHub.shell(nextId).setDraft(draft)
|
||||
from.setDraft('')
|
||||
const imageIds = from.snapshot.imageIds
|
||||
const next = inputHub.shell(nextId)
|
||||
if (imageIds.length === 0 || next.addImages(imageIds)) {
|
||||
if (draft !== '') {
|
||||
next.setDraft(draft)
|
||||
from.setDraft('')
|
||||
}
|
||||
if (imageIds.length > 0) {
|
||||
for (const id of imageIds) from.removeImage(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
sessions.open(nextId)
|
||||
@@ -220,10 +239,14 @@ export function apply(ctx: Context): void {
|
||||
'conversation.view': { kind: 'list', scope: 'session' },
|
||||
},
|
||||
store: chatStore,
|
||||
inject: (sessionId: SessionId, _actions: BoundActions<typeof chatStore>): ConversationSessionInjected => ({
|
||||
views,
|
||||
bindDraftMirror: write => inputHub.shell(sessionId).bindMirror(write),
|
||||
}),
|
||||
inject: (sessionId: SessionId, _actions: BoundActions<typeof chatStore>): ConversationSessionInjected => {
|
||||
const conversation = concreteConversation(ctx)
|
||||
return {
|
||||
views,
|
||||
releaseSessionImages: (id) => { conversation.releaseSessionImages(id) },
|
||||
bindDraftMirror: write => inputHub.shell(sessionId).bindMirror(write),
|
||||
}
|
||||
},
|
||||
}, ConversationSession)
|
||||
|
||||
// Header chrome sits above the resident scrollport but shares the same
|
||||
@@ -262,6 +285,9 @@ export function apply(ctx: Context): void {
|
||||
if (sessionId === undefined) {
|
||||
return {
|
||||
keyboard: undefined,
|
||||
addImages: undefined,
|
||||
removeImage: undefined,
|
||||
draftImages: undefined,
|
||||
resolveSubmitMode: (running, gesture, steeringAvailable) =>
|
||||
submissionPolicy.resolve(running, gesture, steeringAvailable),
|
||||
toggleCommandMenu: undefined,
|
||||
@@ -270,10 +296,32 @@ export function apply(ctx: Context): void {
|
||||
hooks: { notices: ABSENT_NOTICES, lexicon: ABSENT_LEXICON, menuLauncher: ABSENT_MENU_LAUNCHER },
|
||||
}
|
||||
}
|
||||
const conversation = concreteConversation(ctx)
|
||||
const shell = inputHub.shell(sessionId)
|
||||
const slash = inputHub.slash(sessionId)
|
||||
return {
|
||||
keyboard: shell,
|
||||
addImages: (files) => {
|
||||
try {
|
||||
const images = conversation.createDraftImages(files)
|
||||
if (!shell.addImages(images.map(image => image.id))) {
|
||||
conversation.releaseDraftImages(images)
|
||||
}
|
||||
return null
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof UnsupportedImageMediaTypeError) {
|
||||
return t('image.unsupportedType', {
|
||||
type: error.mediaType || t('image.unknownType'),
|
||||
})
|
||||
}
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
},
|
||||
removeImage: (id) => {
|
||||
conversation.releaseDraftImage(id)
|
||||
shell.removeImage(id)
|
||||
},
|
||||
draftImages: ids => conversation.draftImages(ids),
|
||||
resolveSubmitMode: (running, gesture, steeringAvailable) =>
|
||||
submissionPolicy.resolve(running, gesture, steeringAvailable),
|
||||
toggleCommandMenu: slash === undefined
|
||||
@@ -332,6 +380,7 @@ export function apply(ctx: Context): void {
|
||||
},
|
||||
store: chatStore,
|
||||
inject: (sessionId: SessionId, actions: BoundActions<typeof chatStore>): ChatViewInjected => {
|
||||
const conversation = concreteConversation(ctx)
|
||||
const scoped = scopedConversation(sessions, sessionId)
|
||||
return {
|
||||
openDetails: (target) => {
|
||||
@@ -347,6 +396,7 @@ export function apply(ctx: Context): void {
|
||||
})
|
||||
},
|
||||
loadOlder: () => { void scoped.loadOlder() },
|
||||
loadImage: attachment => conversation.resolveImage(sessionId, attachment),
|
||||
// Unregistered 'trajectory' id is safe: the tab ring falls back to
|
||||
// the first view, and the untouched inspect target stays inert.
|
||||
inspectCall: (callId) => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import { ImageGallery, type ImageLoader } from './MessageImage.tsx'
|
||||
import { ReasoningRow } from './ReasoningRow.tsx'
|
||||
import css from './AssistantMarkdown.module.css'
|
||||
|
||||
@@ -22,6 +23,8 @@ export interface AssistantMarkdownProps {
|
||||
streaming: boolean
|
||||
/** Frozen partial of an aborted turn: rendered with a stopped marker. */
|
||||
interrupted?: boolean | undefined
|
||||
/** Session-authorized durable image loader. */
|
||||
loadImage?: ImageLoader
|
||||
/** Resolved prose file mentions for this Assistant's closing turn. */
|
||||
mentions?: MarkdownFileMentions | undefined
|
||||
/** The owning view's locale seat, passed down as a plain prop. */
|
||||
@@ -30,8 +33,9 @@ export interface AssistantMarkdownProps {
|
||||
|
||||
/** Reasoning block as the Think variant summary row (figma 39:28304). */
|
||||
export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||
blocks, streaming, interrupted, mentions, t,
|
||||
blocks, streaming, interrupted, loadImage, mentions, t,
|
||||
}: AssistantMarkdownProps) {
|
||||
const imageLoader = loadImage ?? (() => Promise.reject(new Error(t('image.serviceUnavailable'))))
|
||||
// Stable per locale revision (t identity changes on switch): a fresh object
|
||||
// per render would rebuild MarkdownText's component table every chunk.
|
||||
const codeLabels = useMemo(() => ({ copyLabel: t('copy'), copiedLabel: t('copied') }), [t])
|
||||
@@ -58,6 +62,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||
/>
|
||||
)
|
||||
case 'reasoning': return <ReasoningRow key={i} text={block.text} running={streaming && i === last} t={t} />
|
||||
case 'image': return <ImageGallery key={i} images={[block]} load={imageLoader} align="start" t={t} />
|
||||
// Grouped into tool rows by ChatView; hasVisible above skips an empty shell.
|
||||
case 'tool-call': return null
|
||||
default: return (
|
||||
|
||||
@@ -4,7 +4,7 @@ import { AssistantMarkdown } from './AssistantMarkdown.tsx'
|
||||
|
||||
/** Streaming, settled, and interrupted Assistant states share one keyed renderer instance. */
|
||||
export const AssistantNodeView = memo(function AssistantNodeView({
|
||||
node, useTurnData, openFile, fileMentions, t,
|
||||
node, useTurnData, openFile, loadImage, fileMentions, t,
|
||||
}: ChatNodeViewProps<'assistant-step'>) {
|
||||
const data = node.data
|
||||
const turn = node.location.kind === 'turn' || node.location.kind === 'step'
|
||||
@@ -25,6 +25,7 @@ export const AssistantNodeView = memo(function AssistantNodeView({
|
||||
blocks={data.blocks}
|
||||
streaming={data.status === 'running'}
|
||||
interrupted={data.status === 'interrupted'}
|
||||
loadImage={loadImage}
|
||||
mentions={mentions}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
@@ -18,7 +18,7 @@ type RoutedChatNodeOwner = {
|
||||
/** Subscribe and dispatch one stable Context key without observing sibling Nodes. */
|
||||
export const ChatNodeSeat = memo(function ChatNodeSeat({
|
||||
nodeKey, selectedCallId, cwd, openFile, inspectCall, forkAt,
|
||||
fileMentions, useSession, renderSlot, t,
|
||||
loadImage, fileMentions, useSession, renderSlot, t,
|
||||
}: ChatNodeSeatProps) {
|
||||
const node = useSession(snapshot => snapshot.chat.nodes.get(nodeKey))
|
||||
const routedNode = node as ChatNode | undefined
|
||||
@@ -30,8 +30,9 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({
|
||||
openFile,
|
||||
inspectCall,
|
||||
forkAt,
|
||||
loadImage,
|
||||
fileMentions,
|
||||
}, [node, selectedCallId, cwd, openFile, inspectCall, forkAt, fileMentions])
|
||||
}, [node, selectedCallId, cwd, openFile, inspectCall, forkAt, loadImage, fileMentions])
|
||||
if (routedNode === undefined || owner === null) return null
|
||||
// Runtime dispatch owns the correlation: every Node's discriminant is the
|
||||
// keyed-slot entry passed alongside that same Node. TypeScript does not
|
||||
|
||||
@@ -144,7 +144,7 @@ function TurnStatus({ startTime, t }: {
|
||||
* ordered business Node crosses the keyed renderer seat.
|
||||
*/
|
||||
export function ChatView({
|
||||
useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt,
|
||||
useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, loadImage, inspectCall, chatScroll, forkAt,
|
||||
fileMentions, t,
|
||||
}: ChatViewSlotProps) {
|
||||
const order = useSession(s => s.chat.order)
|
||||
@@ -389,6 +389,7 @@ export function ChatView({
|
||||
openFile={openFile}
|
||||
inspectCall={inspectCall}
|
||||
forkAt={forkAt}
|
||||
loadImage={loadImage}
|
||||
fileMentions={fileMentions}
|
||||
renderSlot={renderSlot}
|
||||
t={t}
|
||||
@@ -401,7 +402,7 @@ export function ChatView({
|
||||
wait, tool execution, streaming) so it never flickers per step. */}
|
||||
{running && <TurnStatus startTime={runningTurnStart} t={t} />}
|
||||
{pendingSteering.map(item => (
|
||||
<PendingSteeringBubble key={item.id} content={item.content} t={t} />
|
||||
<PendingSteeringBubble key={item.id} content={item.content} loadImage={loadImage} t={t} />
|
||||
))}
|
||||
</div>
|
||||
{!atBottom && (
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
.gallery {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
width: min(240px, 100%);
|
||||
}
|
||||
|
||||
.gallery[data-align='end'] {
|
||||
justify-content: flex-end;
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.gallery[data-align='start'] {
|
||||
justify-content: flex-start;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.frame {
|
||||
display: grid;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.frame img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.error {
|
||||
max-width: 240px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
|
||||
border-radius: 10px;
|
||||
background: var(--dsw-alias-interactive-bg-hover-danger);
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import { ImageLightbox } from '../skeleton/ImageLightbox.tsx'
|
||||
import css from './MessageImage.module.css'
|
||||
|
||||
/** Loads a session-authorized durable image URL. */
|
||||
export type ImageLoader = (attachment: ImageAttachmentRef) => Promise<string>
|
||||
|
||||
/** Compact history renderer with retryable loading and double-click original preview. */
|
||||
export function MessageImage({ attachment, load, t }: {
|
||||
attachment: ImageAttachmentRef
|
||||
load: ImageLoader
|
||||
t: ChatViewSlotProps['t']
|
||||
}) {
|
||||
const [src, setSrc] = useState<string | null>(null)
|
||||
const [error, setError] = useState(false)
|
||||
const [open, setOpen] = useState(false)
|
||||
const close = useCallback(() => { setOpen(false) }, [])
|
||||
const size = useMemo(() => {
|
||||
const scale = Math.min(1, 240 / attachment.width, 240 / attachment.height)
|
||||
return { width: Math.max(1, Math.round(attachment.width * scale)), height: Math.max(1, Math.round(attachment.height * scale)) }
|
||||
}, [attachment.height, attachment.width])
|
||||
|
||||
const request = useCallback(() => {
|
||||
setError(false)
|
||||
setSrc(null)
|
||||
void load(attachment).then(setSrc).catch(() => { setError(true) })
|
||||
}, [attachment, load])
|
||||
|
||||
useEffect(() => {
|
||||
let live = true
|
||||
setError(false)
|
||||
void load(attachment).then((url) => { if (live) setSrc(url) }).catch(() => { if (live) setError(true) })
|
||||
return () => { live = false }
|
||||
}, [attachment, load])
|
||||
|
||||
const label = attachment.name ?? t('image.label')
|
||||
if (error) return <button type="button" className={css.error} onClick={request}>{t('image.loadFailed')}</button>
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={css.frame}
|
||||
style={size}
|
||||
title={t('image.openOriginal')}
|
||||
aria-label={t('image.openOriginalLabel', { label })}
|
||||
onDoubleClick={() => { if (src !== null) setOpen(true) }}
|
||||
>
|
||||
{src === null ? <span className={css.loading}>{t('image.loading')}</span> : <img src={src} alt={label} />}
|
||||
</button>
|
||||
{open && src !== null && <ImageLightbox src={src} alt={label} onClose={close} t={t} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Wrapping image group shared by user and assistant history. */
|
||||
export function ImageGallery({ images, load, align, t }: {
|
||||
images: readonly { attachment: ImageAttachmentRef }[]
|
||||
load: ImageLoader
|
||||
align: 'start' | 'end'
|
||||
t: ChatViewSlotProps['t']
|
||||
}) {
|
||||
if (images.length === 0) return null
|
||||
return (
|
||||
<div className={css.gallery} data-align={align}>
|
||||
{images.map((image, index) => (
|
||||
<MessageImage key={`${image.attachment.attachmentId}:${index}`} {...image} load={load} t={t} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,18 +8,17 @@
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* Steering caption above the bubble: mid-turn interjections carry the same
|
||||
bubble as a turn-opening prompt, so the transcript names which one this is. */
|
||||
.steeringMark {
|
||||
padding-right: 4px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
.userStack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
max-width: min(525px, 82%);
|
||||
}
|
||||
|
||||
.bubble {
|
||||
/* 525px cap inside the 736 column; percentage keeps narrow windows sane. */
|
||||
max-width: min(525px, 82%);
|
||||
max-width: 100%;
|
||||
background: var(--dsw-specific-bubble);
|
||||
border-radius: 22px;
|
||||
/* 44px single-line bubble: 24 line + 10 vertical padding each side. */
|
||||
|
||||
@@ -1,30 +1,40 @@
|
||||
// MessageItem: simple chat nodes — user and consumed-steering bubbles
|
||||
// (right-aligned, with clock + copy IconActions; steering adds the
|
||||
// interjection caption that names it; branch lives only under assistant
|
||||
// answers), pending steering (caption + copy only), context injection,
|
||||
// (right-aligned, with clock + copy IconActions; branch lives only under
|
||||
// assistant answers), pending steering (copy only), context injection,
|
||||
// compaction marker, retry disclosure, and unknown-surface JSON rows.
|
||||
|
||||
import { memo, useEffect, useMemo, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type {
|
||||
ModelRetryNode, TurnErrorNode,
|
||||
ModelRetryNode, TurnErrorNode, UserMessageNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { JsonBlock, MessageText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatNodeViewProps, ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import { CompactionItem } from './CompactionItem.tsx'
|
||||
import { ContextInjectionRow } from './ContextInjectionRow.tsx'
|
||||
import { MessageIconActions } from './MessageIconActions.tsx'
|
||||
import { ImageGallery, type ImageLoader } from './MessageImage.tsx'
|
||||
import css from './MessageItem.module.css'
|
||||
|
||||
function contentText(content: readonly unknown[]): { text: string; rest: unknown[] } {
|
||||
type UserImage = Extract<UserMessageNode['content'][number], { type: 'image' }>
|
||||
|
||||
function contentParts(content: readonly unknown[]): {
|
||||
text: string
|
||||
images: { attachment: UserImage['attachment'] }[]
|
||||
rest: unknown[]
|
||||
} {
|
||||
const texts: string[] = []
|
||||
const images: { attachment: UserImage['attachment'] }[] = []
|
||||
const rest: unknown[] = []
|
||||
for (const block of content) {
|
||||
const b = block as { type?: string; text?: string }
|
||||
const b = block as { type?: string; text?: string; attachment?: unknown }
|
||||
if (b.type === 'text' && typeof b.text === 'string') texts.push(b.text)
|
||||
else if (b.type === 'image' && b.attachment !== undefined) {
|
||||
images.push({ attachment: (b as UserImage).attachment })
|
||||
}
|
||||
else rest.push(block)
|
||||
}
|
||||
return { text: texts.join(''), rest }
|
||||
return { text: texts.join(''), images, rest }
|
||||
}
|
||||
|
||||
function retrySeconds(milliseconds: number): number {
|
||||
@@ -151,25 +161,27 @@ function projectUserText(text: string): ReactNode {
|
||||
|
||||
/** Right-aligned bubble shared by user and steering rows. */
|
||||
function UserStyleBubble({
|
||||
content, actions, pending = false, steering = false, t,
|
||||
content, imageLoader, actions, pending = false, t,
|
||||
}: {
|
||||
content: readonly unknown[]
|
||||
imageLoader: ImageLoader
|
||||
/** Optional IconActions (or similar) below the bubble; receives the joined text. */
|
||||
actions?: (text: string) => ReactNode
|
||||
/** Whether this is the Host-authoritative pre-admission steering projection. */
|
||||
pending?: boolean
|
||||
/** Marks the bubble as mid-turn steering rather than a turn-opening prompt. */
|
||||
steering?: boolean
|
||||
t: ChatViewSlotProps['t']
|
||||
}): ReactNode {
|
||||
const { text, rest } = contentText(content)
|
||||
const { text, images, rest } = contentParts(content)
|
||||
const truncated = (total: number): string => t('json.truncated', { total })
|
||||
const showBubble = text !== '' || rest.length > 0
|
||||
return (
|
||||
<div className={css.userRow} data-pending-steering={pending || undefined} data-time-hover-root>
|
||||
{steering && <span className={css.steeringMark} data-steering-mark>{t('message.steering')}</span>}
|
||||
<div className={css.bubble}>
|
||||
{projectUserText(text)}
|
||||
{rest.map((block, i) => <JsonBlock key={i} label={t('message.extraBlock')} payload={block} truncatedLabel={truncated} />)}
|
||||
<div className={css.userStack}>
|
||||
<ImageGallery images={images} load={imageLoader} align="end" t={t} />
|
||||
{showBubble && <div className={css.bubble}>
|
||||
{projectUserText(text)}
|
||||
{rest.map((block, i) => <JsonBlock key={i} label={t('message.extraBlock')} payload={block} truncatedLabel={truncated} />)}
|
||||
</div>}
|
||||
</div>
|
||||
{actions?.(text)}
|
||||
</div>
|
||||
@@ -182,15 +194,17 @@ function UserStyleBubble({
|
||||
* @param props - Pending message content and conversation translator.
|
||||
* @returns the pending steering bubble.
|
||||
*/
|
||||
export function PendingSteeringBubble({ content, t }: {
|
||||
export function PendingSteeringBubble({ content, loadImage, t }: {
|
||||
content: readonly unknown[]
|
||||
loadImage?: ImageLoader
|
||||
t: ChatViewSlotProps['t']
|
||||
}): ReactNode {
|
||||
const imageLoader = loadImage ?? (() => Promise.reject(new Error(t('image.serviceUnavailable'))))
|
||||
return (
|
||||
<UserStyleBubble
|
||||
content={content}
|
||||
imageLoader={imageLoader}
|
||||
pending
|
||||
steering
|
||||
t={t}
|
||||
actions={text => (
|
||||
<MessageIconActions
|
||||
@@ -206,13 +220,13 @@ export function PendingSteeringBubble({ content, t }: {
|
||||
|
||||
/** User and admitted-steering keyed Chat renderer. */
|
||||
export const UserMessageNodeView = memo(function UserMessageNodeView({
|
||||
node, t,
|
||||
node, loadImage, t,
|
||||
}: ChatNodeViewProps<'user' | 'steering'>) {
|
||||
const data = node.data
|
||||
return (
|
||||
<UserStyleBubble
|
||||
content={data.content}
|
||||
steering={data.kind === 'steering'}
|
||||
imageLoader={loadImage}
|
||||
t={t}
|
||||
actions={text => (
|
||||
<MessageIconActions
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { NS } from '../locales.ts'
|
||||
import { AssistantNodeView } from './AssistantNodeView.tsx'
|
||||
import { CommandNodeView, ManualCompactionNodeView } from './CommandNodeView.tsx'
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/** Composer submission vocabulary shared by the input and settings domains. */
|
||||
|
||||
/** Delivery mode requested for one ordinary composer message. */
|
||||
export type InputSubmitMode = 'queue' | 'steer'
|
||||
import type { BusyEnterBehavior } from '../../submission-settings.ts'
|
||||
|
||||
/** Configurable meaning of plain Enter while the addressed agent is busy. */
|
||||
export type BusyEnterBehavior = InputSubmitMode
|
||||
export type { BusyEnterBehavior } from '../../submission-settings.ts'
|
||||
|
||||
/** Delivery mode requested for one ordinary composer message. */
|
||||
export type InputSubmitMode = BusyEnterBehavior
|
||||
|
||||
/** Keyboard gesture whose delivery mode the submission policy resolves. */
|
||||
export type ComposerSubmitGesture = 'enter' | 'accelerated'
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** Conversation slot declarations and their composed component props. */
|
||||
import type { ReactNode, RefObject } from 'react'
|
||||
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import type {
|
||||
InjectFace, MaybeSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore,
|
||||
SlotHookFactory, SnapshotSelectorHook,
|
||||
@@ -12,12 +13,22 @@ import type {
|
||||
import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import type { ComposerBlock } from '../input/blocks.ts'
|
||||
import type { ComposerKeyboard, EditSelection, InputActions, InputNotice, InputState } from '../input/contract.ts'
|
||||
import type {
|
||||
ComposerKeyboard, DraftAttachmentId, EditSelection, InputActions, InputNotice, InputState,
|
||||
} from '../input/contract.ts'
|
||||
import type { createChatStore } from '../stores.ts'
|
||||
import type { ComposerSubmitGesture, InputSubmitMode } from './composer-submission.ts'
|
||||
import type { ChatNode, ChatNodeKind } from './chat-nodes.ts'
|
||||
import type { CallId, SelectionTarget, ViewTab } from './views.ts'
|
||||
|
||||
/** Browser-owned image that has not crossed the durable host boundary. */
|
||||
export interface ComposerAttachment {
|
||||
kind: 'image'
|
||||
id: DraftAttachmentId
|
||||
file: File
|
||||
previewUrl: string
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface SlotMap {
|
||||
/**
|
||||
@@ -27,7 +38,11 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
'conversation.session': { kind: 'single'; scope: 'session' }
|
||||
/** Strict-session header above the resident conversation scrollport. */
|
||||
'conversation.session.header': { kind: 'single'; scope: 'session' }
|
||||
/** Session-header actions contributed by feature plugins. */
|
||||
/**
|
||||
* Session-header actions contributed by feature plugins. Entries render
|
||||
* by ascending `order`; negative values are reserved for static session
|
||||
* context that precedes interactive actions.
|
||||
*/
|
||||
'conversation.session.header.actions': { kind: 'list'; scope: 'session'; owner: ConversationHeaderActionOwnerProps }
|
||||
/**
|
||||
* The conversation view ring: one list entry per view tab (chat here;
|
||||
@@ -213,7 +228,7 @@ export interface ChatFileMentions {
|
||||
forClosing(owner: TurnTailOwnerProps): MarkdownFileMentions | undefined
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
/** Prose file-mention provider (ui-deliverables); reach via ctx.get — optional. */
|
||||
chatFileMentions: ChatFileMentions
|
||||
@@ -258,6 +273,8 @@ export interface ChatNodeOwnerProps {
|
||||
openFile: (path: string) => void
|
||||
inspectCall: (callId: CallId) => void
|
||||
forkAt: (seq: number) => void
|
||||
/** Resolve a session-authorized historical image for inline display. */
|
||||
loadImage: (attachment: ImageAttachmentRef) => Promise<string>
|
||||
fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined
|
||||
}
|
||||
|
||||
@@ -327,6 +344,8 @@ export interface ConversationSessionInjected {
|
||||
subscribe: (fn: () => void) => () => void
|
||||
version: () => number
|
||||
}
|
||||
/** Release historical image URLs when this rendered session scope unmounts. */
|
||||
releaseSessionImages: (sessionId: SessionId) => void
|
||||
/** Bind the input machine's draft persistence mirror to the session store. */
|
||||
bindDraftMirror: (write: (text: string) => void) => () => void
|
||||
}
|
||||
@@ -383,6 +402,12 @@ export interface ComposerBarOwnerProps {
|
||||
export interface ComposerBarInjected {
|
||||
/** The InputBar-exclusive keyboard/DOM command face (private plane); absent with the session. */
|
||||
keyboard: ComposerKeyboard | undefined
|
||||
/** Create previews and append image ids to the session input. */
|
||||
addImages: ((files: readonly File[]) => string | null) | undefined
|
||||
/** Release one preview and remove its id from session input. */
|
||||
removeImage: ((id: DraftAttachmentId) => void) | undefined
|
||||
/** Resolve ordered input ids to browser-owned draft images. */
|
||||
draftImages: ((ids: readonly DraftAttachmentId[]) => readonly ComposerAttachment[]) | undefined
|
||||
/** Resolve one keyboard submission gesture against the current running state and persisted preference. */
|
||||
resolveSubmitMode: (
|
||||
running: boolean,
|
||||
@@ -565,6 +590,8 @@ export interface ChatViewInjected {
|
||||
*/
|
||||
openFile: (path: string) => void
|
||||
loadOlder: () => void
|
||||
/** Resolve a session-authorized historical image for inline display. */
|
||||
loadImage: (attachment: ImageAttachmentRef) => Promise<string>
|
||||
/** Hand a call off to the trajectory view: write the one-shot inspect target and switch tabs. */
|
||||
inspectCall: (callId: CallId) => void
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {
|
||||
AssistantBlock, AssistantMessageNode, ConversationLocation, ConversationMatch,
|
||||
ConversationNodeContext, ConversationNodeDefinition,
|
||||
@@ -242,6 +242,7 @@ function projectAssistant(context: ConversationNodeContext<AssistantState>): Ass
|
||||
/** Per-step Assistant streaming/final/interruption Definition. */
|
||||
export const assistantDefinition: ConversationNodeDefinition<AssistantState> = {
|
||||
kind: 'assistant-step',
|
||||
target: 'chat',
|
||||
match: (event) => {
|
||||
if (event.type === 'step/start') return { id: `${event.data.turn}:${event.data.step}`, role: 'start' }
|
||||
if (event.type === 'assistant/chunk'
|
||||
@@ -291,8 +292,7 @@ export const assistantDefinition: ConversationNodeDefinition<AssistantState> = {
|
||||
value: projected.data,
|
||||
}
|
||||
},
|
||||
buildViewNode: (context, target) => {
|
||||
if (target !== 'chat') return null
|
||||
buildViewNode: (context) => {
|
||||
const projected = projectAssistant(context)
|
||||
if (projected === undefined) return null
|
||||
if (projected.settled === undefined && !projected.visible) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {
|
||||
ChatConversationViewNode, ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot,
|
||||
ConversationLocation, ConversationNode, ConversationTimelineSnapshot,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {
|
||||
CommandNode, CompactionSummaryNode, ConversationMatch, ConversationNodeContext,
|
||||
ConversationNodeDefinition,
|
||||
@@ -175,6 +175,7 @@ export function updateCompactionState<State extends CompactionEvidence>(
|
||||
/** Slash-command lifecycle, including integrated manual compaction, Definition. */
|
||||
export const commandDefinition: ConversationNodeDefinition<CommandState> = {
|
||||
kind: 'command',
|
||||
target: 'chat',
|
||||
match: (event) => {
|
||||
if (event.type === 'command/run') {
|
||||
return { id: String(event.data.commandId), role: 'start' }
|
||||
@@ -202,8 +203,7 @@ export const commandDefinition: ConversationNodeDefinition<CommandState> = {
|
||||
}
|
||||
return updateCompactionState(context.state, match)
|
||||
},
|
||||
buildViewNode: (context, target) => {
|
||||
if (target !== 'chat') return null
|
||||
buildViewNode: (context) => {
|
||||
const state = context.state ?? fallbackState(context)
|
||||
if (state === undefined) return null
|
||||
if (state.command.name !== 'compact') {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {
|
||||
CompactionSummaryNode, ConversationMatch, ConversationNodeContext, ConversationNodeDefinition,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -30,6 +30,7 @@ function fallbackState(context: ConversationNodeContext<CompactionState>): Compa
|
||||
/** Automatic compaction lifecycle and landed checkpoint Definition. */
|
||||
export const compactionDefinition: ConversationNodeDefinition<CompactionState> = {
|
||||
kind: 'compaction',
|
||||
target: 'chat',
|
||||
match: (event) => {
|
||||
const checkpoint = compactSource(event)
|
||||
if (checkpoint !== undefined && checkpoint.sourceCommandId === undefined) {
|
||||
@@ -47,8 +48,7 @@ export const compactionDefinition: ConversationNodeDefinition<CompactionState> =
|
||||
},
|
||||
start: () => ({}),
|
||||
update: (context, match) => updateCompactionState(context.state, match),
|
||||
buildViewNode: (context, target) => {
|
||||
if (target !== 'chat') return null
|
||||
buildViewNode: (context) => {
|
||||
const state = context.state ?? fallbackState(context)
|
||||
if (state.checkpoint === undefined) return null
|
||||
const marker = compactSummary(state.summary, state.checkpoint)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {
|
||||
ConversationNodeDefinition, UnknownSurfaceNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -15,6 +15,7 @@ declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
|
||||
/** Unclaimed append-surface fallback Definition. */
|
||||
export const unknownFallbackDefinition: ConversationNodeDefinition<UnknownSurfaceNode> = {
|
||||
kind: 'unknown-surface',
|
||||
target: 'chat',
|
||||
match: event => isAppendSurfaceEvent(event)
|
||||
? { id: String(event.seq), role: 'start' }
|
||||
: null,
|
||||
@@ -26,7 +27,7 @@ export const unknownFallbackDefinition: ConversationNodeDefinition<UnknownSurfac
|
||||
data: match.event.data,
|
||||
}),
|
||||
update: context => context.state,
|
||||
buildViewNode: (context, target) => target !== 'chat' || context.state === undefined
|
||||
buildViewNode: context => context.state === undefined
|
||||
? null
|
||||
: chatNode(context, 'unknown', context.state.seq, context.state),
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {
|
||||
ConversationNodeDefinition, ConversationPreviousContext,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -50,7 +50,6 @@ function inboxDefinition(target: InboxTarget): ConversationNodeDefinition<InboxS
|
||||
},
|
||||
update: context => context.state,
|
||||
publication: () => 'none',
|
||||
buildViewNode: () => null,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {
|
||||
ContextMessageNode, ConversationNodeDefinition, SteeringMessageNode, UserMessageNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -30,6 +30,7 @@ function isCompactionCheckpoint(event: Parameters<ConversationNodeDefinition['ma
|
||||
/** User, steering, and injected-context message classification Definition. */
|
||||
export const messageDefinition: ConversationNodeDefinition<MessageNode> = {
|
||||
kind: 'input-message',
|
||||
target: 'chat',
|
||||
match: event => event.type === 'user/message'
|
||||
&& isAppendSurfaceEvent(event)
|
||||
&& !isCompactionCheckpoint(event)
|
||||
@@ -68,8 +69,8 @@ export const messageDefinition: ConversationNodeDefinition<MessageNode> = {
|
||||
}
|
||||
},
|
||||
update: context => context.state,
|
||||
buildViewNode: (context, target) => {
|
||||
if (target !== 'chat' || context.state === undefined) return null
|
||||
buildViewNode: (context) => {
|
||||
if (context.state === undefined) return null
|
||||
return chatNode(context, context.state.kind, context.state.seq, context.state)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { registerAssistantConversationNode } from './assistant.ts'
|
||||
import { registerChatConversationView } from './chat-snapshot-builder.ts'
|
||||
import { registerCommandConversationNode } from './command.ts'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {
|
||||
ConversationLocation, ConversationNodeDefinition, ModelRetryNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -40,6 +40,7 @@ function isClosed(location: ConversationLocation): boolean {
|
||||
/** Producer-correlated model retry chain Definition. */
|
||||
export const retryDefinition: ConversationNodeDefinition<RetryState> = {
|
||||
kind: 'model-retry',
|
||||
target: 'chat',
|
||||
match: (event) => {
|
||||
if (event.type === 'llm/retry') {
|
||||
const retryId: unknown = event.data.retryId
|
||||
@@ -70,8 +71,8 @@ export const retryDefinition: ConversationNodeDefinition<RetryState> = {
|
||||
attempt.retry === retry ? { ...attempt, retryState: 'started' } : attempt),
|
||||
}
|
||||
},
|
||||
buildViewNode: (context, target) => {
|
||||
if (target !== 'chat' || context.state === undefined || context.state.attempts.length === 0) return null
|
||||
buildViewNode: (context) => {
|
||||
if (context.state === undefined || context.state.attempts.length === 0) return null
|
||||
const location = context.start?.location ?? context.matches[0]?.location ?? { kind: 'unresolved' as const }
|
||||
const stateAttempts = context.state.attempts
|
||||
const attempts = stateAttempts.map((attempt, index) =>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {
|
||||
ConversationMatch, ConversationNodeContext, ConversationNodeDefinition,
|
||||
RunningToolCall, ToolCallBlock, ToolResultNode,
|
||||
@@ -235,6 +235,7 @@ function fallbackState(context: ConversationNodeContext<ToolState>): ToolState |
|
||||
/** Root Tool lifecycle and nested Code Dispatch Definition. */
|
||||
export const toolDefinition: ConversationNodeDefinition<ToolState> = {
|
||||
kind: 'tool-call',
|
||||
target: 'chat',
|
||||
match: (event) => {
|
||||
if (event.type === 'tool/call') return { id: String(event.data.callId), role: 'start' }
|
||||
if (event.type === 'tool/result' && isAppendSurfaceEvent(event)) {
|
||||
@@ -257,8 +258,7 @@ export const toolDefinition: ConversationNodeDefinition<ToolState> = {
|
||||
}
|
||||
return updateDispatch(context.state, match)
|
||||
},
|
||||
buildViewNode: (context, target) => {
|
||||
if (target !== 'chat') return null
|
||||
buildViewNode: (context) => {
|
||||
const state = context.state ?? fallbackState(context)
|
||||
if (state === undefined) return null
|
||||
const projected = projectBlock(state.root, state, interruption(context))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {
|
||||
ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, TurnErrorNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -63,6 +63,7 @@ function fallbackState(context: ConversationNodeContext<TurnErrorState>): TurnEr
|
||||
/** Terminal turn failure Definition, suppressed when the turn owns a retry chain. */
|
||||
export const turnErrorDefinition: ConversationNodeDefinition<TurnErrorState> = {
|
||||
kind: 'turn-error',
|
||||
target: 'chat',
|
||||
match: (event) => {
|
||||
if (event.type === 'turn/start') return { id: String(event.data.turn), role: 'start' }
|
||||
if (event.type === 'turn/end' && event.data.reason.kind === 'error') {
|
||||
@@ -82,8 +83,7 @@ export const turnErrorDefinition: ConversationNodeDefinition<TurnErrorState> = {
|
||||
? { ...context.state, hidden: true }
|
||||
: context.state
|
||||
},
|
||||
buildViewNode: (context, target) => {
|
||||
if (target !== 'chat') return null
|
||||
buildViewNode: (context) => {
|
||||
const state = context.state ?? fallbackState(context)
|
||||
if (state?.failure === undefined) return null
|
||||
const failure = state.failure
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {
|
||||
ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, TurnLocation,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -151,6 +151,7 @@ function tailData(context: ConversationNodeContext<TurnTailState>): TurnTailChat
|
||||
/** Completed-turn footer Definition independent of any Assistant row. */
|
||||
export const turnTailDefinition: ConversationNodeDefinition<TurnTailState> = {
|
||||
kind: 'turn-tail',
|
||||
target: 'chat',
|
||||
match: (event) => {
|
||||
if (event.type === 'turn/start') return { id: String(event.data.turn), role: 'start' }
|
||||
if (event.type === 'turn/end') return { id: String(event.data.turn), role: 'update' }
|
||||
@@ -179,8 +180,7 @@ export const turnTailDefinition: ConversationNodeDefinition<TurnTailState> = {
|
||||
value,
|
||||
}
|
||||
},
|
||||
buildViewNode: (context, target) => {
|
||||
if (target !== 'chat') return null
|
||||
buildViewNode: (context) => {
|
||||
const turn = turnLocation(context)
|
||||
const data = turn?.data.get('turn-tail')
|
||||
return data === undefined ? null : chatNode(context, 'turn-tail', closingAnchor(context), data)
|
||||
|
||||
@@ -16,6 +16,7 @@ export type {} from './conversation-nodes/turn-tail.ts'
|
||||
export { apply, inject } from './apply.ts'
|
||||
export { ConversationService } from './service.ts'
|
||||
export type { IConversation } from './service.ts'
|
||||
export type { DraftAttachmentId } from './input/contract.ts'
|
||||
|
||||
export type {
|
||||
CallId, ChatStoreState, SelectionTarget, ViewTab,
|
||||
@@ -28,14 +29,14 @@ export type {
|
||||
export type {
|
||||
ChatFileMentions, ChatNodeOwnerProps, ChatNodeViewProps,
|
||||
ChatStore, ChatViewInjected, ChatViewSlotProps, CommandRowOwnerProps, CommandRowProps, ComposerBarInjected,
|
||||
ComposerChainProps, ConversationInjected,
|
||||
ComposerAttachment, ComposerChainProps, ConversationInjected,
|
||||
ConversationSessionHeaderInjected, ConversationSessionInjected, ConversationSlotProps, ConvViewOwnerProps,
|
||||
ConvViewProps, DetailsInjected, DetailsSlotProps, DetailsToolOwnerProps, EmptyWorkspaceOwnerProps,
|
||||
TurnTailOwnerProps, UseChatNodeTurnData,
|
||||
} from './contract/slots.ts'
|
||||
// Export discipline: packages/client/AGENTS.md.
|
||||
|
||||
declare module 'cordis' {
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
/** The outward face only; the concrete service stays inside this plugin. */
|
||||
conversation: import('./service.ts').IConversation
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* (machine.ts) is package-private and never exported.
|
||||
*/
|
||||
import type { ClientContext, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type {
|
||||
ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, PickOutcome,
|
||||
ReferenceInsert, SubmitOutcome, TokenSpan,
|
||||
@@ -13,6 +14,9 @@ import type {
|
||||
import type { QueueRow } from '../contract/queue.ts'
|
||||
import type { InputSubmitMode } from '../contract/composer-submission.ts'
|
||||
|
||||
/** Browser-runtime identity of one unsent image draft. */
|
||||
export type DraftAttachmentId = Branded<'DraftAttachmentId'>
|
||||
|
||||
/**
|
||||
* The scoped-event application verbs: the hub's bail listeners call these,
|
||||
* and the boolean answer IS the event's bail value (true ⟺ the machine
|
||||
@@ -29,6 +33,12 @@ export interface InputTarget {
|
||||
export interface SessionInput extends InputTarget {
|
||||
/** Single write path for draft text (all mutation rides machine events). */
|
||||
setDraft(text: string): void
|
||||
/** Append ordered browser-owned image ids; busy admission phases refuse. */
|
||||
addImages(ids: readonly DraftAttachmentId[]): boolean
|
||||
/** Remove one browser-owned image id. */
|
||||
removeImage(id: DraftAttachmentId): void
|
||||
/** Drop ids whose browser-owned objects no longer exist. */
|
||||
pruneImages(ids: readonly DraftAttachmentId[]): void
|
||||
/**
|
||||
* THE complexity sink: enter adjudication, submit transaction, and the default sink live inside.
|
||||
* @param mode - delivery intent retained through asynchronous adjudication and serialization.
|
||||
@@ -63,6 +73,12 @@ export interface InputService {
|
||||
export interface InputActions {
|
||||
/** Single public draft write path (full next draft; occurrence math via diff scan). */
|
||||
setDraft(text: string): void
|
||||
/** Append ordered browser-owned image ids; busy admission phases refuse. */
|
||||
addImages(ids: readonly DraftAttachmentId[]): boolean
|
||||
/** Remove one browser-owned image id. */
|
||||
removeImage(id: DraftAttachmentId): void
|
||||
/** Drop ids whose browser-owned objects no longer exist. */
|
||||
pruneImages(ids: readonly DraftAttachmentId[]): void
|
||||
/** Enter submission (adjudication / claim transaction / default sink inside). */
|
||||
submit(): void
|
||||
}
|
||||
@@ -192,6 +208,8 @@ export interface InputMachineOptions {
|
||||
/** Published input state (the currency; per-session). */
|
||||
export interface InputState {
|
||||
readonly draft: string
|
||||
/** Ordered runtime-only image ids; bytes and URLs stay in ConversationService. */
|
||||
readonly imageIds: readonly DraftAttachmentId[]
|
||||
/** Monotonic draft revision (span CAS compares against this). */
|
||||
readonly draftRev: number
|
||||
readonly phase: 'plain' | 'adjudicating' | 'claimed' | 'submitting'
|
||||
|
||||
@@ -13,7 +13,7 @@ import type {
|
||||
ReferenceInsert, SlashController, TokenSpan,
|
||||
} from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import type {
|
||||
EditRange, EditSelection, InputActions, InputEffect, InputNotice, InputState,
|
||||
DraftAttachmentId, EditRange, EditSelection, InputActions, InputEffect, InputNotice, InputState,
|
||||
PasteComponent, QueuedMessage, SessionInput, SubmitAttempt,
|
||||
} from './contract.ts'
|
||||
import type { InputSubmitMode } from '../contract/composer-submission.ts'
|
||||
@@ -45,7 +45,7 @@ export interface SessionInputDeps {
|
||||
*/
|
||||
steerQueue?: (() => void) | undefined
|
||||
/** The plain-message sink (send choreography / materialize fork — the hub owns it). */
|
||||
defaultSink(text: string, mode: InputSubmitMode): void
|
||||
defaultSink(text: string, imageIds: readonly DraftAttachmentId[], mode: InputSubmitMode): void
|
||||
}
|
||||
|
||||
/** Guard tier from the machine phase. */
|
||||
@@ -74,6 +74,9 @@ export class SessionInputShell implements SessionInput {
|
||||
/** The public provide-channel action face (one stable identity per session). */
|
||||
readonly actions: InputActions = {
|
||||
setDraft: (text) => { this.setDraft(text) },
|
||||
addImages: ids => this.addImages(ids),
|
||||
removeImage: (id) => { this.removeImage(id) },
|
||||
pruneImages: (ids) => { this.pruneImages(ids) },
|
||||
submit: () => { this.submit('queue') },
|
||||
}
|
||||
|
||||
@@ -82,6 +85,7 @@ export class SessionInputShell implements SessionInput {
|
||||
private readonly core = new InputMachine({ now: () => Date.now() })
|
||||
private noticeSeq = 0
|
||||
private lastDraft = ''
|
||||
private imageIds: readonly DraftAttachmentId[] = []
|
||||
private disposed = false
|
||||
/** Draft persistence mirror (chat store write; receives the clipboard projection, never raw placeholders). */
|
||||
private mirrorFn: ((text: string) => void) | undefined
|
||||
@@ -103,12 +107,54 @@ export class SessionInputShell implements SessionInput {
|
||||
this.run(this.core.dispatch({ type: 'draft-changed', draft: text, ...(editRange !== undefined ? { editRange } : {}) }))
|
||||
}
|
||||
|
||||
/** Append ordered image ids unless an admission transaction is locked. */
|
||||
addImages(ids: readonly DraftAttachmentId[]): boolean {
|
||||
if (this.snapshot.phase === 'adjudicating' || this.snapshot.phase === 'submitting') return false
|
||||
if (ids.length === 0) return true
|
||||
this.imageIds = [...this.imageIds, ...ids]
|
||||
this.publish()
|
||||
return true
|
||||
}
|
||||
|
||||
/** Remove one image id from this draft. */
|
||||
removeImage(id: DraftAttachmentId): void {
|
||||
const next = this.imageIds.filter(candidate => candidate !== id)
|
||||
if (next.length === this.imageIds.length) return
|
||||
this.imageIds = next
|
||||
this.publish()
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep only image ids that still resolve in the browser attachment registry.
|
||||
* @param available - live registry ids.
|
||||
*/
|
||||
pruneImages(available: readonly DraftAttachmentId[]): void {
|
||||
const keep = new Set(available)
|
||||
const next = this.imageIds.filter(id => keep.has(id))
|
||||
if (next.length === this.imageIds.length) return
|
||||
this.imageIds = next
|
||||
this.publish()
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a failed attempt before any images added after its admission.
|
||||
* @param ids - failed attempt image ids.
|
||||
*/
|
||||
restoreImages(ids: readonly DraftAttachmentId[]): void {
|
||||
const current = new Set(this.imageIds)
|
||||
this.imageIds = [...ids.filter(id => !current.has(id)), ...this.imageIds]
|
||||
this.publish()
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the draft as a successful-send commit: no undo unit is recorded and
|
||||
* the undo history is cut, so Ctrl/Cmd-Z cannot resurrect sent content
|
||||
* (the command path gets the same discipline from submit-settled success).
|
||||
* @param imageIds - admitted image ids to remove from this draft.
|
||||
*/
|
||||
commitSend(): void {
|
||||
commitSend(imageIds: readonly DraftAttachmentId[]): void {
|
||||
const submitted = new Set(imageIds)
|
||||
this.imageIds = this.imageIds.filter(id => !submitted.has(id))
|
||||
this.run(this.core.dispatch({ type: 'send-committed' }))
|
||||
}
|
||||
|
||||
@@ -150,6 +196,10 @@ export class SessionInputShell implements SessionInput {
|
||||
* dismisses and the menu tracks frozen.
|
||||
*/
|
||||
submit(mode: InputSubmitMode = 'queue'): void {
|
||||
if (this.snapshot.draft.trim() === '' && this.imageIds.length > 0) {
|
||||
if (this.snapshot.phase === 'plain') this.deps.defaultSink('', [...this.imageIds], mode)
|
||||
return
|
||||
}
|
||||
this.run(this.core.dispatch({ type: 'enter', mode }))
|
||||
const phase = this.snapshot.phase
|
||||
if (phase === 'adjudicating' || phase === 'submitting') {
|
||||
@@ -364,9 +414,10 @@ export class SessionInputShell implements SessionInput {
|
||||
* the clipboard text. Chip-free drafts skip the async detour.
|
||||
*/
|
||||
private sinkSerialized(draft: string, mode: InputSubmitMode): void {
|
||||
const imageIds = [...this.imageIds]
|
||||
const occurrences = this.core.state.occurrences
|
||||
if (occurrences.length === 0) {
|
||||
this.deps.defaultSink(draft.trim(), mode)
|
||||
this.deps.defaultSink(draft.trim(), imageIds, mode)
|
||||
return
|
||||
}
|
||||
const slash = this.deps.slash?.()
|
||||
@@ -386,7 +437,7 @@ export class SessionInputShell implements SessionInput {
|
||||
cursor = part.offset + 1
|
||||
}
|
||||
out += draft.slice(cursor)
|
||||
this.deps.defaultSink(out.trim(), mode)
|
||||
this.deps.defaultSink(out.trim(), imageIds, mode)
|
||||
},
|
||||
(error: unknown) => {
|
||||
controller.abort()
|
||||
@@ -444,7 +495,7 @@ export class SessionInputShell implements SessionInput {
|
||||
|
||||
private compose(): InputState {
|
||||
const core = this.core.state
|
||||
return { ...core, queue: this.deps.queue?.getSnapshot() ?? EMPTY_QUEUE }
|
||||
return { ...core, imageIds: this.imageIds, queue: this.deps.queue?.getSnapshot() ?? EMPTY_QUEUE }
|
||||
}
|
||||
|
||||
private publish(): void {
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { ClientContext, ISessions, SessionBinding, SessionFace, SessionId }
|
||||
import type { SlashController } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { queueReadFaceOf } from '../queue/store.ts'
|
||||
import type { ComposerKeyboard, InputService, SessionInput } from './contract.ts'
|
||||
import type { ComposerKeyboard, DraftAttachmentId, InputService, SessionInput } from './contract.ts'
|
||||
import type { InputSubmitMode } from '../contract/composer-submission.ts'
|
||||
import type { PopupDismissFace } from './facade.ts'
|
||||
import { SessionInputShell } from './facade.ts'
|
||||
@@ -22,6 +22,17 @@ interface CommandFace {
|
||||
popupFor(actx: ClientContext): PopupDismissFace
|
||||
}
|
||||
|
||||
/** Attachment-send face resolved lazily to keep hub/service construction acyclic. */
|
||||
interface ConversationAttachmentFace {
|
||||
sendSession(
|
||||
session: SessionFace,
|
||||
text: string,
|
||||
imageIds: readonly DraftAttachmentId[],
|
||||
mode: InputSubmitMode,
|
||||
): Promise<void>
|
||||
releaseDraftImage(id: DraftAttachmentId): void
|
||||
}
|
||||
|
||||
/** Session-addressed input facade registry (InputService face + composer-layer extras). */
|
||||
export class InputHub implements InputService {
|
||||
private readonly shells = new Map<SessionId, SessionInputShell>()
|
||||
@@ -64,7 +75,7 @@ export class InputHub implements InputService {
|
||||
slash: () => this.controller(actx),
|
||||
popup: () => this.popup(actx),
|
||||
queue: queueReadFaceOf(session),
|
||||
defaultSink: (text, mode) => { this.sink(session, text, mode) },
|
||||
defaultSink: (text, imageIds, mode) => { this.sink(session, text, imageIds, mode) },
|
||||
steerQueue: () => { void this.steerQueue(session, shell) },
|
||||
})
|
||||
this.shells.set(id, shell)
|
||||
@@ -83,8 +94,11 @@ export class InputHub implements InputService {
|
||||
]
|
||||
return () => {
|
||||
for (const off of offs) off()
|
||||
const drafts = shell.snapshot.imageIds
|
||||
shell.dispose()
|
||||
this.shells.delete(id)
|
||||
const conversation = this.rootCtx.get('conversation') as ConversationAttachmentFace | undefined
|
||||
for (const imageId of drafts) conversation?.releaseDraftImage(imageId)
|
||||
}
|
||||
}, 'conversation.input: session shell')
|
||||
return shell
|
||||
@@ -132,19 +146,25 @@ 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: SessionFace, text: string, mode: InputSubmitMode): void {
|
||||
if (text === '') return
|
||||
private sink(
|
||||
session: SessionFace,
|
||||
text: string,
|
||||
imageIds: readonly DraftAttachmentId[],
|
||||
mode: InputSubmitMode,
|
||||
): void {
|
||||
if (text === '' && imageIds.length === 0) return
|
||||
const shell = this.shells.get(session.sessionId)
|
||||
// Commit, not an editable clear: undo must not resurrect sent content.
|
||||
shell?.commitSend()
|
||||
void session.prompt([{ type: 'text', text }], mode).then(
|
||||
(result) => {
|
||||
if (!result.ok && shell?.snapshot.draft === '') shell.setDraft(text)
|
||||
},
|
||||
() => {
|
||||
shell?.commitSend(imageIds)
|
||||
void this.conversation().sendSession(session, text, imageIds, mode).catch(() => {
|
||||
if (this.shells.get(session.sessionId) === shell) {
|
||||
shell?.restoreImages(imageIds)
|
||||
if (shell?.snapshot.draft === '') shell.setDraft(text)
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
const conversation = this.rootCtx.get('conversation') as ConversationAttachmentFace | undefined
|
||||
for (const id of imageIds) conversation?.releaseDraftImage(id)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -186,4 +206,10 @@ export class InputHub implements InputService {
|
||||
if (sessions === undefined) throw new Error('conversation.input: sessions service unavailable')
|
||||
return sessions
|
||||
}
|
||||
|
||||
private conversation(): ConversationAttachmentFace {
|
||||
const conversation = this.rootCtx.get('conversation') as ConversationAttachmentFace | undefined
|
||||
if (conversation === undefined) throw new Error('conversation.input: conversation service unavailable')
|
||||
return conversation
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +133,7 @@ export class InputMachine {
|
||||
const c = this.claim
|
||||
return {
|
||||
draft: this.draft,
|
||||
imageIds: [],
|
||||
draftRev: this.draftRev,
|
||||
phase: this.phase,
|
||||
...(c ? { claim: { token: c.token, ...(c.hint !== undefined ? { hint: c.hint } : {}) } } : {}),
|
||||
|
||||
@@ -1,27 +1,42 @@
|
||||
/**
|
||||
* Browser-local Composer submission policy. It owns the persisted busy-Enter
|
||||
* Composer submission policy. It owns the live busy-Enter
|
||||
* preference and resolves keyboard gestures into queue/steer delivery modes;
|
||||
* Host and Agent keep the actual delivery-window authority.
|
||||
*/
|
||||
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
createSnapshotStore, type SettingsScope, type SnapshotStore,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
BusyEnterBehavior, ComposerSubmitGesture, InputSubmitMode,
|
||||
} from '../contract/composer-submission.ts'
|
||||
import { BUSY_ENTER_FIELD, DEFAULT_BUSY_ENTER_BEHAVIOR } from '../../submission-settings.ts'
|
||||
import type { ConversationSettings } from '../../submission-settings.ts'
|
||||
|
||||
/** localStorage key holding the busy-Enter preference. */
|
||||
export const BUSY_ENTER_STORAGE_KEY = 'dsh.conversation.busyEnter'
|
||||
|
||||
/** Default preserves Enter-as-Queue for running conversations. */
|
||||
export const DEFAULT_BUSY_ENTER_BEHAVIOR: BusyEnterBehavior = 'queue'
|
||||
export { DEFAULT_BUSY_ENTER_BEHAVIOR } from '../../submission-settings.ts'
|
||||
|
||||
/**
|
||||
* Persisted policy used by both the composer inject face and its Settings row.
|
||||
* Busy-Enter policy used by both the composer inject face and its Settings row.
|
||||
* Direct `steer` is intentionally best-effort: AgentLoop turns a closed-window
|
||||
* submission into the next waking Queue item.
|
||||
*/
|
||||
export class ComposerSubmissionPolicy {
|
||||
/** Reactive preference source for the Settings row. */
|
||||
readonly busyEnter: SnapshotStore<BusyEnterBehavior> = createSnapshotStore(restoreBusyEnter())
|
||||
readonly busyEnter: SnapshotStore<BusyEnterBehavior> = createSnapshotStore(DEFAULT_BUSY_ENTER_BEHAVIOR)
|
||||
private readonly host: SettingsScope<ConversationSettings> | undefined
|
||||
|
||||
/**
|
||||
* @param host - durable preference scope owned by the providing plugin;
|
||||
* absent compositions stay process-local. The adoption subscription shares
|
||||
* the scope's plugin lifetime — a disposed scope never publishes again, so
|
||||
* the policy needs no release hook.
|
||||
*/
|
||||
constructor(host?: SettingsScope<ConversationSettings>) {
|
||||
this.host = host
|
||||
if (host !== undefined) {
|
||||
host.subscribe(() => { this.adopt(host) })
|
||||
this.adopt(host)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one keyboard gesture without changing state.
|
||||
@@ -42,36 +57,23 @@ export class ComposerSubmissionPolicy {
|
||||
}
|
||||
|
||||
/**
|
||||
* Change and persist the plain-Enter behavior used during busy state.
|
||||
* Change the plain-Enter behavior used during busy state; the live value
|
||||
* publishes before the durable write starts.
|
||||
* @param behavior - Queue or Steer.
|
||||
*/
|
||||
setBusyEnter(behavior: BusyEnterBehavior): void {
|
||||
if (this.busyEnter.getSnapshot() === behavior) return
|
||||
this.busyEnter.set(behavior)
|
||||
persistBusyEnter(behavior)
|
||||
void this.host?.set(BUSY_ENTER_FIELD, behavior)
|
||||
}
|
||||
}
|
||||
|
||||
/** Restore a valid preference; unavailable or corrupt storage uses Queue. */
|
||||
function restoreBusyEnter(): BusyEnterBehavior {
|
||||
if (typeof localStorage === 'undefined') return DEFAULT_BUSY_ENTER_BEHAVIOR
|
||||
let stored: string | null
|
||||
try {
|
||||
stored = localStorage.getItem(BUSY_ENTER_STORAGE_KEY)
|
||||
} catch {
|
||||
// Storage access can fail in privacy modes; the default remains usable.
|
||||
return DEFAULT_BUSY_ENTER_BEHAVIOR
|
||||
}
|
||||
if (stored === 'queue' || stored === 'steer') return stored
|
||||
return DEFAULT_BUSY_ENTER_BEHAVIOR
|
||||
}
|
||||
|
||||
/** Persist a preference when browser storage is available. */
|
||||
function persistBusyEnter(behavior: BusyEnterBehavior): void {
|
||||
if (typeof localStorage === 'undefined') return
|
||||
try {
|
||||
localStorage.setItem(BUSY_ENTER_STORAGE_KEY, behavior)
|
||||
} catch {
|
||||
// A storage failure makes the preference session-only; input stays usable.
|
||||
/**
|
||||
* Adopt the scope's accepted durable behavior without writing it back.
|
||||
* @param host - the constructor-narrowed scope driving this adoption.
|
||||
*/
|
||||
private adopt(host: SettingsScope<ConversationSettings>): void {
|
||||
const section = host.getSnapshot().value
|
||||
if (section === undefined || this.busyEnter.getSnapshot() === section.busyEnter) return
|
||||
this.busyEnter.set(section.busyEnter)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,20 @@ export const zh = {
|
||||
'input.send': '发送消息',
|
||||
'placeholder.steerQueue': 'Cmd/Ctrl+Enter 插话发送全部排队消息',
|
||||
'input.accessMode': '访问模式,当前:{name}',
|
||||
'image.dropHint': '松开以添加图片',
|
||||
'image.pending': '待发送图片',
|
||||
'image.openOriginal': '双击查看原图',
|
||||
'image.openOriginalLabel': '{label},双击查看原图',
|
||||
'image.remove': '移除图片 {name}',
|
||||
'image.original': '原图',
|
||||
'image.label': '图片',
|
||||
'image.loadFailed': '图片加载失败,点击重试',
|
||||
'image.loading': '图片加载中…',
|
||||
'image.preview': '原图预览',
|
||||
'image.closePreview': '关闭原图预览',
|
||||
'image.serviceUnavailable': '图片读取服务不可用',
|
||||
'image.unsupportedType': '不支持的图片格式:{type}',
|
||||
'image.unknownType': '未知格式',
|
||||
'context.aria': '上下文已用 {percent}',
|
||||
'context.used': '上下文已用',
|
||||
'context.system': '系统提示词',
|
||||
@@ -46,7 +60,7 @@ export const zh = {
|
||||
'access.confirm.acknowledge': '我已了解风险,并愿意继续',
|
||||
'access.confirm.cancel': '取消',
|
||||
'access.confirm.enable': '启用 Full access',
|
||||
'hero.headline': '探索未知之境',
|
||||
'hero.headline': '探索未至之境',
|
||||
'hero.preview': '预览版',
|
||||
'hero.chooseWorkspace': '选择工作区',
|
||||
'session.hierarchy': '会话层级',
|
||||
@@ -80,7 +94,6 @@ export const zh = {
|
||||
'message.context.relay.from': '来自会话 {session}',
|
||||
'message.context.recall.counts': '保留 {retained} 条 · 省略 {omitted} 条',
|
||||
'message.context.recall.truncated': '已截断',
|
||||
'message.steering': '插话',
|
||||
'message.compaction': '上下文已压缩',
|
||||
'message.compaction.running': '正在压缩…',
|
||||
'message.compaction.completed': '已压缩 {items} 条历史记录(约 {tokens} tokens)',
|
||||
@@ -169,6 +182,20 @@ export const en = {
|
||||
'input.send': 'Send message',
|
||||
'placeholder.steerQueue': 'Cmd/Ctrl+Enter steers all queued messages',
|
||||
'input.accessMode': 'Access mode, current: {name}',
|
||||
'image.dropHint': 'Drop to add images',
|
||||
'image.pending': 'Pending images',
|
||||
'image.openOriginal': 'Double-click to view original',
|
||||
'image.openOriginalLabel': '{label}, double-click to view original',
|
||||
'image.remove': 'Remove image {name}',
|
||||
'image.original': 'Original image',
|
||||
'image.label': 'Image',
|
||||
'image.loadFailed': 'Image failed to load; click to retry',
|
||||
'image.loading': 'Loading image…',
|
||||
'image.preview': 'Original image preview',
|
||||
'image.closePreview': 'Close original image preview',
|
||||
'image.serviceUnavailable': 'Image loading service unavailable',
|
||||
'image.unsupportedType': 'Unsupported image format: {type}',
|
||||
'image.unknownType': 'unknown format',
|
||||
'context.aria': '{percent} of context used',
|
||||
'context.used': 'of context used',
|
||||
'context.system': 'System prompt',
|
||||
@@ -224,7 +251,6 @@ export const en = {
|
||||
'message.context.relay.from': 'From session {session}',
|
||||
'message.context.recall.counts': '{retained} kept · {omitted} omitted',
|
||||
'message.context.recall.truncated': 'truncated',
|
||||
'message.steering': 'Interjection',
|
||||
'message.compaction': 'Context compacted',
|
||||
'message.compaction.running': 'Compacting context…',
|
||||
'message.compaction.completed': 'Compacted {items} history items (~{tokens} tokens)',
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//
|
||||
// The 'conversation.input.dock' SlotMap declaration lives in
|
||||
// ../contract/slots.ts beside the other input-region slots.
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { useEffect, useId, useMemo, useState } from 'react'
|
||||
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
@@ -7,15 +7,18 @@
|
||||
* through one property read; assignment through the tracker proxy and `#`
|
||||
* private fields bypass that rebinding.
|
||||
*/
|
||||
import { Service } from 'cordis'
|
||||
import type { Context } from 'cordis'
|
||||
import { Service } from '@deepseek-ai/cordis'
|
||||
import type { Context } from '@deepseek-ai/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 { ISessions, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment'
|
||||
import type { ComposerAttachment } from './contract/slots.ts'
|
||||
import type { QueueAction, QueueItemId } from './contract/queue.ts'
|
||||
import type { ComposerBlocks } from './input/blocks.ts'
|
||||
import type { InputService } from './input/contract.ts'
|
||||
import type { DraftAttachmentId, InputService } from './input/contract.ts'
|
||||
import type { InputSubmitMode } from './contract/composer-submission.ts'
|
||||
|
||||
/**
|
||||
* The outward conversation face (`ctx.conversation`): the scope-addressed
|
||||
@@ -55,12 +58,46 @@ export interface IConversation {
|
||||
loadOlder(): Promise<void>
|
||||
}
|
||||
|
||||
/** Create one browser-only draft descriptor; only its id enters input state. */
|
||||
function browserDraftAttachment(file: File): ComposerAttachment {
|
||||
return {
|
||||
kind: 'image',
|
||||
id: crypto.randomUUID() as DraftAttachmentId,
|
||||
previewUrl: URL.createObjectURL(file),
|
||||
file,
|
||||
}
|
||||
}
|
||||
|
||||
interface ImageUrlEntry {
|
||||
readonly sessionId: SessionId
|
||||
readonly generation: number
|
||||
readonly pending: Promise<string>
|
||||
}
|
||||
|
||||
/** Unsupported browser-declared image type, localized by the UI boundary. */
|
||||
export class UnsupportedImageMediaTypeError extends Error {
|
||||
/** Browser-declared MIME value, possibly empty. */
|
||||
readonly mediaType: string
|
||||
|
||||
/** @param mediaType - Browser-declared MIME value, possibly empty. */
|
||||
constructor(mediaType: string) {
|
||||
super(`unsupported image media type: ${mediaType || '(empty)'}`)
|
||||
this.name = 'UnsupportedImageMediaTypeError'
|
||||
this.mediaType = mediaType
|
||||
}
|
||||
}
|
||||
|
||||
/** Scope-addressed conversation service (root singleton, provided as `conversation`). */
|
||||
export class ConversationService extends Service implements IConversation {
|
||||
/** The per-session input machine registry (InputService face). */
|
||||
readonly input: InputService
|
||||
/** The per-session composer-block registry. */
|
||||
readonly blocks: ComposerBlocks
|
||||
private readonly draftAttachments = new Map<DraftAttachmentId, ComposerAttachment>()
|
||||
private readonly imageUrls = new Map<string, ImageUrlEntry>()
|
||||
private readonly imageGenerations = new Map<SessionId, number>()
|
||||
private readonly createdImageUrls = new Set<string>()
|
||||
private disposed = false
|
||||
|
||||
/**
|
||||
* @param ctx - owning root context (the plugin apply context; the service
|
||||
@@ -73,6 +110,14 @@ export class ConversationService extends Service implements IConversation {
|
||||
super(ctx, 'conversation')
|
||||
this.input = config.input
|
||||
this.blocks = config.blocks
|
||||
ctx.effect(() => () => {
|
||||
this.disposed = true
|
||||
for (const url of this.createdImageUrls) revokePreview(url)
|
||||
this.createdImageUrls.clear()
|
||||
this.draftAttachments.clear()
|
||||
this.imageUrls.clear()
|
||||
this.imageGenerations.clear()
|
||||
}, 'conversation attachment URL cache')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,6 +132,136 @@ export class ConversationService extends Service implements IConversation {
|
||||
if (!result.ok) throw new Error(`conversation.send failed: ${result.error.code}: ${result.error.message}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit ordered draft images with text through one host admission.
|
||||
* @param session - target session.
|
||||
* @param text - serialized prompt text.
|
||||
* @param imageIds - ordered draft-local attachment ids.
|
||||
* @param mode - queue or steer delivery selected by composer policy.
|
||||
*/
|
||||
async sendSession(
|
||||
session: SessionFace,
|
||||
text: string,
|
||||
imageIds: readonly DraftAttachmentId[],
|
||||
mode: InputSubmitMode,
|
||||
): Promise<void> {
|
||||
const attachments = this.draftImages(imageIds)
|
||||
if (attachments.length !== imageIds.length) {
|
||||
throw new Error('conversation.sendSession: one or more draft images are no longer available')
|
||||
}
|
||||
const uploaded = await this.serializeImages(attachments.map(attachment => attachment.file))
|
||||
const content = [...uploaded, ...(text === '' ? [] : [{ type: 'text' as const, text }])]
|
||||
const result = await session.prompt(content, mode)
|
||||
if (!result.ok) throw new Error(`conversation.send failed: ${result.error.code}: ${result.error.message}`)
|
||||
this.releaseDraftImages(attachments)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create runtime-only draft images and their object URLs.
|
||||
* @param files - browser files to register after MIME validation.
|
||||
* @returns ordered draft descriptors.
|
||||
*/
|
||||
createDraftImages(files: readonly File[]): readonly ComposerAttachment[] {
|
||||
for (const file of files) imageMediaType(file.type)
|
||||
return files.map((file) => {
|
||||
const attachment = browserDraftAttachment(file)
|
||||
this.draftAttachments.set(attachment.id, attachment)
|
||||
this.createdImageUrls.add(attachment.previewUrl)
|
||||
return attachment
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve ordered input-state ids to runtime-owned draft images.
|
||||
* @param ids - draft attachment ids.
|
||||
* @returns descriptors that remain live, in requested order.
|
||||
*/
|
||||
draftImages(ids: readonly DraftAttachmentId[]): readonly ComposerAttachment[] {
|
||||
const attachments: ComposerAttachment[] = []
|
||||
for (const id of ids) {
|
||||
const attachment = this.draftAttachments.get(id)
|
||||
if (attachment !== undefined) attachments.push(attachment)
|
||||
}
|
||||
return attachments
|
||||
}
|
||||
|
||||
/**
|
||||
* Release one browser-owned draft image and preview URL.
|
||||
* @param id - draft attachment id.
|
||||
*/
|
||||
releaseDraftImage(id: DraftAttachmentId): void {
|
||||
const attachment = this.draftAttachments.get(id)
|
||||
if (attachment === undefined) return
|
||||
this.draftAttachments.delete(id)
|
||||
this.createdImageUrls.delete(attachment.previewUrl)
|
||||
revokePreview(attachment.previewUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* Release a set of browser-owned draft images.
|
||||
* @param attachments - descriptors to release.
|
||||
*/
|
||||
releaseDraftImages(attachments: readonly ComposerAttachment[]): void {
|
||||
for (const attachment of attachments) this.releaseDraftImage(attachment.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve and cache one session-authorized historical image URL.
|
||||
* @param sessionId - owning session authorization scope.
|
||||
* @param attachment - durable image reference.
|
||||
* @returns browser URL valid until its rendered session is released.
|
||||
*/
|
||||
resolveImage(sessionId: SessionId, attachment: ImageAttachmentRef): Promise<string> {
|
||||
if (this.disposed) return Promise.reject(new Error('conversation.resolveImage: service is disposed'))
|
||||
const key = `${sessionId}:${attachment.attachmentId}`
|
||||
const cached = this.imageUrls.get(key)
|
||||
if (cached !== undefined) return cached.pending
|
||||
const generation = this.imageGenerations.get(sessionId) ?? 0
|
||||
const session = this.requireSessions().binding(sessionId)?.session
|
||||
if (session === undefined) {
|
||||
return Promise.reject(new Error(`conversation.resolveImage: unknown session "${sessionId}"`))
|
||||
}
|
||||
const pending = session.readAttachment(attachment.attachmentId)
|
||||
.then((result) => {
|
||||
if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`)
|
||||
if (this.disposed) throw new Error('conversation.resolveImage: service was disposed before loading completed')
|
||||
if ((this.imageGenerations.get(sessionId) ?? 0) !== generation) {
|
||||
throw new Error('historical image scope was released before loading completed')
|
||||
}
|
||||
if (typeof URL.createObjectURL !== 'function') {
|
||||
return `data:${result.value.attachment.mediaType};base64,${bytesToBase64(result.value.data)}`
|
||||
}
|
||||
const bytes = Uint8Array.from(result.value.data)
|
||||
const url = URL.createObjectURL(new Blob([bytes.buffer], { type: result.value.attachment.mediaType }))
|
||||
this.createdImageUrls.add(url)
|
||||
return url
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (this.imageUrls.get(key)?.generation === generation) this.imageUrls.delete(key)
|
||||
throw error
|
||||
})
|
||||
this.imageUrls.set(key, { sessionId, generation, pending })
|
||||
return pending
|
||||
}
|
||||
|
||||
/**
|
||||
* Release every historical image URL owned by one rendered session.
|
||||
* @param sessionId - rendered session scope.
|
||||
*/
|
||||
releaseSessionImages(sessionId: SessionId): void {
|
||||
this.imageGenerations.set(sessionId, (this.imageGenerations.get(sessionId) ?? 0) + 1)
|
||||
for (const [key, entry] of this.imageUrls) {
|
||||
if (entry.sessionId !== sessionId) continue
|
||||
this.imageUrls.delete(key)
|
||||
void entry.pending.then((url) => {
|
||||
if (!this.createdImageUrls.delete(url)) return
|
||||
revokePreview(url)
|
||||
}, () => {
|
||||
// A failed or invalidated load owns no object URL.
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply one operation to a pending queue occurrence. */
|
||||
async updateQueue(itemId: QueueItemId, action: QueueAction): Promise<void> {
|
||||
const session = this.scopedSession('updateQueue')
|
||||
@@ -136,4 +311,39 @@ export class ConversationService extends Service implements IConversation {
|
||||
if (sessions === undefined) throw new Error('conversation: sessions service unavailable')
|
||||
return sessions
|
||||
}
|
||||
|
||||
/** Convert browser files to canonical base64 prompt parts. */
|
||||
private serializeImages(images: readonly File[]): Promise<Parameters<SessionFace['prompt']>[0]> {
|
||||
return Promise.all(images.map(async file => ({
|
||||
type: 'image' as const,
|
||||
mediaType: imageMediaType(file.type),
|
||||
data: bytesToBase64(new Uint8Array(await file.arrayBuffer())),
|
||||
...(file.name === '' ? {} : { name: file.name }),
|
||||
})))
|
||||
}
|
||||
}
|
||||
|
||||
function imageMediaType(value: string): ImageMediaType {
|
||||
switch (value) {
|
||||
case 'image/png':
|
||||
case 'image/jpeg':
|
||||
case 'image/webp':
|
||||
case 'image/gif':
|
||||
return value
|
||||
default:
|
||||
throw new UnsupportedImageMediaTypeError(value)
|
||||
}
|
||||
}
|
||||
|
||||
function bytesToBase64(data: Uint8Array): string {
|
||||
let binary = ''
|
||||
const chunk = 0x8000
|
||||
for (let offset = 0; offset < data.length; offset += chunk) {
|
||||
binary += String.fromCharCode(...data.subarray(offset, offset + chunk))
|
||||
}
|
||||
return btoa(binary)
|
||||
}
|
||||
|
||||
function revokePreview(url: string): void {
|
||||
if (url.startsWith('blob:')) URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
@@ -263,8 +263,9 @@
|
||||
.composerHero {
|
||||
position: relative; /* .heroGlow positioning context */
|
||||
align-self: center;
|
||||
/* figma 75:8208: 12 between hero chrome / workspace row / card. */
|
||||
gap: 12px;
|
||||
/* figma 75:8208 drew 12 between all three rows; the workspace row now sits
|
||||
8 above the card (its margin-top restores 12 under the hero chrome). */
|
||||
gap: 8px;
|
||||
/* Foot inside the centered box floats the stack a bit above true center. */
|
||||
padding-bottom: 32px;
|
||||
/* Card cap + both clearances: the hero input card lands at exactly the same
|
||||
@@ -292,7 +293,9 @@
|
||||
.heroWorkspaceRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
margin-top: 4px;
|
||||
/* figma drew px 8; nudged +12 so the chip's folder glyph lines up closer to
|
||||
the card's inner controls below. */
|
||||
padding-left: 20px;
|
||||
|
||||
@@ -121,8 +121,8 @@ export function ConversationSessionHeader({
|
||||
* @returns the active view area, or null while the Session remains blank.
|
||||
*/
|
||||
export function ConversationSession({
|
||||
useSession, useInput, inputActions, useStore, actions,
|
||||
renderSlot, views, bindDraftMirror,
|
||||
sessionId, useSession, useInput, inputActions, useStore, actions,
|
||||
renderSlot, views, bindDraftMirror, releaseSessionImages,
|
||||
}: ConversationSessionProps) {
|
||||
useSyncExternalStore(views.subscribe, views.version)
|
||||
const tabs = views.list()
|
||||
@@ -143,6 +143,10 @@ export function ConversationSession({
|
||||
// the machine mirror, not this seed effect.
|
||||
}, [inputActions])
|
||||
|
||||
useEffect(() => () => {
|
||||
releaseSessionImages(sessionId)
|
||||
}, [releaseSessionImages, sessionId])
|
||||
|
||||
if (blank && composerPhase === 'blank') return null
|
||||
return (
|
||||
<div className={css.viewArea}>
|
||||
|
||||
@@ -105,7 +105,7 @@
|
||||
min-height: 28px;
|
||||
padding: 0 8px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
border-radius: 16px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-size: 13px;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 40px;
|
||||
background: color-mix(in srgb, var(--dsw-alias-label-primary) 74%, transparent);
|
||||
}
|
||||
|
||||
.image {
|
||||
max-width: min(100%, 1600px);
|
||||
max-height: calc(100vh - 80px);
|
||||
object-fit: contain;
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-specific-input-major);
|
||||
box-shadow: var(--dsw-shadow-lv3);
|
||||
}
|
||||
|
||||
.close {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
|
||||
border-radius: 999px;
|
||||
background: var(--dsw-specific-input-major);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import css from './ImageLightbox.module.css'
|
||||
|
||||
/** Document-level original-image preview opened by an explicit double-click. */
|
||||
export function ImageLightbox({ src, alt, onClose, t }: {
|
||||
src: string
|
||||
alt: string
|
||||
onClose: () => void
|
||||
t: ChatViewSlotProps['t']
|
||||
}) {
|
||||
const closeRef = useRef<HTMLButtonElement | null>(null)
|
||||
const restoreRef = useRef<HTMLElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
restoreRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||||
closeRef.current?.focus()
|
||||
const onKeyDown = (event: globalThis.KeyboardEvent): void => {
|
||||
if (event.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
restoreRef.current?.focus()
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css.backdrop}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('image.preview')}
|
||||
onMouseDown={(event) => { if (event.target === event.currentTarget) onClose() }}
|
||||
>
|
||||
<img className={css.image} src={src} alt={alt} />
|
||||
<button ref={closeRef} type="button" className={css.close} aria-label={t('image.closePreview')} onClick={onClose}>×</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -102,6 +102,25 @@
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
||||
}
|
||||
|
||||
.dragActive {
|
||||
border-color: var(--dsw-alias-state-business-primary);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-business-primary) 24%, transparent), var(--dsw-shadow-lv2);
|
||||
}
|
||||
|
||||
.dropHint {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
inset: 4px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 16px;
|
||||
background: color-mix(in srgb, var(--dsw-specific-input-major) 88%, var(--dsw-alias-state-business-primary));
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.accessory {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -109,6 +128,57 @@
|
||||
padding: 10px 12px 0;
|
||||
}
|
||||
|
||||
.attachments {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 12px 12px 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.attachment {
|
||||
position: relative;
|
||||
flex: 0 0 72px;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.thumbnail img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.remove {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--dsw-specific-input-major);
|
||||
border-radius: 999px;
|
||||
background: var(--dsw-alias-label-primary);
|
||||
color: var(--dsw-specific-input-major);
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Floating overlay anchor (menu / popupSelect shell): entries position
|
||||
themselves against the card (bottom: 100% + gap); closed entries render null. */
|
||||
.overlayAnchor {
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
* region-slot content) ride the owner props. Session facts
|
||||
* (running/removed/promptError) are self-selected via useSession. */
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { ChangeEvent, DragEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconPlusOutline16, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
// Type-only: the `plan` projection key merge (the TodoDock posture — the
|
||||
@@ -16,10 +16,11 @@ import type {} from '@deepseek-ai/dsh-plan-mode/client'
|
||||
// Type-only: the `goal` projection key merge (hint disambiguation).
|
||||
import type {} from '@deepseek-ai/dsh-goal/client'
|
||||
import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ComposerBarProps } from '../contract/slots.ts'
|
||||
import type { ComposerAttachment, ComposerBarProps } from '../contract/slots.ts'
|
||||
import { deriveDecorations } from '../input/decorations.ts'
|
||||
import type { DraftDecorations } from '../input/decorations.ts'
|
||||
import { ContextMeter } from './ContextMeter.tsx'
|
||||
import { ImageLightbox } from './ImageLightbox.tsx'
|
||||
import { PermissionSelect } from './PermissionSelect.tsx'
|
||||
import css from './InputBar.module.css'
|
||||
|
||||
@@ -35,7 +36,8 @@ export interface InputBarError {
|
||||
export type InputBarProps = ComposerBarProps
|
||||
|
||||
export function InputBar({
|
||||
useSession, useInput, inputActions, keyboard, resolveSubmitMode, toggleCommandMenu, stop, command, t,
|
||||
useSession, useInput, inputActions, keyboard, addImages, removeImage, draftImages,
|
||||
resolveSubmitMode, toggleCommandMenu, stop, command, t,
|
||||
renderSlot, useNotices, useLexicon, useMenuLauncher,
|
||||
useProjection, sessionId, variant, disabled: inert = false, blocked, placeholder,
|
||||
accessory, overlay, leftItems, rightItems, footer,
|
||||
@@ -63,8 +65,16 @@ export function InputBar({
|
||||
// current; the bar renders the same DOM inert instead of a parallel tree.
|
||||
const live = input !== undefined && keyboard !== undefined && inputActions !== undefined
|
||||
const draft = input?.draft ?? ''
|
||||
const empty = draft.trim() === ''
|
||||
const attachments = useMemo(
|
||||
() => input === undefined || draftImages === undefined ? [] : draftImages(input.imageIds),
|
||||
[draftImages, input?.imageIds],
|
||||
)
|
||||
const empty = draft.trim() === '' && attachments.length === 0
|
||||
const [preview, setPreview] = useState<ComposerAttachment | null>(null)
|
||||
const [dragActive, setDragActive] = useState(false)
|
||||
const [dropError, setDropError] = useState<string | null>(null)
|
||||
const inputRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
const dragDepthRef = useRef(0)
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null)
|
||||
const mirrorRef = useRef<HTMLDivElement | null>(null)
|
||||
// IME guard: composition Enter picks a candidate, it must not send. The ref outlives renders;
|
||||
@@ -102,6 +112,17 @@ export function InputBar({
|
||||
const canSteerQueue = !locked && !machineBusy && !commandMenuOpen && empty && running && subagent === null
|
||||
&& input.queue.some(row => row.placement === 'queued')
|
||||
|
||||
useEffect(() => {
|
||||
if (input === undefined || inputActions === undefined) return
|
||||
if (attachments.length !== input.imageIds.length) {
|
||||
inputActions.pruneImages(attachments.map(attachment => attachment.id))
|
||||
}
|
||||
}, [attachments, input?.imageIds, inputActions])
|
||||
|
||||
useEffect(() => {
|
||||
if (preview !== null && !attachments.some(attachment => attachment.id === preview.id)) setPreview(null)
|
||||
}, [attachments, preview])
|
||||
|
||||
// Scroll the draft scrollport the minimum that brings `caret` into view — the
|
||||
// browser's own behavior for typing, performed for the paths where it does
|
||||
// not act.
|
||||
@@ -330,8 +351,16 @@ export function InputBar({
|
||||
const onPaste = (e: React.ClipboardEvent<HTMLTextAreaElement>): void => {
|
||||
if (keyboard === undefined) return // absent machine: disabled textarea, no events
|
||||
if (machineBusy || locked) return
|
||||
const files = Array.from(e.clipboardData.items)
|
||||
.filter(item => item.kind === 'file')
|
||||
.map(item => item.getAsFile())
|
||||
.filter((file): file is File => file !== null)
|
||||
if (files.length > 0 && addImages !== undefined) setDropError(addImages(files))
|
||||
const text = e.clipboardData.getData('text/plain')
|
||||
if (text === '') return
|
||||
if (text === '') {
|
||||
if (files.length > 0) e.preventDefault()
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
const el = e.currentTarget
|
||||
const sel = selectionOf(el)
|
||||
@@ -345,6 +374,39 @@ export function InputBar({
|
||||
keyboard.track(keyboard.snapshot.draft, caret)
|
||||
}
|
||||
|
||||
const onDragEnter = (event: DragEvent<HTMLDivElement>): void => {
|
||||
if (!event.dataTransfer.types.includes('Files')) return
|
||||
event.preventDefault()
|
||||
if (locked || machineBusy || addImages === undefined) return
|
||||
dragDepthRef.current += 1
|
||||
setDropError(null)
|
||||
setDragActive(true)
|
||||
}
|
||||
|
||||
const onDragOver = (event: DragEvent<HTMLDivElement>): void => {
|
||||
if (!event.dataTransfer.types.includes('Files')) return
|
||||
event.preventDefault()
|
||||
event.dataTransfer.dropEffect = locked || machineBusy || addImages === undefined ? 'none' : 'copy'
|
||||
}
|
||||
|
||||
const onDragLeave = (event: DragEvent<HTMLDivElement>): void => {
|
||||
if (!event.dataTransfer.types.includes('Files') || locked || machineBusy) return
|
||||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
|
||||
if (dragDepthRef.current === 0) setDragActive(false)
|
||||
}
|
||||
|
||||
const onDrop = (event: DragEvent<HTMLDivElement>): void => {
|
||||
if (!event.dataTransfer.types.includes('Files')) return
|
||||
event.preventDefault()
|
||||
dragDepthRef.current = 0
|
||||
setDragActive(false)
|
||||
if (locked || machineBusy || addImages === undefined) return
|
||||
const dropped = [...event.dataTransfer.files]
|
||||
if (dropped.length > 0) setDropError(addImages(dropped))
|
||||
}
|
||||
|
||||
const closePreview = useCallback(() => { setPreview(null) }, [])
|
||||
|
||||
const onSelect = (e: React.SyntheticEvent<HTMLTextAreaElement>): void => {
|
||||
// Any caret/selection gesture ends a live paste attempt (the machine
|
||||
// cannot observe DOM selection). Cheap no-op when none is live.
|
||||
@@ -454,7 +516,7 @@ export function InputBar({
|
||||
}
|
||||
pushPlain(draft.length)
|
||||
if (deco.hint !== null) {
|
||||
// Claim tokens are shaped `/name ` (trailing space); trim to the bare name.
|
||||
// Claim tokens have the `/name ` format (trailing space); trim to the bare name.
|
||||
const commandName = input?.claim?.token.slice(1).trim() ?? ''
|
||||
const hintKey = `hint.${commandName === 'goal' && hasGoal ? 'goal.active' : commandName}`
|
||||
// Dynamic lookup by claimed command name: unknown commands miss the
|
||||
@@ -477,9 +539,43 @@ export function InputBar({
|
||||
{notice.text}
|
||||
</div>
|
||||
)}
|
||||
<div className={css.card} data-composer-card>
|
||||
{dropError !== null && <div className={css.error} role="alert">{dropError}</div>}
|
||||
<div
|
||||
className={clsx(css.card, dragActive && css.dragActive)}
|
||||
data-composer-card
|
||||
onDragEnter={onDragEnter}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
{dragActive && <div className={css.dropHint} role="status">{t('image.dropHint')}</div>}
|
||||
{overlay !== undefined && <div className={css.overlayAnchor}>{overlay}</div>}
|
||||
{accessory !== undefined && <div className={css.accessory}>{accessory}</div>}
|
||||
{attachments.length > 0 && (
|
||||
<div className={css.attachments} role="group" aria-label={t('image.pending')}>
|
||||
{attachments.map(attachment => (
|
||||
<div key={attachment.id} className={css.attachment}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.thumbnail}
|
||||
title={t('image.openOriginal')}
|
||||
onDoubleClick={() => { setPreview(attachment) }}
|
||||
>
|
||||
<img src={attachment.previewUrl} alt={attachment.file.name || t('image.pending')} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.remove}
|
||||
aria-label={t('image.remove', { name: attachment.file.name })}
|
||||
onClick={() => {
|
||||
setDropError(null)
|
||||
removeImage?.(attachment.id)
|
||||
}}
|
||||
>×</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* One scrollport, two text layers. The hidden mirror renders draft+'\n' and stretches the
|
||||
stack to the draft's FULL height (counting rows by '\n' cannot see soft wraps); the
|
||||
absolutely-positioned backdrop and textarea ride that height, and .scroll — capped at 14
|
||||
@@ -508,7 +604,10 @@ export function InputBar({
|
||||
? t('placeholder.steerQueue')
|
||||
: planActive ? t('placeholder.plan') : t('placeholder.default'))}
|
||||
rows={2}
|
||||
onChange={onChange}
|
||||
onChange={(event) => {
|
||||
setDropError(null)
|
||||
onChange(event)
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
onSelect={onSelect}
|
||||
onCopy={(e) => { onCopyOrCut(e, false) }}
|
||||
@@ -585,6 +684,14 @@ export function InputBar({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{preview !== null && (
|
||||
<ImageLightbox
|
||||
src={preview.previewUrl}
|
||||
alt={preview.file.name || t('image.original')}
|
||||
onClose={closePreview}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
{footer}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -51,6 +51,9 @@
|
||||
}
|
||||
|
||||
.chevron {
|
||||
/* inline-flex, not inline: an inline seat reserves baseline descent under
|
||||
the svg and floats the glyph off-center in the 28px trigger. */
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
transition: transform 120ms ease;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// framework-free. Visual: figma 772:51905 / 772:52972 / 772:53419.
|
||||
|
||||
import { useId, useState } from 'react'
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
// The domain's client-namespace pure-type outlet: one import edge delivers
|
||||
// the `todos` projection-key merge (single source, no consumer-side restated
|
||||
|
||||
@@ -9,8 +9,6 @@ import type { CallId, ChatStoreState, SelectionTarget } from './contract/views.t
|
||||
type ChatActions = {
|
||||
select: (draft: ChatStoreState, target: SelectionTarget | null) => void
|
||||
setDraft: (draft: ChatStoreState, text: string) => void
|
||||
clearDraft: (draft: ChatStoreState) => void
|
||||
restoreDraft: (draft: ChatStoreState, text: string) => void
|
||||
setView: (draft: ChatStoreState, view: string) => void
|
||||
setInspect: (draft: ChatStoreState, target: { callId: CallId } | null) => void
|
||||
}
|
||||
@@ -21,15 +19,14 @@ type ChatActions = {
|
||||
*/
|
||||
export function createChatStore(): EngineStoreHandle<ChatStoreState, ChatActions> {
|
||||
return defineStore({
|
||||
// Anchored to the contract shape: consumers read the store through
|
||||
// PropsStore<ChatStore>'s SnapshotSelectorHook<ChatStoreState>, so init
|
||||
// and the contract cannot drift.
|
||||
init: (): ChatStoreState => ({ selection: null, draft: '', view: null, inspect: null }),
|
||||
persist: 'dsh.conversation.chat',
|
||||
actions: {
|
||||
select: (d, target: SelectionTarget | null) => { d.selection = target },
|
||||
setDraft: (d, text: string) => { d.draft = text },
|
||||
clearDraft: (d) => { d.draft = '' },
|
||||
// Optimistic-send failure restore: only when the user typed nothing new
|
||||
// since the clear (send choreography lives in the inject factory).
|
||||
restoreDraft: (d, text: string) => { if (d.draft === '') d.draft = text },
|
||||
setView: (d, view: string) => { d.view = view },
|
||||
setInspect: (d, target: { callId: CallId } | null) => { d.inspect = target },
|
||||
},
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
/** Host loader entry for the browser-only conversation plugin. */
|
||||
/** Host registration for browser conversation preferences. */
|
||||
|
||||
/** Provides no host-side behavior. */
|
||||
export function apply(): void {}
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { CONVERSATION_SETTINGS_NAMESPACE, ConversationSettingsSchema } from './submission-settings.ts'
|
||||
|
||||
export {
|
||||
BUSY_ENTER_BEHAVIORS, BUSY_ENTER_FIELD, CONVERSATION_SETTINGS_NAMESPACE,
|
||||
DEFAULT_BUSY_ENTER_BEHAVIOR, type BusyEnterBehavior, type ConversationSettings,
|
||||
} from './submission-settings.ts'
|
||||
|
||||
/**
|
||||
* Register the durable conversation section when a settings provider exists.
|
||||
* @param ctx - Host context whose optional settings service owns the section.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.inject(['settings'], (settingsCtx) => {
|
||||
settingsCtx.settings.register(
|
||||
settingsNamespace(CONVERSATION_SETTINGS_NAMESPACE),
|
||||
ConversationSettingsSchema,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-conversation'
|
||||
|
||||
29
packages/client/ui-conversation/src/submission-settings.ts
Normal file
29
packages/client/ui-conversation/src/submission-settings.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/** Busy-Enter preference stored in the Host user-settings document. */
|
||||
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
|
||||
/** Settings namespace owned by the conversation plugin. */
|
||||
export const CONVERSATION_SETTINGS_NAMESPACE = 'ui-conversation'
|
||||
|
||||
/** Field carrying the delivery mode for plain Enter while an agent is busy. */
|
||||
export const BUSY_ENTER_FIELD = 'busyEnter'
|
||||
|
||||
/** Busy-Enter behaviors accepted at settings and input boundaries. */
|
||||
export const BUSY_ENTER_BEHAVIORS = ['queue', 'steer'] as const
|
||||
|
||||
/** Configurable meaning of plain Enter while the addressed agent is busy. */
|
||||
export type BusyEnterBehavior = typeof BUSY_ENTER_BEHAVIORS[number]
|
||||
|
||||
/** Default preserves Enter-as-Queue for running conversations. */
|
||||
export const DEFAULT_BUSY_ENTER_BEHAVIOR: BusyEnterBehavior = 'queue'
|
||||
|
||||
/** Durable conversation section shared by the Host schema and the browser scope. */
|
||||
export interface ConversationSettings {
|
||||
/** Delivery mode for plain Enter while the addressed agent is busy. */
|
||||
busyEnter: BusyEnterBehavior
|
||||
}
|
||||
|
||||
/** Durable conversation schema; also the wire envelope the browser scope validates against. */
|
||||
export const ConversationSettingsSchema: z<ConversationSettings> = z.object({
|
||||
[BUSY_ENTER_FIELD]: z.union([...BUSY_ENTER_BEHAVIORS]).default(DEFAULT_BUSY_ENTER_BEHAVIOR),
|
||||
})
|
||||
@@ -47,6 +47,7 @@ function sessionFakeFor() {
|
||||
|
||||
async function bench() {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
const sessionFake = sessionFakeFor()
|
||||
await runtime.sessions.add({
|
||||
id: ROOT,
|
||||
|
||||
@@ -50,6 +50,7 @@ function WorkspaceProbe({ open }: EmptyWorkspaceOwnerProps) {
|
||||
|
||||
async function bench(opts?: { blank?: boolean }) {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
const locale = new LocaleService(runtime.ctx)
|
||||
runtime.provide('locale', locale)
|
||||
@@ -74,6 +75,7 @@ async function bench(opts?: { blank?: boolean }) {
|
||||
describe('resident composer', () => {
|
||||
it('renders the locked view state while no session exists at all', async () => {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
const locale = new LocaleService(runtime.ctx)
|
||||
runtime.provide('locale', locale)
|
||||
@@ -90,6 +92,7 @@ describe('resident composer', () => {
|
||||
|
||||
it('keeps the complete Hero tree mounted when the first Workspace session appears', async () => {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
const locale = new LocaleService(runtime.ctx)
|
||||
runtime.provide('locale', locale)
|
||||
@@ -153,6 +156,7 @@ describe('resident composer', () => {
|
||||
describe('prompt rejection through the assembled composer', () => {
|
||||
it('renders the promptError alert strip and keeps the draft in the machine', async () => {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
const locale = new LocaleService(runtime.ctx)
|
||||
runtime.provide('locale', locale)
|
||||
|
||||
@@ -21,6 +21,7 @@ const CHILD = 'child-1' as SessionId
|
||||
|
||||
async function bench() {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
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 })
|
||||
|
||||
@@ -229,7 +229,7 @@ describe('MessageItem arms', () => {
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('consumed steering is captioned as an interjection and keeps copy without branch', () => {
|
||||
it('consumed steering renders as a plain user bubble and keeps copy without branch', () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
@@ -242,7 +242,7 @@ describe('MessageItem arms', () => {
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
expect(view.getByText('插话')).toBeTruthy()
|
||||
expect(view.queryByText('插话')).toBeNull()
|
||||
expect(view.getByText('steer!')).toBeTruthy()
|
||||
expect(view.getByText(/附加内容块/)).toBeTruthy()
|
||||
fireEvent.click(view.getByRole('button', { name: '复制' }))
|
||||
|
||||
@@ -7,6 +7,7 @@ import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import type {
|
||||
AssistantMessageNode, ConversationSnapshot, SessionId, ToolResultNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { EMPTY_CONVERSATION_VIEWS } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
|
||||
@@ -43,7 +44,7 @@ const assistant = (seq: number, turn: number, usage?: unknown): AssistantMessage
|
||||
|
||||
function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, chat: chatSnapshotFixture(),
|
||||
sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, chat: chatSnapshotFixture(),
|
||||
nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
|
||||
@@ -25,8 +25,6 @@ describe('createChatStore', () => {
|
||||
|
||||
store.actions.setDraft('hello')
|
||||
expect(store.store.getSnapshot().draft).toBe('hello')
|
||||
store.actions.clearDraft()
|
||||
expect(store.store.getSnapshot().draft).toBe('')
|
||||
|
||||
store.actions.setView('chat')
|
||||
expect(store.store.getSnapshot().view).toBe('chat')
|
||||
@@ -37,17 +35,6 @@ describe('createChatStore', () => {
|
||||
expect(store.store.getSnapshot().inspect).toBeNull()
|
||||
})
|
||||
|
||||
it('restoreDraft only fills an empty draft (optimistic-send rollback contract)', () => {
|
||||
const store = createChatStore().create()
|
||||
// Rollback path: draft was cleared by send, nothing typed since.
|
||||
store.actions.restoreDraft('failed text')
|
||||
expect(store.store.getSnapshot().draft).toBe('failed text')
|
||||
// The user typed something new before the failure landed: keep theirs.
|
||||
store.actions.setDraft('newer input')
|
||||
store.actions.restoreDraft('stale text')
|
||||
expect(store.store.getSnapshot().draft).toBe('newer input')
|
||||
})
|
||||
|
||||
it('persists per scope key and rehydrates a fresh instance', () => {
|
||||
const handle = createChatStore()
|
||||
const s1 = handle.create('sess-1')
|
||||
|
||||
@@ -12,7 +12,9 @@ import type {
|
||||
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'
|
||||
import {
|
||||
createSnapshotStore, EMPTY_CONVERSATION_VIEWS, PendingWait,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
ChatNode, ChatNodeOwnerProps, ChatNodeViewProps, ChatViewSlotProps, SelectionTarget, UseChatNodeTurnData,
|
||||
@@ -47,7 +49,8 @@ type RoutedChatNodeOwner = ChatNodeOwnerProps & { readonly node: ChatNode }
|
||||
|
||||
function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, chat: chatSnapshotFixture(), nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, chat: chatSnapshotFixture(), nodes: [],
|
||||
turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
}
|
||||
@@ -261,7 +264,13 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
||||
useWorkspaces: emptyWorkspaces(),
|
||||
useProjection: (() => undefined),
|
||||
useInput: (() => { throw new Error('unused') }),
|
||||
inputActions: { setDraft: () => {}, submit: () => {} },
|
||||
inputActions: {
|
||||
setDraft: () => {},
|
||||
addImages: () => true,
|
||||
removeImage: () => {},
|
||||
pruneImages: () => {},
|
||||
submit: () => {},
|
||||
},
|
||||
useStore: bindSnapshotSelector(chat),
|
||||
actions: chat.actions,
|
||||
renderSlot,
|
||||
@@ -269,6 +278,7 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
||||
openDetails,
|
||||
openFile,
|
||||
loadOlder,
|
||||
loadImage: vi.fn(() => Promise.reject(new Error('not used'))),
|
||||
inspectCall,
|
||||
chatScroll,
|
||||
forkAt,
|
||||
@@ -459,9 +469,6 @@ describe('ChatView', () => {
|
||||
expect(view.queryByText('later')).toBeNull()
|
||||
const pendingBubble = view.getByText('interrupt now').closest('[data-pending-steering]')
|
||||
expect(pendingBubble).not.toBeNull()
|
||||
// Pending and durable steering carry the same interjection caption, so the
|
||||
// hand-off does not change what the row says it is.
|
||||
expect(within(pendingBubble as HTMLElement).getByText('插话')).toBeTruthy()
|
||||
fireEvent.click(within(pendingBubble as HTMLElement).getByRole('button', { name: '复制' }))
|
||||
expect(writeText).toHaveBeenCalledWith('interrupt now')
|
||||
expect(within(pendingBubble as HTMLElement).queryByRole('button', { name: '在新对话中分支' })).toBeNull()
|
||||
@@ -483,7 +490,6 @@ describe('ChatView', () => {
|
||||
})
|
||||
expect(view.getAllByText('interrupt now')).toHaveLength(1)
|
||||
expect(view.container.querySelector('[data-pending-steering]')).toBeNull()
|
||||
expect(view.getAllByText('插话')).toHaveLength(1)
|
||||
// Only the durable steering bubble: the turn is still running, so its
|
||||
// assistant narration owns no footer yet, and a steering bubble never
|
||||
// carries a branch action.
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// @vitest-environment jsdom
|
||||
// Branch tails the acceptance specs do not reach: the node-half empty apply
|
||||
// and AssistantMarkdown reasoning/unknown block arms.
|
||||
// Branch tails the acceptance specs do not reach: the node-half apply
|
||||
// without a settings service and AssistantMarkdown reasoning/unknown block arms.
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
@@ -16,8 +17,8 @@ const t: AssistantMarkdownProps['t'] = makeTranslate(zh, commonZh)
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('tails', () => {
|
||||
it('node-half apply is an intentional no-op', () => {
|
||||
expect(() => { nodeApply() }).not.toThrow()
|
||||
it('node-half apply tolerates a Host without settings', () => {
|
||||
expect(() => { nodeApply(new Context()) }).not.toThrow()
|
||||
})
|
||||
|
||||
it('AssistantMarkdown renders reasoning as a Think row and unknown blocks as JSON fallback', () => {
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore, EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
createSnapshotStore, EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { ConversationSnapshot, SessionId, SessionListState, WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionProviderComponent } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
@@ -48,7 +50,7 @@ function renderToolDetailsProbe(owners?: DetailsToolOwnerProps[]): DetailsSlotPr
|
||||
|
||||
function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, chat: EMPTY_CHAT_SNAPSHOT,
|
||||
sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, chat: EMPTY_CHAT_SNAPSHOT,
|
||||
nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
@@ -122,7 +124,13 @@ describe('render branch tails', () => {
|
||||
useWorkspaces={bindSnapshotSelector(emptyWorkspaces)}
|
||||
useProjection={(() => undefined)}
|
||||
useInput={(() => { throw new Error('unused') })}
|
||||
inputActions={{ setDraft: () => {}, submit: () => {} }}
|
||||
inputActions={{
|
||||
setDraft: () => {},
|
||||
addImages: () => true,
|
||||
removeImage: () => {},
|
||||
pruneImages: () => {},
|
||||
submit: () => {},
|
||||
}}
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
closeDetails={vi.fn()}
|
||||
@@ -173,7 +181,13 @@ describe('render branch tails', () => {
|
||||
useWorkspaces={bindSnapshotSelector(emptyWorkspaces)}
|
||||
useProjection={(() => undefined)}
|
||||
useInput={(() => { throw new Error('unused') })}
|
||||
inputActions={{ setDraft: () => {}, submit: () => {} }}
|
||||
inputActions={{
|
||||
setDraft: () => {},
|
||||
addImages: () => true,
|
||||
removeImage: () => {},
|
||||
pruneImages: () => {},
|
||||
submit: () => {},
|
||||
}}
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
closeDetails={vi.fn()}
|
||||
|
||||
30
packages/client/ui-conversation/tests/host.spec.ts
Normal file
30
packages/client/ui-conversation/tests/host.spec.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Settings, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import {
|
||||
CONVERSATION_SETTINGS_NAMESPACE, DEFAULT_BUSY_ENTER_BEHAVIOR, apply,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation'
|
||||
|
||||
class MemorySettings extends Settings {
|
||||
readonly writable = true
|
||||
protected load(): Promise<Record<string, unknown>> { return Promise.resolve({}) }
|
||||
protected persist(_ns: SettingsNamespace, _section: Record<string, unknown>): Promise<void> {
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
describe('ui-conversation host', () => {
|
||||
it('registers, validates, and disposes the durable busy-Enter preference', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(MemorySettings).await()
|
||||
const fiber = ctx.plugin({ apply })
|
||||
await fiber.await()
|
||||
const ns = settingsNamespace(CONVERSATION_SETTINGS_NAMESPACE)
|
||||
expect(ctx.settings.get(ns)).toEqual({ busyEnter: DEFAULT_BUSY_ENTER_BEHAVIOR })
|
||||
await ctx.settings.update(ns, { busyEnter: 'steer' })
|
||||
expect(ctx.settings.get(ns)).toEqual({ busyEnter: 'steer' })
|
||||
await expect(ctx.settings.update(ns, { busyEnter: 'invalid' })).rejects.toThrow()
|
||||
await fiber.dispose()
|
||||
expect(ctx.settings.describe().map(row => row.ns)).not.toContain(ns)
|
||||
})
|
||||
})
|
||||
@@ -7,11 +7,15 @@
|
||||
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore, EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
createSnapshotStore, EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import type { ClientContext, ConversationSnapshot, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { SessionInputShell } from '../src/client/input/facade.ts'
|
||||
import type { ComposerAttachment } from '../src/client/contract/slots.ts'
|
||||
import type { DraftAttachmentId } from '../src/client/input/contract.ts'
|
||||
import { InputBar } from '../src/client/skeleton/InputBar.tsx'
|
||||
import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
@@ -35,7 +39,7 @@ const SID = 's1' as SessionId
|
||||
|
||||
function snapshotOf(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, chat: EMPTY_CHAT_SNAPSHOT,
|
||||
sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, chat: EMPTY_CHAT_SNAPSHOT,
|
||||
nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
@@ -69,6 +73,8 @@ interface BenchOptions {
|
||||
overlay?: React.ReactNode
|
||||
leftItems?: React.ReactNode
|
||||
rightItems?: React.ReactNode
|
||||
attachments?: readonly ComposerAttachment[]
|
||||
addImages?: (files: readonly File[]) => string | null
|
||||
commandMenuOpen?: boolean
|
||||
busyEnter?: 'queue' | 'steer'
|
||||
toggleCommandMenu?: (selection: { start: number; end: number }) => void
|
||||
@@ -113,7 +119,9 @@ function bench(over?: BenchOptions) {
|
||||
: {}),
|
||||
})
|
||||
if (over?.draft !== undefined && over.draft !== '') shell.setDraft(over.draft)
|
||||
if (over?.attachments !== undefined) shell.addImages(over.attachments.map(attachment => attachment.id))
|
||||
const stop = vi.fn()
|
||||
const removeImage = vi.fn((id: DraftAttachmentId) => { shell.removeImage(id) })
|
||||
const menuLauncher = createSnapshotStore<string | null>(over?.commandMenuOpen === true ? 'command' : null)
|
||||
const slotCalls: { key: string; owner: unknown }[] = []
|
||||
const renderSlot = ((key: string, owner: object) => {
|
||||
@@ -139,6 +147,12 @@ function bench(over?: BenchOptions) {
|
||||
useInput: bindSnapshotSelector(shell.state),
|
||||
inputActions: shell.actions,
|
||||
keyboard: shell,
|
||||
addImages: over?.addImages ?? (() => null),
|
||||
removeImage,
|
||||
draftImages: ids => ids.flatMap((id) => {
|
||||
const attachment = over?.attachments?.find(candidate => candidate.id === id)
|
||||
return attachment === undefined ? [] : [attachment]
|
||||
}),
|
||||
resolveSubmitMode: (running, gesture, steeringAvailable) => {
|
||||
if (!running || !steeringAvailable) return 'queue'
|
||||
const preferred = over?.busyEnter ?? 'queue'
|
||||
@@ -168,11 +182,66 @@ function bench(over?: BenchOptions) {
|
||||
)!
|
||||
const interruptButton = view.container.querySelector<HTMLButtonElement>('button[aria-label="停止生成"]')
|
||||
return {
|
||||
view, textarea, button, interruptButton, props, sink, shell, wiring: shell, session, stop, slotCalls, menuLauncher,
|
||||
view, textarea, button, interruptButton, props, sink, shell, wiring: shell, session, stop, removeImage, slotCalls,
|
||||
menuLauncher,
|
||||
steerQueue: over?.steerQueue,
|
||||
}
|
||||
}
|
||||
|
||||
describe('image draft rail', () => {
|
||||
it('collects clipboard files while preserving text from a mixed paste', () => {
|
||||
const addImages = vi.fn(() => null)
|
||||
const { textarea, shell } = bench({ addImages })
|
||||
const image = new File([Uint8Array.of(1, 2, 3)], 'pixel.png', { type: 'image/png' })
|
||||
fireEvent.paste(textarea, {
|
||||
clipboardData: {
|
||||
items: [
|
||||
{ kind: 'string', type: 'text/plain', getAsFile: () => null },
|
||||
{ kind: 'file', type: 'image/png', getAsFile: () => image },
|
||||
],
|
||||
getData: () => '同时粘贴的文字',
|
||||
},
|
||||
})
|
||||
expect(addImages).toHaveBeenCalledWith([image])
|
||||
expect(shell.snapshot.draft).toBe('同时粘贴的文字')
|
||||
})
|
||||
|
||||
it('accepts file drops and prevents browser navigation', () => {
|
||||
const addImages = vi.fn(() => null)
|
||||
const { view } = bench({ addImages })
|
||||
const card = view.container.querySelector('[class*="card"]')!
|
||||
const image = new File([Uint8Array.of(1)], 'dropped.png', { type: 'image/png' })
|
||||
const dataTransfer = { types: ['Files'], files: [image], dropEffect: 'none' }
|
||||
expect(fireEvent.dragEnter(card, { dataTransfer })).toBe(false)
|
||||
expect(view.getByRole('status').textContent).toContain('松开以添加图片')
|
||||
expect(fireEvent.dragOver(card, { dataTransfer })).toBe(false)
|
||||
expect(dataTransfer.dropEffect).toBe('copy')
|
||||
expect(fireEvent.drop(card, { dataTransfer })).toBe(false)
|
||||
expect(addImages).toHaveBeenCalledWith([image])
|
||||
})
|
||||
|
||||
it('sends an image-only draft and removes its thumbnail', () => {
|
||||
const file = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' })
|
||||
const attachment = { kind: 'image' as const, id: 'draft-1' as DraftAttachmentId, file, previewUrl: 'blob:draft-1' }
|
||||
const { view, textarea, sink, removeImage } = bench({ attachments: [attachment] })
|
||||
expect((view.getByRole('button', { name: '发送消息' }) as HTMLButtonElement).disabled).toBe(false)
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||
expect(sink).toHaveBeenCalledWith('', ['draft-1'], 'queue')
|
||||
fireEvent.click(view.getByRole('button', { name: '移除图片 pixel.png' }))
|
||||
expect(removeImage).toHaveBeenCalledWith('draft-1')
|
||||
})
|
||||
|
||||
it('opens the original image on double-click and closes it with Escape', () => {
|
||||
const file = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' })
|
||||
const attachment = { kind: 'image' as const, id: 'draft-1' as DraftAttachmentId, file, previewUrl: 'blob:draft-1' }
|
||||
const { view } = bench({ attachments: [attachment] })
|
||||
fireEvent.doubleClick(view.getByTitle('双击查看原图'))
|
||||
expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy()
|
||||
fireEvent.keyDown(window, { key: 'Escape' })
|
||||
expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Enter semantics', () => {
|
||||
it('advertises the empty-draft whole-queue steering gesture when it is available', () => {
|
||||
const { textarea } = bench({ running: true, queue: [row('q-1')], steerQueue: vi.fn() })
|
||||
@@ -228,7 +297,7 @@ describe('Enter semantics', () => {
|
||||
it('plain Enter submits queue mode through the machine; repeat and empty are suppressed', () => {
|
||||
const { textarea, sink } = bench({ draft: 'hello' })
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||
expect(sink).toHaveBeenCalledWith('hello', 'queue')
|
||||
expect(sink).toHaveBeenCalledWith('hello', [], 'queue')
|
||||
fireEvent.keyDown(textarea, { key: 'Enter', repeat: true })
|
||||
expect(sink).toHaveBeenCalledTimes(1)
|
||||
const empty = bench({ draft: ' ' })
|
||||
@@ -253,15 +322,15 @@ describe('Enter semantics', () => {
|
||||
it('Ctrl/Meta+Enter sends normally while idle and steers while running', () => {
|
||||
const idle = bench({ draft: 'hello' })
|
||||
fireEvent.keyDown(idle.textarea, { key: 'Enter', metaKey: true })
|
||||
expect(idle.sink).toHaveBeenCalledWith('hello', 'queue')
|
||||
expect(idle.sink).toHaveBeenCalledWith('hello', [], 'queue')
|
||||
|
||||
const busyCtrl = bench({ running: true, draft: 'steer with ctrl' })
|
||||
fireEvent.keyDown(busyCtrl.textarea, { key: 'Enter', ctrlKey: true })
|
||||
expect(busyCtrl.sink).toHaveBeenCalledWith('steer with ctrl', 'steer')
|
||||
expect(busyCtrl.sink).toHaveBeenCalledWith('steer with ctrl', [], 'steer')
|
||||
|
||||
const busyMeta = bench({ running: true, draft: 'steer with cmd' })
|
||||
fireEvent.keyDown(busyMeta.textarea, { key: 'Enter', metaKey: true })
|
||||
expect(busyMeta.sink).toHaveBeenCalledWith('steer with cmd', 'steer')
|
||||
expect(busyMeta.sink).toHaveBeenCalledWith('steer with cmd', [], 'steer')
|
||||
})
|
||||
|
||||
it('empty-draft Cmd/Ctrl+Enter steers the whole queue instead of submitting', () => {
|
||||
@@ -326,7 +395,7 @@ describe('Enter semantics', () => {
|
||||
const steerQueue = vi.fn()
|
||||
const { textarea, sink } = bench({ running: true, queue: [row('q-1')], draft: '插话', steerQueue })
|
||||
fireEvent.keyDown(textarea, { key: 'Enter', ctrlKey: true })
|
||||
expect(sink).toHaveBeenCalledWith('插话', 'steer')
|
||||
expect(sink).toHaveBeenCalledWith('插话', [], 'steer')
|
||||
expect(steerQueue).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -374,7 +443,7 @@ describe('running and lock semantics', () => {
|
||||
expect(textarea.disabled).toBe(false)
|
||||
fireEvent.change(textarea, { target: { value: '排队消息2' } })
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||
expect(sink).toHaveBeenCalledWith('排队消息2', 'queue')
|
||||
expect(sink).toHaveBeenCalledWith('排队消息2', [], 'queue')
|
||||
expect(button.getAttribute('aria-label')).toBe('停止生成')
|
||||
fireEvent.click(button)
|
||||
expect(stop).toHaveBeenCalledTimes(1)
|
||||
@@ -383,17 +452,17 @@ describe('running and lock semantics', () => {
|
||||
it('running plain Enter follows the busy-state Steer preference', () => {
|
||||
const { textarea, sink } = bench({ running: true, busyEnter: 'steer', draft: '直接插话' })
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||
expect(sink).toHaveBeenCalledWith('直接插话', 'steer')
|
||||
expect(sink).toHaveBeenCalledWith('直接插话', [], 'steer')
|
||||
})
|
||||
|
||||
it('running Cmd/Ctrl+Enter uses the opposite of the busy-state Enter preference', () => {
|
||||
const meta = bench({ running: true, busyEnter: 'steer', draft: '排到下一轮' })
|
||||
fireEvent.keyDown(meta.textarea, { key: 'Enter', metaKey: true })
|
||||
expect(meta.sink).toHaveBeenCalledWith('排到下一轮', 'queue')
|
||||
expect(meta.sink).toHaveBeenCalledWith('排到下一轮', [], 'queue')
|
||||
|
||||
const ctrl = bench({ running: true, busyEnter: 'steer', draft: 'also queue' })
|
||||
fireEvent.keyDown(ctrl.textarea, { key: 'Enter', ctrlKey: true })
|
||||
expect(ctrl.sink).toHaveBeenCalledWith('also queue', 'queue')
|
||||
expect(ctrl.sink).toHaveBeenCalledWith('also queue', [], 'queue')
|
||||
})
|
||||
|
||||
it('running continuable subagent keeps Send beside an independent Stop', () => {
|
||||
@@ -413,7 +482,7 @@ describe('running and lock semantics', () => {
|
||||
expect(interruptButton).not.toBeNull()
|
||||
expect(textarea.disabled).toBe(false)
|
||||
fireEvent.click(button)
|
||||
expect(sink).toHaveBeenCalledWith('后续消息', 'queue')
|
||||
expect(sink).toHaveBeenCalledWith('后续消息', [], 'queue')
|
||||
fireEvent.click(interruptButton!)
|
||||
expect(stop).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
@@ -470,11 +539,11 @@ describe('running and lock semantics', () => {
|
||||
}
|
||||
const plain = bench({ running: true, busyEnter: 'steer', draft: 'plain', subagent })
|
||||
fireEvent.keyDown(plain.textarea, { key: 'Enter' })
|
||||
expect(plain.sink).toHaveBeenCalledWith('plain', 'queue')
|
||||
expect(plain.sink).toHaveBeenCalledWith('plain', [], 'queue')
|
||||
|
||||
const accelerated = bench({ running: true, draft: 'accelerated', subagent })
|
||||
fireEvent.keyDown(accelerated.textarea, { key: 'Enter', metaKey: true })
|
||||
expect(accelerated.sink).toHaveBeenCalledWith('accelerated', 'queue')
|
||||
expect(accelerated.sink).toHaveBeenCalledWith('accelerated', [], 'queue')
|
||||
})
|
||||
|
||||
it('disabled (session removed) locks the textarea and chrome', () => {
|
||||
@@ -487,7 +556,7 @@ describe('running and lock semantics', () => {
|
||||
it('idle primary sends and disables on empty draft', () => {
|
||||
const { button, sink } = bench({ draft: 'go' })
|
||||
fireEvent.click(button)
|
||||
expect(sink).toHaveBeenCalledWith('go', 'queue')
|
||||
expect(sink).toHaveBeenCalledWith('go', [], 'queue')
|
||||
const empty = bench()
|
||||
expect(empty.button.disabled).toBe(true)
|
||||
})
|
||||
@@ -612,7 +681,7 @@ describe('running and lock semantics', () => {
|
||||
}
|
||||
// Pasted text lands below the fold: scroll down by exactly the overshoot.
|
||||
caretAt(500)
|
||||
fireEvent.paste(textarea, { clipboardData: { getData: () => 'pasted' } })
|
||||
fireEvent.paste(textarea, { clipboardData: { items: [], getData: () => 'pasted' } })
|
||||
await settle()
|
||||
expect(scroll.scrollTop).toBe(88) // 524 - 436
|
||||
// Measured on the mirror's own text, at the index the paste left the caret
|
||||
@@ -621,12 +690,12 @@ describe('running and lock semantics', () => {
|
||||
expect(measured!.offset).toBe('pasted'.length)
|
||||
// A caret already inside the box does not move it.
|
||||
caretAt(200)
|
||||
fireEvent.paste(textarea, { clipboardData: { getData: () => 'more' } })
|
||||
fireEvent.paste(textarea, { clipboardData: { items: [], getData: () => 'more' } })
|
||||
await settle()
|
||||
expect(scroll.scrollTop).toBe(88)
|
||||
// Above the fold (a cut can leave it there): scroll back up.
|
||||
caretAt(60)
|
||||
fireEvent.paste(textarea, { clipboardData: { getData: () => 'again' } })
|
||||
fireEvent.paste(textarea, { clipboardData: { items: [], getData: () => 'again' } })
|
||||
await settle()
|
||||
expect(scroll.scrollTop).toBe(48) // 88 - (100 - 60)
|
||||
// A caret straight after a newline has nothing on its line to measure, so
|
||||
@@ -634,7 +703,7 @@ describe('running and lock semantics', () => {
|
||||
// chromium reports no client rects at all for the collapsed position.
|
||||
mirror.style.lineHeight = '24px'
|
||||
caretAt(500)
|
||||
fireEvent.paste(textarea, { clipboardData: { getData: () => 'block\n' } })
|
||||
fireEvent.paste(textarea, { clipboardData: { items: [], getData: () => 'block\n' } })
|
||||
await settle()
|
||||
// The four pastes accumulate at the draft's head, so the caret is at the
|
||||
// end of what they inserted — and the measured index is the newline before it.
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore, EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
createSnapshotStore, EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ClientContext, ConversationSnapshot, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SubmitOutcome } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
@@ -26,7 +28,7 @@ const SID = 's1' as SessionId
|
||||
/** Standard-props InputBar mount over a real shell (the composer-bar entry shape). */
|
||||
function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled?: boolean }) {
|
||||
const session = createSnapshotStore<ConversationSnapshot>({
|
||||
sessionId: SID, chat: EMPTY_CHAT_SNAPSHOT,
|
||||
sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, chat: EMPTY_CHAT_SNAPSHOT,
|
||||
nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
pending: [], queue: [], running: over?.running ?? false, composerPhase: 'active',
|
||||
removed: over?.disabled ?? false, openState: 'open', openError: null, hasMore: false,
|
||||
@@ -48,6 +50,9 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
|
||||
useInput: bindSnapshotSelector(shell.state),
|
||||
inputActions: shell.actions,
|
||||
keyboard: shell,
|
||||
addImages: () => null,
|
||||
removeImage: () => {},
|
||||
draftImages: () => [],
|
||||
resolveSubmitMode: () => 'queue',
|
||||
toggleCommandMenu: vi.fn(),
|
||||
useNotices: bindSnapshotSelector(shell.notices),
|
||||
@@ -90,7 +95,7 @@ describe('matrix row: plain', () => {
|
||||
fireEvent.change(textarea, { target: { value: '普通消息' } })
|
||||
expect(shell.snapshot.claim).toBeUndefined()
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||
expect(sink).toHaveBeenCalledWith('普通消息', 'queue')
|
||||
expect(sink).toHaveBeenCalledWith('普通消息', [], 'queue')
|
||||
expect(shell.snapshot.phase).toBe('plain')
|
||||
})
|
||||
})
|
||||
@@ -189,7 +194,7 @@ describe('matrix row: locked (session disabled)', () => {
|
||||
expect((textarea).disabled).toBe(false)
|
||||
fireEvent.change(textarea, { target: { value: '排队' } })
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||
expect(sink).toHaveBeenCalledWith('排队', 'queue')
|
||||
expect(sink).toHaveBeenCalledWith('排队', [], 'queue')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -8,10 +8,12 @@
|
||||
* itself is not a dependency of this package; the source below is the
|
||||
* decision-table contract at the `SlashSource` boundary.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { EMPTY_CHAT_SNAPSHOT, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS, SessionsService,
|
||||
} 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'
|
||||
@@ -112,7 +114,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
|
||||
actx.on('slash/input-consume-token', req => shell.consumeToken(req.guard) ? true : undefined)
|
||||
const wiring = shell
|
||||
const sessionStore = createSnapshotStore<ConversationSnapshot>({
|
||||
sessionId, chat: EMPTY_CHAT_SNAPSHOT,
|
||||
sessionId, views: EMPTY_CONVERSATION_VIEWS, chat: EMPTY_CHAT_SNAPSHOT,
|
||||
nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
@@ -134,6 +136,9 @@ async function scopedBench(register?: (slash: SlashService) => void) {
|
||||
useInput: bindSnapshotSelector(shell.state),
|
||||
inputActions: shell.actions,
|
||||
keyboard: shell,
|
||||
addImages: () => null,
|
||||
removeImage: () => {},
|
||||
draftImages: () => [],
|
||||
resolveSubmitMode: () => 'queue',
|
||||
toggleCommandMenu: (selection) => {
|
||||
const snapshot = shell.snapshot
|
||||
@@ -237,7 +242,7 @@ describe('scenario D: execute-kind /compact', () => {
|
||||
act(() => { b2.shell.setDraft('/compact 现在') })
|
||||
fireEvent.keyDown(b2.textarea, { key: 'Enter' })
|
||||
// execute with trailing → matchEnter answers undefined → default sink.
|
||||
await vi.waitFor(() => { expect(b2.sink).toHaveBeenCalledWith('/compact 现在', 'queue') })
|
||||
await vi.waitFor(() => { expect(b2.sink).toHaveBeenCalledWith('/compact 现在', [], 'queue') })
|
||||
expect(b2.executed).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -291,7 +296,7 @@ describe('scenario I: unknown /xyz + enter', () => {
|
||||
const b = await bench()
|
||||
act(() => { b.shell.setDraft('/xyz 干点啥') })
|
||||
fireEvent.keyDown(b.textarea, { key: 'Enter' })
|
||||
await vi.waitFor(() => { expect(b.sink).toHaveBeenCalledWith('/xyz 干点啥', 'queue') })
|
||||
await vi.waitFor(() => { expect(b.sink).toHaveBeenCalledWith('/xyz 干点啥', [], 'queue') })
|
||||
expect(b.shell.snapshot.phase).toBe('plain')
|
||||
expect(b.execute).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
81
packages/client/ui-conversation/tests/message-image.spec.tsx
Normal file
81
packages/client/ui-conversation/tests/message-image.spec.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
|
||||
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { MessageImage } from '../src/client/chat/MessageImage.tsx'
|
||||
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
|
||||
import { en, zh } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const t = makeTranslate(zh, commonZh)
|
||||
const enT = makeTranslate(en, commonZh)
|
||||
|
||||
const attachment = {
|
||||
attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`),
|
||||
mediaType: 'image/png' as const,
|
||||
bytes: 68,
|
||||
width: 640,
|
||||
height: 320,
|
||||
name: 'history.png',
|
||||
}
|
||||
|
||||
describe('MessageImage', () => {
|
||||
it('loads a session-authorized URL, bounds the thumbnail, and double-clicks into the original', async () => {
|
||||
const load = vi.fn().mockResolvedValue('blob:history')
|
||||
const view = render(<MessageImage attachment={attachment} load={load} t={t} />)
|
||||
const frame = view.getByRole('button', { name: 'history.png,双击查看原图' })
|
||||
expect(frame.getAttribute('style')).toContain('width: 240px')
|
||||
expect(frame.getAttribute('style')).toContain('height: 120px')
|
||||
await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() })
|
||||
expect(load).toHaveBeenCalledWith(attachment)
|
||||
fireEvent.doubleClick(frame)
|
||||
expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy()
|
||||
fireEvent.click(view.getByRole('button', { name: '关闭原图预览' }))
|
||||
expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull()
|
||||
})
|
||||
|
||||
it('surfaces a retry control when durable bytes cannot be read', async () => {
|
||||
const load = vi.fn()
|
||||
.mockRejectedValueOnce(new Error('offline'))
|
||||
.mockResolvedValueOnce('blob:retry')
|
||||
const view = render(<MessageImage attachment={attachment} load={load} t={t} />)
|
||||
const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' })
|
||||
fireEvent.click(retry)
|
||||
await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() })
|
||||
expect(load).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('renders image controls from the active English dictionary', async () => {
|
||||
const load = vi.fn().mockResolvedValue('blob:history')
|
||||
const view = render(<MessageImage attachment={attachment} load={load} t={enT} />)
|
||||
const frame = view.getByRole('button', { name: 'history.png, double-click to view original' })
|
||||
await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() })
|
||||
fireEvent.doubleClick(frame)
|
||||
expect(view.getByRole('dialog', { name: 'Original image preview' })).toBeTruthy()
|
||||
expect(view.getByRole('button', { name: 'Close original image preview' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps assistant images at their original position between text blocks', async () => {
|
||||
const view = render(
|
||||
<AssistantMarkdown
|
||||
t={t}
|
||||
blocks={[
|
||||
{ kind: 'text', text: 'before' },
|
||||
{ kind: 'image', attachment },
|
||||
{ kind: 'text', text: 'after' },
|
||||
]}
|
||||
streaming={false}
|
||||
loadImage={() => Promise.resolve('blob:middle')}
|
||||
/>,
|
||||
)
|
||||
const image = await view.findByAltText('history.png')
|
||||
const before = view.getByText('before')
|
||||
const after = view.getByText('after')
|
||||
expect(before.compareDocumentPosition(image) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
|
||||
expect(image.compareDocumentPosition(after) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -6,7 +6,9 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
ConversationSnapshot, QueuedMessage, SessionId, SessionListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -33,7 +35,7 @@ function row(id: string, text: string | null, preview = text ?? '[image]'): Queu
|
||||
|
||||
function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, chat: EMPTY_CHAT_SNAPSHOT,
|
||||
sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, chat: EMPTY_CHAT_SNAPSHOT,
|
||||
nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
pending: [], queue, running: true, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
@@ -61,7 +63,8 @@ function liveSession(initial: ConversationSnapshot) {
|
||||
}
|
||||
}
|
||||
|
||||
const INPUT_STATE: InputState = { draft: '', draftRev: 0, phase: 'plain', occurrences: [], queue: [] }
|
||||
/** InputZone owner stub (the dock reads useSession only; the zone fields satisfy the owner share). */
|
||||
const INPUT_STATE: InputState = { draft: '', imageIds: [], draftRev: 0, phase: 'plain', occurrences: [], queue: [] }
|
||||
|
||||
// Standard locale seat stub mirroring the real ns → common → key chain.
|
||||
const t: QueueDockProps['t'] = makeTranslate(zh, commonZh)
|
||||
|
||||
@@ -3,17 +3,17 @@
|
||||
// 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 { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { QueuedMessage } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
|
||||
import { makeTranslate, SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { QueuedMessage, SessionFace } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { ComposerBlockRegistry } from '../src/client/input/blocks.ts'
|
||||
import { InputHub } from '../src/client/input/hub.ts'
|
||||
import { ConversationService, UnsupportedImageMediaTypeError } from '../src/client/service.ts'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
|
||||
async function bench() {
|
||||
async function bench(readAttachment?: SessionFace['readAttachment']) {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
const prompt = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } }))
|
||||
const updateQueue = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } }))
|
||||
@@ -21,7 +21,7 @@ async function bench() {
|
||||
const loadOlder = vi.fn(() => Promise.resolve())
|
||||
await runtime.sessions.add({
|
||||
id: 's1',
|
||||
session: { prompt, updateQueue, cancel, loadOlder },
|
||||
session: { prompt, updateQueue, cancel, loadOlder, ...(readAttachment === undefined ? {} : { readAttachment }) },
|
||||
})
|
||||
// config.input is required (the apply shares its hub with the inject
|
||||
// factories); the bench passes its own instance explicitly.
|
||||
@@ -34,7 +34,7 @@ async function bench() {
|
||||
const root = runtime.ctx.get('conversation') as ConversationService
|
||||
const scoped = runtime.sessions.scope('s1')!.get('conversation') as ConversationService
|
||||
const shell = hub.shellFor(runtime.sessions.binding('s1')!)
|
||||
return { runtime, root, scoped, hub, shell, prompt, updateQueue, cancel, loadOlder }
|
||||
return { runtime, fiber, root, scoped, hub, shell, prompt, updateQueue, cancel, loadOlder }
|
||||
}
|
||||
|
||||
describe('ConversationService', () => {
|
||||
@@ -83,6 +83,52 @@ describe('ConversationService', () => {
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('releases draft previews when their session scope is disposed', async () => {
|
||||
const b = await bench()
|
||||
const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:draft-1')
|
||||
const revoked = vi.spyOn(URL, 'revokeObjectURL').mockReturnValue(undefined)
|
||||
try {
|
||||
const [attachment] = b.root.createDraftImages([
|
||||
new File([new Uint8Array(4)], 'a.png', { type: 'image/png' }),
|
||||
])
|
||||
if (attachment === undefined) throw new Error('draft attachment missing')
|
||||
b.root.input.for(b.runtime.sessions.scope('s1')!).addImages([attachment.id])
|
||||
await b.runtime.sessions.remove('s1')
|
||||
expect(b.root.draftImages([attachment.id])).toEqual([])
|
||||
expect(revoked).toHaveBeenCalledWith('blob:draft-1')
|
||||
} finally {
|
||||
created.mockRestore()
|
||||
revoked.mockRestore()
|
||||
}
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('validates every MIME type before allocating previews', async () => {
|
||||
const b = await bench()
|
||||
const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:preview')
|
||||
expect(() => b.root.createDraftImages([
|
||||
new File([Uint8Array.of(1)], 'valid.png', { type: 'image/png' }),
|
||||
new File([Uint8Array.of(2)], 'invalid.svg', { type: 'image/svg+xml' }),
|
||||
])).toThrow(UnsupportedImageMediaTypeError)
|
||||
expect(created).not.toHaveBeenCalled()
|
||||
created.mockRestore()
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('invalidates pending historical image loads when the rendered session is released', async () => {
|
||||
const read = Promise.withResolvers<Awaited<ReturnType<SessionFace['readAttachment']>>>()
|
||||
const b = await bench(() => read.promise)
|
||||
const sessionId = b.runtime.sessions.behavior('s1').sessionId
|
||||
const attachment = {
|
||||
attachmentId: AttachmentId('image-1'), mediaType: 'image/png', bytes: 1, width: 1, height: 1,
|
||||
} as const
|
||||
const pending = b.root.resolveImage(sessionId, attachment)
|
||||
b.root.releaseSessionImages(sessionId)
|
||||
read.resolve({ ok: true, value: { attachment, data: Uint8Array.of(1) } })
|
||||
await expect(pending).rejects.toThrow('historical image scope was released')
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
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')).rejects.toThrow(/requires a session scope/)
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore, EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
createSnapshotStore, EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
ConversationSnapshot, SessionId, SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -70,7 +72,7 @@ const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState =>
|
||||
|
||||
function conversationSnapshot(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, chat: EMPTY_CHAT_SNAPSHOT,
|
||||
sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, chat: EMPTY_CHAT_SNAPSHOT,
|
||||
nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
@@ -179,6 +181,7 @@ function mount(
|
||||
subscribe: () => () => {},
|
||||
version: () => 1,
|
||||
}}
|
||||
releaseSessionImages={vi.fn()}
|
||||
bindDraftMirror={write => wiring.bindMirror(write)}
|
||||
/>
|
||||
)
|
||||
@@ -198,6 +201,9 @@ function mount(
|
||||
useInput={useInput}
|
||||
inputActions={inputActions}
|
||||
keyboard={wiring}
|
||||
addImages={() => null}
|
||||
removeImage={() => {}}
|
||||
draftImages={() => []}
|
||||
resolveSubmitMode={() => 'queue'}
|
||||
toggleCommandMenu={vi.fn()}
|
||||
useNotices={bindSnapshotSelector(wiring.notices)}
|
||||
@@ -301,7 +307,7 @@ describe('ConversationRoot resident composer', () => {
|
||||
fireEvent.change(box, { target: { value: 'ordinary revised' } })
|
||||
expect(b.chat.store.getSnapshot().draft).toBe('ordinary revised')
|
||||
fireEvent.keyDown(box, { key: 'Enter' })
|
||||
expect(b.sink).toHaveBeenCalledWith('ordinary revised', 'queue')
|
||||
expect(b.sink).toHaveBeenCalledWith('ordinary revised', [], 'queue')
|
||||
expect((b.view.getByRole('button', { name: 'Child' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(b.view.queryByText('Root')).toBeNull()
|
||||
})
|
||||
@@ -352,7 +358,7 @@ describe('ConversationRoot resident composer', () => {
|
||||
const header = b.view.container.querySelector('header')
|
||||
expect(host).not.toBeNull()
|
||||
expect(header?.getAttribute('aria-hidden')).toBe('true')
|
||||
expect(b.view.getByText('探索未知之境')).toBeTruthy()
|
||||
expect(b.view.getByText('探索未至之境')).toBeTruthy()
|
||||
expect(b.view.getByText('预览版')).toBeTruthy()
|
||||
expect(b.view.queryByTestId('view-chat')).toBeNull()
|
||||
// The same machine-backed textarea is live in the hero, and the
|
||||
@@ -376,7 +382,7 @@ describe('ConversationRoot resident composer', () => {
|
||||
const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true, openState: 'loading' }))
|
||||
const root = b.view.container.querySelector('[data-phase]')
|
||||
expect(root?.getAttribute('data-phase')).toBe('settling')
|
||||
expect(b.view.queryByText('探索未知之境')).toBeNull()
|
||||
expect(b.view.queryByText('探索未至之境')).toBeNull()
|
||||
})
|
||||
|
||||
it('settling phase: a session the list has no row for settles conservatively', () => {
|
||||
@@ -401,7 +407,7 @@ describe('ConversationRoot resident composer', () => {
|
||||
// blank the column for the history round-trip.
|
||||
const root = b.view.container.querySelector('[data-phase]')
|
||||
expect(root?.getAttribute('data-phase')).toBe('hero')
|
||||
expect(b.view.getByText('探索未知之境')).toBeTruthy()
|
||||
expect(b.view.getByText('探索未至之境')).toBeTruthy()
|
||||
expect(b.view.getByRole('textbox')).toBeTruthy()
|
||||
})
|
||||
|
||||
@@ -419,7 +425,7 @@ describe('ConversationRoot resident composer', () => {
|
||||
expect(after.value).toBe('kept across flip')
|
||||
expect(b.chat.store.getSnapshot().draft).toBe('kept across flip')
|
||||
expect(b.view.container.querySelector('[data-conversation-scroll]')?.contains(after)).toBe(true)
|
||||
expect(b.view.queryByText('探索未知之境')).toBeNull()
|
||||
expect(b.view.queryByText('探索未至之境')).toBeNull()
|
||||
expect(b.view.getByTestId('view-chat')).toBeTruthy()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import {
|
||||
BUSY_ENTER_STORAGE_KEY, ComposerSubmissionPolicy, DEFAULT_BUSY_ENTER_BEHAVIOR,
|
||||
ComposerSubmissionPolicy, DEFAULT_BUSY_ENTER_BEHAVIOR,
|
||||
} from '../src/client/input/submission-policy.ts'
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
localStorage.clear()
|
||||
})
|
||||
import type { ConversationSettings } from '../src/submission-settings.ts'
|
||||
|
||||
describe('ComposerSubmissionPolicy', () => {
|
||||
it('defaults to Queue and only applies the preference while running', () => {
|
||||
@@ -28,40 +25,42 @@ describe('ComposerSubmissionPolicy', () => {
|
||||
expect(policy.resolve(true, 'accelerated', true)).toBe('queue')
|
||||
expect(policy.resolve(false, 'enter', true)).toBe('queue')
|
||||
expect(policy.resolve(false, 'accelerated', true)).toBe('queue')
|
||||
expect(localStorage.getItem(BUSY_ENTER_STORAGE_KEY)).toBe('steer')
|
||||
})
|
||||
|
||||
it('restores a valid preference and leaves an identical write untouched', () => {
|
||||
localStorage.setItem(BUSY_ENTER_STORAGE_KEY, 'steer')
|
||||
const write = vi.spyOn(Storage.prototype, 'setItem')
|
||||
const policy = new ComposerSubmissionPolicy()
|
||||
it('writes an explicit change through the scope after publishing it locally', () => {
|
||||
const host = stubSettingsScope<ConversationSettings>()
|
||||
const observed: string[] = []
|
||||
let liveBehavior = (): string => 'unconstructed'
|
||||
const scope: typeof host.scope = {
|
||||
...host.scope,
|
||||
set: (field, value) => {
|
||||
observed.push(`${field}=${String(value)}:${liveBehavior()}`)
|
||||
return host.scope.set(field, value)
|
||||
},
|
||||
}
|
||||
const policy = new ComposerSubmissionPolicy(scope)
|
||||
liveBehavior = () => policy.busyEnter.getSnapshot()
|
||||
policy.setBusyEnter('steer')
|
||||
expect(observed).toEqual(['busyEnter=steer:steer'])
|
||||
expect(host.set).toHaveBeenCalledWith('busyEnter', 'steer')
|
||||
expect(host.set).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('adopts a Host preference without writing it back and leaves an identical write untouched', () => {
|
||||
const host = stubSettingsScope<ConversationSettings>()
|
||||
const policy = new ComposerSubmissionPolicy(host.scope)
|
||||
host.publish({ status: 'ready', value: { busyEnter: 'steer' }, revision: 1, writable: true })
|
||||
expect(policy.busyEnter.getSnapshot()).toBe('steer')
|
||||
policy.setBusyEnter('steer')
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
write.mockRestore()
|
||||
expect(host.set).not.toHaveBeenCalled()
|
||||
host.publish({ value: { busyEnter: 'steer' }, revision: 2 })
|
||||
expect(policy.busyEnter.getSnapshot()).toBe('steer')
|
||||
})
|
||||
|
||||
it('uses Queue for invalid, unavailable, or unreadable storage', () => {
|
||||
localStorage.setItem(BUSY_ENTER_STORAGE_KEY, 'invalid')
|
||||
expect(new ComposerSubmissionPolicy().busyEnter.getSnapshot()).toBe('queue')
|
||||
|
||||
vi.stubGlobal('localStorage', undefined)
|
||||
expect(new ComposerSubmissionPolicy().busyEnter.getSnapshot()).toBe('queue')
|
||||
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: () => { throw new Error('blocked') },
|
||||
setItem: vi.fn(),
|
||||
})
|
||||
expect(new ComposerSubmissionPolicy().busyEnter.getSnapshot()).toBe('queue')
|
||||
})
|
||||
|
||||
it('keeps the in-memory preference when persistence throws', () => {
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: () => null,
|
||||
setItem: () => { throw new Error('quota') },
|
||||
})
|
||||
const policy = new ComposerSubmissionPolicy()
|
||||
policy.setBusyEnter('steer')
|
||||
it('adopts a section already standing at construction', () => {
|
||||
const host = stubSettingsScope<ConversationSettings>()
|
||||
host.publish({ status: 'ready', value: { busyEnter: 'steer' }, revision: 1, writable: true })
|
||||
const policy = new ComposerSubmissionPolicy(host.scope)
|
||||
expect(policy.busyEnter.getSnapshot()).toBe('steer')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// View-ring type-chain samples. This spec pins the conversation-owned SlotMap
|
||||
// row, list-kind registration shape, composed view props, and the runtime
|
||||
// ledger projection consumed by ConversationRoot.
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ReactNode } from 'react'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
@@ -8,9 +8,18 @@
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../attachment/attachment"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../connection"
|
||||
},
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
@@ -59,6 +68,9 @@
|
||||
{
|
||||
"path": "../locale"
|
||||
},
|
||||
{
|
||||
"path": "../../settings/settings"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user