Merge remote-tracking branch 'origin/master' into worktree-locale
# Conflicts: # docs/module-graph.md # packages/client/locale/README.i18n.yaml # packages/client/locale/README.zh.md
This commit is contained in:
6
packages/client/README.i18n.yaml
Normal file
6
packages/client/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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/README.md
|
||||
README.md: b111d67fa49e06227e324a33bd53417ad28c3a5b
|
||||
README.zh.md: b498008eb82f6ab357718f2af761f38e51140ef8
|
||||
34
packages/client/README.md
Normal file
34
packages/client/README.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# client/ — web-GUI browser half
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The browser side of the dsh web GUI: shell kernel, module system, wire consumer, React-free object services, the slot system, and the `ui-*` feature-plugin roster. Authoring rules live in [AGENTS.md](AGENTS.md); the host half is [`host/`](../host/README.md). All **product** packages, named `@deepseek-ai/dsh-client-<name>`.
|
||||
|
||||
| Package | Role | ctx key / slot |
|
||||
|---|---|---|
|
||||
| `web/` | Shell kernel: `AppWebEntry` runs the two-stage boot over the host-pushed entry graph | (boots the tree) |
|
||||
| `modules/` | Client module system: browser peer of Node's ESM loader as a lazy CJS table under the vendored cordis Loader | (module face) |
|
||||
| `web-react/` | Shell-side React glue: `createSlotRenderer` + `SessionProvider` render seats | (renderer install) |
|
||||
| `connection/` | Wire consumer both ends: browser `ctx.connection` (shared api client + stream loop) and the node half mounting the `/api` route with its browser-trust fence | `ctx.connection` |
|
||||
| `runtime/` | Client cordis boot and React-free object services: slots, Sessions, Workspaces, per-session bindings | `ctx.slots` `ctx.sessions` `ctx.workspaces` |
|
||||
| `hmr/` | Dev-only hot reload for fetch-arrival client plugins (`--dev` graphs) | (dev entry) |
|
||||
| `locale/` | Browser locale preference (`zh`/`en`) plus the ns×locale dictionary registry | `ctx.locale` |
|
||||
| `ui-slots/` | Slot registry pure core: SlotMap merging, single `register` API, the four-share props family | (types + core) |
|
||||
| `ui-theme/` | Theme preference over the `--dsw-*` token stylesheets (`light`/`dark`/`system`) | `ctx.theme` |
|
||||
| `ui-primitives/` | Pure React atoms: icons, Button/Pill/Menu/Modal/Input, markdown family | (component library) |
|
||||
| `ui-layout/` | Shell three-column AppFrame; declares `sidebar` / `conversation` / `details` / `conversation.empty` | `ctx.layout` |
|
||||
| `ui-sidebar/` | Sidebar shell: Workspace/session rail, search, collapse; declares `sidebar.workspaces` | (slot host) |
|
||||
| `ui-workspace/` | Shared Workspace picker: browser region + hero picker over the same creation flow | (fills `sidebar.workspaces`, `conversation.hero.workspace`) |
|
||||
| `ui-conversation/` | Conversation domain: skeleton, chat view, input dock, per-tool row slots | (slot host) |
|
||||
| `ui-trajectory/` | Trajectory/Waterfall view tabs; the minimal pure-consumer plugin exemplar | (fills `conversation.view`) |
|
||||
| `ui-command/` | Command surface: session-keyed directory cache, `/` source, three-kind dispatch | `ctx.command` |
|
||||
| `ui-slash/` | Input trigger pipeline: `/` and `@` detection, grouped candidate menu, source roster | `ctx.slash` |
|
||||
| `ui-skill/` | `/`-trigger skill reference source over the `skill.list` RPC | (registers into `ctx.slash`) |
|
||||
| `ui-subagent/` | `@`-trigger subagent reference source over the sessions snapshot | (registers into `ctx.slash`) |
|
||||
| `ui-model/` | Model selection: `/model` popupSelect + the composer model seat over `ModelService` | `ctx.models` |
|
||||
| `ui-question/` | Web `ask_user_question`: host half mounts the tool, browser half fills the composer seat | (fills `conversation.composer`) |
|
||||
| `ui-settings/` | Settings shell: trigger chrome + modal panel; declares the `settings.*` slots | (slot host) |
|
||||
| `ui-settings-general/` | Settings ownerless copy: chrome content + General section skeleton | (fills `settings.*`) |
|
||||
| `ui-models/` | Models settings nav entry (content column lands in a later phase) | (fills `settings.section`) |
|
||||
|
||||
Feature UI composes only through the slot system (`ctx.slots.register`) — the [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md) is the definitive model; the [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) owns the loading chain and object layer.
|
||||
34
packages/client/README.zh.md
Normal file
34
packages/client/README.zh.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# client/ — web GUI 浏览器半侧
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
dsh web GUI 的浏览器侧:shell 内核、模块系统、协议消费层、无 React 依赖的对象服务、slot 系统,以及 `ui-*` 特性插件阵列。编写规则见 [AGENTS.md](AGENTS.md);宿主半侧是 [`host/`](../host/README.md)。全部为**产品**包,命名为 `@deepseek-ai/dsh-client-<name>`。
|
||||
|
||||
| 包 | 角色 | ctx 键/slot |
|
||||
|---|---|---|
|
||||
| `web/` | shell 内核:`AppWebEntry` 基于宿主推送的条目图运行两阶段启动 | (启动整棵树) |
|
||||
| `modules/` | 客户端模块系统:Node ESM 加载器的浏览器对等物,是 vendored cordis Loader 之下的惰性 CJS 表 | (模块面) |
|
||||
| `web-react/` | shell 侧 React 胶水:`createSlotRenderer` + `SessionProvider` 渲染座位 | (渲染器安装) |
|
||||
| `connection/` | 协议两端的消费者:浏览器侧 `ctx.connection`(共享 api 客户端 + 流循环),node 半侧挂载带浏览器信任栅栏的 `/api` 路由 | `ctx.connection` |
|
||||
| `runtime/` | 客户端 cordis 启动与无 React 对象服务:slots、Session、Workspace、逐会话绑定 | `ctx.slots` `ctx.sessions` `ctx.workspaces` |
|
||||
| `hmr/` | 仅开发用的 fetch 到达型客户端插件热重载(`--dev` 图) | (开发条目) |
|
||||
| `locale/` | 浏览器语言偏好(`zh`/`en`)与 ns×locale 词典注册表 | `ctx.locale` |
|
||||
| `ui-slots/` | slot 注册表纯核心:SlotMap 合并、单一 `register` API、四份额 props 族 | (类型 + 核心) |
|
||||
| `ui-theme/` | 基于 `--dsw-*` token 样式表的主题偏好(`light`/`dark`/`system`) | `ctx.theme` |
|
||||
| `ui-primitives/` | 纯 React 原子:图标、Button/Pill/Menu/Modal/Input、markdown 族 | (组件库) |
|
||||
| `ui-layout/` | shell 三栏 AppFrame;声明 `sidebar`/`conversation`/`details`/`conversation.empty` | `ctx.layout` |
|
||||
| `ui-sidebar/` | 侧栏 shell:Workspace/会话栏、搜索、折叠;声明 `sidebar.workspaces` | (slot 宿主) |
|
||||
| `ui-workspace/` | 共享 Workspace 选择器:浏览区域 + hero 选择器共用同一创建流程 | (填充 `sidebar.workspaces`、`conversation.hero.workspace`) |
|
||||
| `ui-conversation/` | 会话域:骨架、聊天视图、输入坞、逐工具行 slot | (slot 宿主) |
|
||||
| `ui-trajectory/` | Trajectory/Waterfall 视图标签;最小纯消费者插件范例 | (填充 `conversation.view`) |
|
||||
| `ui-command/` | 命令面:按会话键控的目录缓存、`/` 源、三类分发 | `ctx.command` |
|
||||
| `ui-slash/` | 输入触发流水线:光标下的 `/` 与 `@` 检测、分组候选菜单、源名册 | `ctx.slash` |
|
||||
| `ui-skill/` | 基于 `skill.list` RPC 的 `/` 触发技能引用源 | (注册进 `ctx.slash`) |
|
||||
| `ui-subagent/` | 基于会话快照的 `@` 触发子代理引用源 | (注册进 `ctx.slash`) |
|
||||
| `ui-model/` | 模型选择:`/model` popupSelect + 输入坞模型座位,均由 `ModelService` 驱动 | `ctx.models` |
|
||||
| `ui-question/` | Web `ask_user_question`:宿主半侧挂载工具,浏览器半侧填充输入坞座位 | (填充 `conversation.composer`) |
|
||||
| `ui-settings/` | 设置 shell:触发 chrome + 模态面板;声明 `settings.*` slot | (slot 宿主) |
|
||||
| `ui-settings-general/` | 设置的无主文案:chrome 内容 + General 分区骨架 | (填充 `settings.*`) |
|
||||
| `ui-models/` | 模型设置导航项(内容列留待后续阶段) | (填充 `settings.section`) |
|
||||
|
||||
特性 UI 只通过 slot 系统组合(`ctx.slots.register`)——[slot 系统标准](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md)是权威模型;[web 客户端架构 Note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) 拥有加载链与对象层。
|
||||
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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
|
||||
README.md: 80228a180faba0c556ff720e999b29b5bb1635b6
|
||||
README.zh.md: f4b857886bfafa891ceb1bd6b79b27e1fb725819
|
||||
# pnpm run verify-translation-pairing --write packages/client/connection/README.md
|
||||
README.md: 173a9b9998e17d201b2d31d73ea74a94b319dae6
|
||||
README.zh.md: ca5da643db443956c25399f07c8b460900942ad4
|
||||
|
||||
@@ -4,6 +4,10 @@ English | [中文](README.zh.md)
|
||||
|
||||
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3.
|
||||
|
||||
## /api browser-trust fence
|
||||
|
||||
The node half guards every request under `/api` before bridging (`src/api-request-trust.ts`). Every request — browser-marked or not — must present a `Host` that is a loopback authority or matches a `trustedHosts` entry: exact on `host:port` entries, any port on port-less entries, both sides compared through WHATWG normalization (DNS-rebinding defense). There is deliberately no shortcut for requests without browser markers: over plain HTTP a browser attaches neither `Origin` nor Fetch-Metadata to reads (EventSource, images, navigations — those headers go only to trustworthy destinations), so an unmarked request may still be a rebound browser read with a readable response, and Host is the one header rebinding cannot forge; non-browser clients pass the same fence via loopback, the CLI-derived LAN IP literals, or a declared authority. When markers are present, an attached `Origin` must equal the Host authority, and an explicit `sec-fetch-site: cross-site` marker is refused. A `trustedHosts` entry that is not a bare, canonical `host[:port]` authority — one WHATWG parsing reads back exactly as written — fails the plugin load loudly: parsing would otherwise quietly authorize the hostname inside `harness.internal/path`, or broaden a dangling-colon or zero-padded port to an any-port grant. Failures answer plain 403 before any RPC dispatch. A non-loopback (`--host 0.0.0.0`) deployment therefore needs its serving authorities trusted: the dsh CLI derives the machine's LAN IP literals itself and its `--trusted-host` flag declares named ones, so `trustedHosts` in cordis.yml is for compositions the CLI does not boot. The fence is deliberately not an authentication layer — reachability policy stays with the webserver binding, and auth remains deferred work. Decision record: [the api browser-trust boundary Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md).
|
||||
|
||||
## Keyless fixture
|
||||
|
||||
Any `fixture` query parameter selects the in-memory carrier. `fixture=empty` starts with no Workspace or Session; `fixturePrompt=reject` rejects prompts before acceptance; `fixtureAttach=fail` publishes a Session but rejects its Workspace attachment; `fixtureSessionCreate=drop-response` publishes and frames a Session before dropping the create response; and `fixtureFrames=workspace-first` reverses the default session-first create-frame order. Workspace creation by name/path and caller-preallocated SessionIds remain deterministic enough for assembled Web tests to reconcile list and frame arrival.
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
|
||||
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。
|
||||
|
||||
## /api 浏览器信任栅栏
|
||||
|
||||
node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust.ts`)。每个请求——无论是否带浏览器标记——`Host` 都必须是回环地址权威,或与某个 `trustedHosts` 条目匹配:带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较(DNS rebinding 防御)。刻意不为无浏览器标记的请求开捷径:明文 HTTP 下浏览器的读取(EventSource、图片、导航——这些头只发给可信目标)既不带 `Origin` 也不带 Fetch-Metadata,因此无标记请求仍可能是被重绑页面发起的、响应可被读走的读取,而 Host 是重绑唯一伪造不了的请求头;非浏览器客户端经由回环地址、CLI 推导的 LAN IP 字面量或已声明的权威通过同一道栅栏。当标记存在时,`Origin` 必须与 Host 权威完全一致;显式的 `sec-fetch-site: cross-site` 标记一律拒绝。不是纯的、规范形 `host[:port]` 权威的 `trustedHosts` 条目——即 WHATWG 解析读回后与原文不完全一致的——会让插件加载大声失败:否则解析会悄悄授权 `harness.internal/path` 这类笔误里的 hostname,或把悬空冒号、补零端口放大成任意端口授权。失败在任何 RPC 分发之前以纯 403 应答。因此非回环(`--host 0.0.0.0`)部署需要让自己的服务权威被信任:dsh CLI 会自行推导本机的 LAN IP 字面量,其 `--trusted-host` flag 用于声明具名权威,所以 cordis.yml 中的 `trustedHosts` 面向 CLI 不参与引导的组合。这道栅栏刻意不承担认证职责——可达性策略归 webserver 绑定配置,认证仍是延期工作。决策记录:[api 浏览器信任边界 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md)。
|
||||
|
||||
## 无密钥 fixture
|
||||
|
||||
任何 `fixture` 查询参数都会选择内存载体。`fixture=empty` 启动时不含 Workspace 或 Session;`fixturePrompt=reject` 在接受前拒绝提示词;`fixtureAttach=fail` 发布 Session 但拒绝将其附加到 Workspace;`fixtureSessionCreate=drop-response` 在丢弃创建响应前发布 Session 并为其发出帧;`fixtureFrames=workspace-first` 则反转默认的 Session 优先创建帧顺序。按名称/路径创建 Workspace 以及由调用方预先分配 SessionId,均具有足够的确定性,组装后的 Web 测试可以据此协调列表与帧的到达。
|
||||
|
||||
@@ -33,7 +33,8 @@
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^"
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
|
||||
129
packages/client/connection/src/api-request-trust.ts
Normal file
129
packages/client/connection/src/api-request-trust.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Browser-trust fence for every /api request. Defends the two confused-deputy
|
||||
* paths a browser opens against a local HTTP API — DNS rebinding (Host names
|
||||
* the attacker's domain while the socket reaches this server) and cross-site
|
||||
* requests fired from a malicious page. The Host fence binds every request,
|
||||
* browser-looking or not: over plain HTTP a browser attaches neither Origin
|
||||
* nor Fetch-Metadata to reads (EventSource, images, navigations — those
|
||||
* headers go only to trustworthy destinations), so an unmarked request may
|
||||
* still be a rebound browser read and Host is the one header rebinding cannot
|
||||
* forge. Non-browser and remote clients pass the same fence via loopback, the
|
||||
* CLI-derived LAN IP literals, or a declared `trustedHosts` authority.
|
||||
* Network reachability and authentication stay out of scope: binding policy
|
||||
* belongs to the webserver config, and this fence is not an auth layer.
|
||||
*/
|
||||
|
||||
import type { IncomingHttpHeaders } from 'node:http'
|
||||
|
||||
/** The request facts the fence reads (structural subset of IncomingMessage). */
|
||||
interface ApiTrustRequest {
|
||||
headers: IncomingHttpHeaders
|
||||
}
|
||||
|
||||
function header(headers: IncomingHttpHeaders, name: string): string | undefined {
|
||||
const value = headers[name]
|
||||
return typeof value === 'string' ? value : undefined
|
||||
}
|
||||
|
||||
function isLoopbackHostname(hostname: string): boolean {
|
||||
if (hostname === 'localhost' || hostname === '[::1]') return true
|
||||
const parts = hostname.split('.')
|
||||
return parts.length === 4
|
||||
&& parts[0] === '127'
|
||||
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
|
||||
}
|
||||
|
||||
/** Normalized URL of a Host-header authority (hostname lowercased, default port stripped, IPv6 bracketed), or undefined when unparsable. */
|
||||
function parseAuthority(authority: string): URL | undefined {
|
||||
try {
|
||||
// http: is a WHATWG "special scheme": parsing yields a non-empty hostname or throws.
|
||||
return new URL(`http://${authority}`)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert one configured `trustedHosts` entry is a bare authority (`host` or
|
||||
* `host:port`) in canonical form: it must survive WHATWG parsing unchanged
|
||||
* (case aside). Anything parsing would silently rewrite is refused as a typo
|
||||
* that must fail the load loudly instead of being ignored until requests 403
|
||||
* or quietly changing the grant: URL parts beyond the authority
|
||||
* (`harness.internal/path`, `user@harness.internal` — which would authorize
|
||||
* the embedded hostname), stripped whitespace, a dangling colon or
|
||||
* zero-padded port (which would broaden an intended exact-port grant to every
|
||||
* port), and non-canonical host spellings (`0x7f.0.0.1`, percent-encoding,
|
||||
* unbracketed IPv6; IDN hosts are declared in punycode, the form the wire
|
||||
* carries).
|
||||
* @param entry - the configured value, verbatim.
|
||||
*/
|
||||
export function assertTrustedAuthority(entry: string): void {
|
||||
const entryUrl = parseAuthority(entry)
|
||||
if (entryUrl !== undefined && canonicalAuthority(entry, entryUrl) === entry.toLowerCase()) return
|
||||
throw new Error(`client-connection: trustedHosts entry ${JSON.stringify(entry)} is not a bare host[:port] authority`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical form of a parsed authority: `hostname` when no port was written,
|
||||
* else `hostname:port`. The port is judged from URL parses under both special
|
||||
* schemes (their default ports differ, so `:80` and `:443` still count as
|
||||
* explicit), never from the raw string, where WHATWG trimming would misread
|
||||
* shapes like `host:port ` as port-less.
|
||||
*/
|
||||
function canonicalAuthority(entry: string, entryUrl: URL): string {
|
||||
// An authority that parsed under http cannot fail under https.
|
||||
const port = entryUrl.port !== '' ? entryUrl.port : new URL(`https://${entry}`).port
|
||||
return port === '' ? entryUrl.hostname : `${entryUrl.hostname}:${port}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the request authority matches a `trustedHosts` entry. An entry with
|
||||
* an explicit port matches that exact authority; a port-less entry matches the
|
||||
* hostname on any port (the shape the CLI derives for IP-literal LAN serving,
|
||||
* where the bound port may be OS-assigned). Both sides compare through WHATWG
|
||||
* normalization, so case and a redundant `:80` never decide trust.
|
||||
*/
|
||||
function isTrustedAuthority(hostUrl: URL, trustedHosts: readonly string[]): boolean {
|
||||
return trustedHosts.some((entry) => {
|
||||
const entryUrl = parseAuthority(entry)
|
||||
if (entryUrl === undefined) return false
|
||||
return canonicalAuthority(entry, entryUrl) === entryUrl.hostname
|
||||
? entryUrl.hostname === hostUrl.hostname
|
||||
: entryUrl.host === hostUrl.host
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether one /api request may reach the RPC bridge.
|
||||
* @param request - node HTTP request facts (headers).
|
||||
* @param trustedHosts - non-loopback authorities this deployment serves: exact `host:port`, or port-less `host` matching any port.
|
||||
* @returns true when the Host is ours (loopback or trusted) and any attached browser markers are same-origin.
|
||||
*/
|
||||
export function isTrustedApiRequest(request: ApiTrustRequest, trustedHosts: readonly string[]): boolean {
|
||||
// Host fence (DNS-rebinding defense), applied to every request: the browser
|
||||
// fills Host from the URL it believes it is talking to, so a rebound page
|
||||
// carries the attacker's domain here even though the socket lands on this
|
||||
// server. There is no marker shortcut — a browser read over plain HTTP
|
||||
// (EventSource, images, navigations) arrives with neither Origin nor
|
||||
// Fetch-Metadata, indistinguishable from curl, and its response is readable
|
||||
// by the rebound page.
|
||||
const host = header(request.headers, 'host')
|
||||
if (host === undefined) return false
|
||||
const hostUrl = parseAuthority(host)
|
||||
if (hostUrl === undefined) return false
|
||||
if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) return false
|
||||
// Cross-site fence: modern browsers label the initiator relationship on
|
||||
// every fetch; an explicit cross-site marker is refused regardless of Origin.
|
||||
if (header(request.headers, 'sec-fetch-site') === 'cross-site') return false
|
||||
// Origin fence: when a browser attaches an Origin it must be exactly this
|
||||
// authority (compared through the same normalization as the Host). Absent
|
||||
// Origin is fine — the Host fence above already bound the request. The
|
||||
// literal "null" (sandboxed iframes, file: pages) is an opaque origin, refused.
|
||||
const origin = header(request.headers, 'origin')
|
||||
if (origin === undefined) return true
|
||||
try {
|
||||
return new URL(origin).host === hostUrl.host
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
export type {
|
||||
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
|
||||
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
|
||||
DirectoryEntry, DirectoryListing,
|
||||
WorkspaceApi, WorkspaceId, WorkspaceView,
|
||||
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
|
||||
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
||||
|
||||
@@ -80,6 +80,62 @@ const MARKDOWN_FIXTURE = [
|
||||
|
||||
const USER_MARKDOWN_LITERAL = '用户字面量:# 不渲染 `code` [link](https://example.com)'
|
||||
|
||||
/**
|
||||
* SGR wrapper for the terminal output sample below: authoring the escapes as
|
||||
* `\u001b` keeps literal control bytes out of this source file.
|
||||
* @param code - the SGR parameter (an ANSI color or attribute number).
|
||||
* @param body - the text the attribute applies to.
|
||||
* @returns the body wrapped in the attribute and a reset.
|
||||
*/
|
||||
function sgr(code: number, body: string): string {
|
||||
return `\u001b[${code}m${body}\u001b[0m`
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal output sample for fixture turn 65, authored to carry every feature
|
||||
* the terminal card draws that turn 60's two prompt rows cannot reach:
|
||||
* basic-16 SGR foreground runs (green, red, bright-black) that must resolve to
|
||||
* `--dsw-*` tokens, a bold run, column-aligned table rows that must scroll
|
||||
* rather than fold, more than DEFAULT_TERMINAL_MAX_LINES (16) lines so the
|
||||
* height cap collapses the middle. The exit status is authored separately in
|
||||
* TERMINAL_EXIT_STATUS and deliberately absent from this text: the real bash
|
||||
* presenter CONSUMES its `[exit code: N]` marker out of the body, because a
|
||||
* terminal card shows the exit as its own pill and leaving the marker in would
|
||||
* render it twice (packages/bash/tool-bash/src/render.ts).
|
||||
*/
|
||||
const TERMINAL_OUTPUT_FIXTURE = [
|
||||
sgr(1, 'Running 4 checks'),
|
||||
`${sgr(32, '\u2713')} typecheck 1.82s`,
|
||||
`${sgr(32, '\u2713')} lint 0.94s`,
|
||||
`${sgr(32, '\u2713')} duplication 2.10s`,
|
||||
`${sgr(31, '\u2717')} unit 8.41s`,
|
||||
'',
|
||||
sgr(90, 'packages/client/ui-primitives/tests/terminal-block.spec.tsx'),
|
||||
` ${sgr(31, 'FAIL')} caps output at the configured line budget`,
|
||||
' expected 16 lines, received 24',
|
||||
'',
|
||||
'NAME LINES BRANCHES FUNCTIONS UNCOVERED',
|
||||
'TerminalBlock.tsx 100% 100% 100% -',
|
||||
'ansi.ts 100% 100% 100% -',
|
||||
'clipboard.ts 100% 100% 100% -',
|
||||
'CodeBlock.tsx 98.4% 96.2% 100% 41-43',
|
||||
'highlight.ts 100% 100% 100% -',
|
||||
'Pill.tsx 100% 100% 100% -',
|
||||
'StateDot.tsx 100% 100% 100% -',
|
||||
'markdown/Markdown.tsx 100% 100% 100% -',
|
||||
'',
|
||||
sgr(31, '1 of 4 checks failed'),
|
||||
].join('\n')
|
||||
|
||||
/**
|
||||
* Exit status for each terminal sample, keyed by its output text. Authored
|
||||
* alongside the sample rather than parsed back out of its trailing marker,
|
||||
* which is the bash tool's own job and not something to reimplement here.
|
||||
*/
|
||||
const TERMINAL_EXIT_STATUS: Record<string, { exitCode: number } | { signal: string }> = {
|
||||
[TERMINAL_OUTPUT_FIXTURE]: { exitCode: 1 },
|
||||
}
|
||||
|
||||
const DEEPSEEK_REASONING = {
|
||||
efforts: [
|
||||
{ id: 'off', name: 'Off' },
|
||||
@@ -170,7 +226,9 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
push({ type: 'step/end', data: { turn, step: 0 } })
|
||||
push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
|
||||
}
|
||||
toolTurn(60, 'fx-bash', '{"command":"ls -la","cwd":"/tmp/fixture"}', 'total 2\ndrwxr-xr-x fixture\n-rw-r--r-- demo.txt')
|
||||
// A two-line command, so the fixture covers the terminal card's one-row-per-
|
||||
// command-line prompt (and that the card still marks the call exactly once).
|
||||
toolTurn(60, 'fx-bash', '{"command":"ls -la\\necho done","cwd":"/tmp/fixture"}', 'total 2\ndrwxr-xr-x fixture\n-rw-r--r-- demo.txt')
|
||||
toolTurn(61, 'fx-write', '{"path":"notes/demo.txt","content":"hello fixture\\n"}', 'wrote notes/demo.txt')
|
||||
toolTurn(62, 'edit', '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}', '已编辑')
|
||||
toolTurn(63, 'write', '{"file_path":"notes/new-demo.txt","content":"hello fixture\\n"}', '已写入')
|
||||
@@ -224,8 +282,22 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
{ content: '实现 fixture 样本', status: 'in_progress' },
|
||||
{ content: '浏览器验收', status: 'pending' },
|
||||
]
|
||||
// Turn 65: the terminal sample turn 60's two clean prompt rows cannot cover —
|
||||
// ANSI SGR coloring, output past the terminal card's height cap, a nested cwd
|
||||
// whose prompt label is its last segment, and a non-zero exit authored beside
|
||||
// the sample in TERMINAL_EXIT_STATUS — its body deliberately carries no
|
||||
// `[exit code: N]` marker, since the real presenter consumes that one out of
|
||||
// the body. Named `bash`, so it also covers
|
||||
// the keyed toolview row (turn 60's `fx-bash` covers the render-site fallback
|
||||
// row) — the two chat-row shapes the terminal card renders in.
|
||||
//
|
||||
// Ordered BEFORE the todo turn deliberately: the standing plan retires at the
|
||||
// next `turn/start`, so a turn appended after it would leave the dock's plan
|
||||
// strip empty and take the todo surfaces' own coverage with it.
|
||||
toolTurn(65, 'bash', '{"command":"pnpm run check","cwd":"/tmp/fixture/deep/nested"}', TERMINAL_OUTPUT_FIXTURE)
|
||||
|
||||
const todoArgs = JSON.stringify({ todos: fixtureTodos })
|
||||
toolTurn(65, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.')
|
||||
toolTurn(66, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.')
|
||||
// The real tool appends the snapshot mid-execution — between tool/call and
|
||||
// tool/result — so the fixture reproduces that exact ordering (the last
|
||||
// toolTurn events run ... tool/call, tool/result, step/end, turn/end).
|
||||
@@ -250,7 +322,10 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined {
|
||||
return undefined
|
||||
}
|
||||
switch (name) {
|
||||
// Both names present the same terminal card: `fx-bash` lands on the
|
||||
// render-site fallback row, `bash` on the keyed BashRow registration.
|
||||
case 'fx-bash':
|
||||
case 'bash':
|
||||
return { card: 'terminal', title: str(args.command), cwd: str(args.cwd, '/tmp/fixture'), description: 'fixture 终端样本' }
|
||||
case 'fx-write':
|
||||
return {
|
||||
@@ -271,7 +346,10 @@ function presentResult(name: string, argsRaw: string, resultText: string): ToolR
|
||||
if (call === undefined) return undefined
|
||||
switch (call.card) {
|
||||
case 'terminal':
|
||||
return { card: 'terminal', output: resultText, exitCode: 0 }
|
||||
// The sample's own exit status, authored beside it: re-parsing the
|
||||
// trailing marker here would duplicate the bash tool's `parseExitStatus`,
|
||||
// which this client-side fixture cannot import.
|
||||
return { card: 'terminal', output: resultText, ...(TERMINAL_EXIT_STATUS[resultText] ?? { exitCode: 0 }) }
|
||||
case 'diff':
|
||||
return { card: 'diff', diffs: call.diffs }
|
||||
case 'generic':
|
||||
@@ -331,6 +409,44 @@ function planViewOf(log: readonly SessionEvent[]): { active: boolean; pending: b
|
||||
}
|
||||
|
||||
/** Fixture parallel of the host's projection units: whole current values per key over the full log. */
|
||||
/** Fixture preset table (the host PermissionService defaults). */
|
||||
const PERMISSION_PRESETS: Record<string, { sandbox: string; approval: string; description: string }> = {
|
||||
'workspace-write': { sandbox: 'workspace-write', approval: 'ask', description: 'Write inside the workspace and permitted temporary directories; wider retries require approval.' },
|
||||
'danger-full-access': { sandbox: 'danger-full-access', approval: 'never', description: 'Full file access without approval prompts.' },
|
||||
}
|
||||
|
||||
/** Host permissions-unit parallel: fold the three knob events, derive the select over the fixture defaults. */
|
||||
function permissionSelectOf(
|
||||
log: readonly SessionEvent[],
|
||||
): { options: { value: string; name: string; description?: string }[]; currentValue: string } {
|
||||
let preset: string | null = null
|
||||
let sandbox = 'workspace-write'
|
||||
let approval = 'ask'
|
||||
for (const event of log) {
|
||||
const item = event as { type: string; data: Record<string, unknown> }
|
||||
if (item.type === 'permission/preset') preset = item.data['preset'] as string
|
||||
else if (item.type === 'sandbox/mode') sandbox = item.data['mode'] as string
|
||||
else if (item.type === 'approval/policy') approval = item.data['policy'] as string
|
||||
}
|
||||
const matches = (spec: { sandbox: string; approval: string }): boolean => spec.sandbox === sandbox && spec.approval === approval
|
||||
let currentValue = 'custom'
|
||||
const folded = preset === null ? undefined : PERMISSION_PRESETS[preset]
|
||||
if (preset !== null && folded !== undefined && matches(folded)) {
|
||||
currentValue = preset
|
||||
} else {
|
||||
for (const [name, spec] of Object.entries(PERMISSION_PRESETS)) {
|
||||
if (matches(spec)) { currentValue = name; break }
|
||||
}
|
||||
}
|
||||
return {
|
||||
options: [
|
||||
...Object.entries(PERMISSION_PRESETS).map(([value, spec]) => ({ value, name: value, description: spec.description })),
|
||||
...currentValue === 'custom' ? [{ value: 'custom', name: 'Custom', description: 'Current sandbox and approval settings do not match a preset.' }] : [],
|
||||
],
|
||||
currentValue,
|
||||
}
|
||||
}
|
||||
|
||||
function projectionValuesOf(log: readonly SessionEvent[]): Record<string, unknown> {
|
||||
const values: Record<string, unknown> = {}
|
||||
const titleEvent = log.findLast(item => (item as { type: string }).type === 'session/title')
|
||||
@@ -339,6 +455,8 @@ function projectionValuesOf(log: readonly SessionEvent[]): Record<string, unknow
|
||||
}
|
||||
// Always present (tool-todo unit composed): null when no plan stands.
|
||||
values['todos'] = backscanTodos(log) ?? null
|
||||
// Always present (permission service composed): the whole select.
|
||||
values['permissions'] = permissionSelectOf(log)
|
||||
// Always present (plan-mode unit composed): the {active, pending} view.
|
||||
values['plan'] = planViewOf(log)
|
||||
// Always present (GoalService unit composed): null before create / after clear.
|
||||
@@ -373,6 +491,16 @@ function projectionFramesOf(id: SessionId, log: readonly SessionEvent[], event:
|
||||
seq: event.seq,
|
||||
}]
|
||||
}
|
||||
// Knob fold: any of the three whole-value knob events advances the select.
|
||||
if (type === 'permission/preset' || type === 'sandbox/mode' || type === 'approval/policy') {
|
||||
return [{
|
||||
type: 'session/projection',
|
||||
sessionId: id,
|
||||
key: 'permissions',
|
||||
value: permissionSelectOf(log),
|
||||
seq: event.seq,
|
||||
}]
|
||||
}
|
||||
// The plan unit advances on its two folded event kinds.
|
||||
if (type === 'plan/mode' || (type === 'command/run'
|
||||
&& (event as unknown as { data: { name?: string } }).data.name === 'plan')) {
|
||||
@@ -575,9 +703,43 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
updatedAt: fixtureEpoch,
|
||||
}]
|
||||
let nextWorkspace = 1
|
||||
|
||||
// In-memory browse tree behind the fixture's `browse` picker capability —
|
||||
// deterministic content mirroring the design mock so assembled Web tests
|
||||
// and snapshots can walk it. Leaves are materialized lazily: a child listed
|
||||
// by its parent lists as empty until something is created inside it.
|
||||
const FIXTURE_HOME = '/home/fixture'
|
||||
const directoryTree = new Map<string, string[]>([
|
||||
['/', ['home']],
|
||||
['/home', ['fixture']],
|
||||
[FIXTURE_HOME, ['Documents', 'Downloads', '.config']],
|
||||
[`${FIXTURE_HOME}/Documents`, [
|
||||
'project', 'deepseek-iOS', 'deepseek-android', 'deepseek-platform',
|
||||
'deepseek-web', 'deepseek-harness', 'deepseek-app', 'deepseek-landing-blog',
|
||||
]],
|
||||
])
|
||||
const childrenOf = (path: string): string[] | undefined => {
|
||||
const known = directoryTree.get(path)
|
||||
if (known !== undefined) return known
|
||||
const parent = path.slice(0, path.lastIndexOf('/')) || '/'
|
||||
const name = path.slice(path.lastIndexOf('/') + 1)
|
||||
return directoryTree.get(parent)?.includes(name) === true ? [] : undefined
|
||||
}
|
||||
const crumbsOf = (path: string): { name: string; path: string; hidden: boolean }[] => {
|
||||
const crumbs = [{ name: '/', path: '/', hidden: false }]
|
||||
let acc = ''
|
||||
for (const segment of path.split('/').filter(Boolean)) {
|
||||
acc += `/${segment}`
|
||||
crumbs.push({ name: segment, path: acc, hidden: false })
|
||||
}
|
||||
return crumbs
|
||||
}
|
||||
const mint = (): ReturnType<typeof RpcId> => RpcId(`fx-rpc-${nextRpc++}`)
|
||||
/** Resident pending approval (stable rpcId: every mux open replays the same id, matching host replay semantics). */
|
||||
/** Resident pending approval (stable rpcId: every mux open replays the same id while unanswered, matching host replay semantics). */
|
||||
const pendingApprovalRpcId = mint()
|
||||
const pendingApprovalId = 'fx-approval-1' as Extract<MuxFrame, { type: 'approval/requested' }>['approvalId']
|
||||
/** Cleared once answered through respond; replay stops and approval/resolved is broadcast. */
|
||||
let approvalPending = true
|
||||
const pendingQuestionRpcId = mint()
|
||||
let questionPending = true
|
||||
const fixtureQuestions: Extract<MuxFrame, { type: 'question/requested' }>['questions'] = [
|
||||
@@ -859,6 +1021,27 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
if (options.dropSessionCreateResponse) throw new Error('fixture: dropped session.create response after publication')
|
||||
return ok(request, { sessionId: created.sessionId })
|
||||
},
|
||||
rename: (request) => {
|
||||
const missing = requireSession(request)
|
||||
if (missing !== undefined) return missing
|
||||
const { sessionId, title } = request.payload
|
||||
const normalized = title.trim().replace(/\s+/g, ' ')
|
||||
if (normalized.length === 0) {
|
||||
return err(request, {
|
||||
code: 'title-invalid',
|
||||
message: 'session title must contain visible characters',
|
||||
details: { sessionId },
|
||||
})
|
||||
}
|
||||
// The append emits the session/event and its session/projection frame
|
||||
// (host parallel); the unary response settles the caller first.
|
||||
append(sessionId, {
|
||||
type: 'session/title',
|
||||
data: { title: normalized, messageSeqs: [], source: { kind: 'user' } },
|
||||
})
|
||||
const appended = logOf(sessionId).at(-1) as SessionEvent
|
||||
return ok(request, { title: normalized, seq: appended.seq })
|
||||
},
|
||||
history: async (request) => {
|
||||
const log = logs.get(request.payload.sessionId) ?? []
|
||||
// Snapshot at request time, deliver after the transit delay (mirrors a real host under latency).
|
||||
@@ -980,7 +1163,42 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
},
|
||||
host: {
|
||||
describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions }),
|
||||
pickDirectory: request => ok(request, { path: null }),
|
||||
// Deterministic native pick: the keyless lanes drive the full
|
||||
// pick-then-adopt path without an OS chooser (design-mock content,
|
||||
// same tree the browse primitives serve).
|
||||
pickDirectory: request => ok(request, { path: `${FIXTURE_HOME}/Documents/project` }),
|
||||
listDirectory: (request) => {
|
||||
const target = request.payload.path ?? FIXTURE_HOME
|
||||
const children = childrenOf(target)
|
||||
if (children === undefined) {
|
||||
return err(request, { code: 'directory-unreadable', message: `cannot list ${target}: not in the fixture tree`, details: { path: target } })
|
||||
}
|
||||
return ok(request, {
|
||||
path: target,
|
||||
home: FIXTURE_HOME,
|
||||
crumbs: crumbsOf(target),
|
||||
entries: [...children].sort((a, b) => a.localeCompare(b))
|
||||
.map(name => ({ name, path: target === '/' ? `/${name}` : `${target}/${name}`, hidden: name.startsWith('.') })),
|
||||
// The fixture tree is tiny; no level ever reaches a backend bound.
|
||||
truncated: false,
|
||||
})
|
||||
},
|
||||
createDirectory: (request) => {
|
||||
const parent = request.payload.path
|
||||
const children = childrenOf(parent)
|
||||
if (children === undefined) {
|
||||
return err(request, { code: 'directory-create-failed', message: `missing parent ${parent}`, details: { path: parent } })
|
||||
}
|
||||
// Same root special case as listDirectory's entry paths: a plain join
|
||||
// under '/' would mint '//name' and fork the tree's identity.
|
||||
const target = parent === '/' ? `/${request.payload.name}` : `${parent}/${request.payload.name}`
|
||||
if (children.includes(request.payload.name)) {
|
||||
return err(request, { code: 'directory-exists', message: `${target} already exists`, details: { path: target } })
|
||||
}
|
||||
directoryTree.set(parent, [...children, request.payload.name])
|
||||
directoryTree.set(target, [])
|
||||
return ok(request, { path: target })
|
||||
},
|
||||
openPath: request => ok(request, { opened: true as const }),
|
||||
},
|
||||
workspace: {
|
||||
@@ -1082,6 +1300,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
{ name: 'compact', description: 'fixture:压缩当前会话上下文' },
|
||||
{ name: 'echo', description: 'fixture:回显参数', input: { hint: 'text to echo' } },
|
||||
{ name: 'goal', description: 'set or view the goal for a long-running task', input: { hint: '<objective>' } },
|
||||
{ name: 'permission', description: 'Switch the permission preset (sandbox mode + approval policy)', input: { hint: '<preset>' } },
|
||||
{ name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]' } },
|
||||
],
|
||||
})
|
||||
@@ -1098,6 +1317,26 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
const match = /^\/(\S+)((?:\s.*)?)$/.exec(request.payload.line.trim())
|
||||
const name = match?.[1]
|
||||
const args = match?.[2] ?? ''
|
||||
// /permission mirrors the host handler: switch through the knob
|
||||
// events (each append pushes a permissions projection frame).
|
||||
if (name === 'permission') {
|
||||
const preset = args.trim()
|
||||
const commandId = `fx-cmd-${logOf(id).length}` as CommandId
|
||||
append(id, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } })
|
||||
const spec = PERMISSION_PRESETS[preset]
|
||||
if (preset === '') {
|
||||
const current = permissionSelectOf(logOf(id)).currentValue
|
||||
append(id, { type: 'command/done', data: { commandId, kind: 'success', text: `Current permission preset: ${current}. Available: ${Object.keys(PERMISSION_PRESETS).join(', ')}.` } })
|
||||
} else if (spec === undefined) {
|
||||
append(id, { type: 'command/done', data: { commandId, kind: 'error', text: `unknown permission preset ${JSON.stringify(preset)} (available: ${Object.keys(PERMISSION_PRESETS).join(', ')})` } })
|
||||
} else {
|
||||
if (permissionSelectOf(logOf(id)).currentValue !== preset) append(id, { type: 'permission/preset', data: { preset } })
|
||||
append(id, { type: 'sandbox/mode', data: { mode: spec.sandbox } })
|
||||
append(id, { type: 'approval/policy', data: { policy: spec.approval } })
|
||||
append(id, { type: 'command/done', data: { commandId, kind: 'success', text: `Permission preset: ${preset}.` } })
|
||||
}
|
||||
return ok(request, { matched: true as const, commandId })
|
||||
}
|
||||
if (name === 'goal') {
|
||||
// Host parallel: /goal with an objective creates (or reports) the
|
||||
// current goal; the command lifecycle pair brackets the mutation.
|
||||
@@ -1232,14 +1471,16 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
conn.push({ rpcId: mint(), payload: { type: 'session/projection', sessionId: s.sessionId, key, value: values[key], seq: log.length - 1 } })
|
||||
}
|
||||
}
|
||||
conn.push({
|
||||
rpcId: pendingApprovalRpcId,
|
||||
payload: {
|
||||
type: 'approval/requested', sessionId: sid('fx-alpha'),
|
||||
approvalId: 'fx-approval-1' as MuxFrame extends never ? never : Extract<MuxFrame, { type: 'approval/requested' }>['approvalId'],
|
||||
toolName: 'dangerous_tool', reason: 'fixture 常驻占位审批(可见不可答)',
|
||||
},
|
||||
})
|
||||
if (approvalPending) {
|
||||
conn.push({
|
||||
rpcId: pendingApprovalRpcId,
|
||||
payload: {
|
||||
type: 'approval/requested', sessionId: sid('fx-alpha'),
|
||||
approvalId: pendingApprovalId,
|
||||
toolName: 'dangerous_tool', reason: 'fixture 常驻审批(可答:批准/拒绝后消失)',
|
||||
},
|
||||
})
|
||||
}
|
||||
if (questionPending) {
|
||||
conn.push({
|
||||
rpcId: pendingQuestionRpcId,
|
||||
@@ -1277,6 +1518,19 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
},
|
||||
},
|
||||
respond(message: ClientResponse): Promise<RpcReceipt> {
|
||||
// Same routing discipline as the host: rpcId first, then the payload's
|
||||
// audit correlation; a settled or unknown id is not-pending.
|
||||
if (message.rpcId === pendingApprovalRpcId) {
|
||||
if (!approvalPending) return Promise.resolve({ accepted: false, reason: 'not-pending' })
|
||||
if (!message.result.ok) return Promise.resolve({ accepted: false, reason: 'bad-response' })
|
||||
const value = message.result.value as { approvalId?: unknown; outcome?: unknown }
|
||||
if (value.approvalId !== pendingApprovalId || (value.outcome !== 'allowed-once' && value.outcome !== 'rejected')) {
|
||||
return Promise.resolve({ accepted: false, reason: 'bad-response' })
|
||||
}
|
||||
approvalPending = false
|
||||
emitMux({ type: 'approval/resolved', sessionId: sid('fx-alpha'), approvalId: pendingApprovalId, outcome: value.outcome })
|
||||
return Promise.resolve({ accepted: true })
|
||||
}
|
||||
if (!questionPending || message.rpcId !== pendingQuestionRpcId) {
|
||||
return Promise.resolve({ accepted: false, reason: 'not-pending' })
|
||||
}
|
||||
@@ -1331,10 +1585,13 @@ export class FixtureApiClient extends AbstractApiClient {
|
||||
case 'session.history': return this.api.sessions.history(request)
|
||||
case 'session.models': return this.api.sessions.models(request)
|
||||
case 'session.selectModel': return this.api.sessions.selectModel(request)
|
||||
case 'session.rename': return this.api.sessions.rename(request)
|
||||
case 'session.prompt': return this.api.sessions.prompt(request)
|
||||
case 'session.cancel': return this.api.sessions.cancel(request)
|
||||
case 'host.describe': return this.api.host.describe(request)
|
||||
case 'host.pickDirectory': return this.api.host.pickDirectory(request, new AbortController().signal)
|
||||
case 'host.listDirectory': return this.api.host.listDirectory(request, new AbortController().signal)
|
||||
case 'host.createDirectory': return this.api.host.createDirectory(request)
|
||||
case 'host.openPath': return this.api.host.openPath(request, new AbortController().signal)
|
||||
case 'workspace.list': return this.api.workspace.list(request)
|
||||
case 'workspace.create': return this.api.workspace.create(request)
|
||||
|
||||
@@ -13,6 +13,7 @@ import { WebApiClient } from './web-api-client.ts'
|
||||
export type {
|
||||
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
|
||||
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
|
||||
DirectoryEntry, DirectoryListing,
|
||||
ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
|
||||
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
|
||||
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/** Host HTTP bridge for browser-client RPC. */
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
// Activates the httpServer Context merge used below.
|
||||
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
import { API_PATH } from './api-path.ts'
|
||||
import { bridge } from './http-bridge.ts'
|
||||
import { isTrustedNativeDialogRequest } from './native-dialog-request.ts'
|
||||
import { assertTrustedAuthority, isTrustedApiRequest } from './api-request-trust.ts'
|
||||
|
||||
export { API_PATH } from './api-path.ts'
|
||||
|
||||
@@ -15,20 +16,42 @@ export const name = 'client-connection'
|
||||
/** Services required before mounting the route. */
|
||||
export const inject = ['httpServer', 'apiProxy']
|
||||
|
||||
/** Plugin config: the deployment's non-loopback serving authorities. */
|
||||
export interface ConnectionConfig {
|
||||
/**
|
||||
* Authorities this deployment serves beyond loopback: exact `host:port`, or
|
||||
* port-less `host` matching any port. The /api trust fence refuses any
|
||||
* request whose Host is neither loopback nor listed here, so a
|
||||
* non-loopback (`0.0.0.0`) deployment must declare the names it is reached
|
||||
* by (the dsh CLI derives the machine's LAN IP literals itself). An entry
|
||||
* that is not a bare, canonical authority fails the plugin load.
|
||||
*/
|
||||
trustedHosts?: string[]
|
||||
}
|
||||
|
||||
export const Config: z<ConnectionConfig> = z.object({
|
||||
trustedHosts: z.array(String).default([]),
|
||||
})
|
||||
|
||||
/**
|
||||
* Mounts the API gateway under the browser transport prefix.
|
||||
* Mounts the API gateway under the browser transport prefix. Every request on
|
||||
* the prefix passes the browser-trust fence first (DNS-rebinding and
|
||||
* cross-site defense — [api-request-trust](./api-request-trust.ts)).
|
||||
* @param ctx - Host plugin context.
|
||||
* @param config - resolved plugin config (schema defaults applied).
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
export function apply(ctx: Context, config?: ConnectionConfig): void {
|
||||
// The Loader resolves schema defaults; hand-built test contexts may pass none.
|
||||
const trustedHosts = config?.trustedHosts ?? []
|
||||
// Config boundary: a malformed entry fails the load loudly here rather than
|
||||
// silently authorizing its hostname prefix at request time.
|
||||
for (const entry of trustedHosts) assertTrustedAuthority(entry)
|
||||
const apiHandler = toFetchHandler(ctx.apiProxy)
|
||||
const route: WebRoute = {
|
||||
kind: 'prefix',
|
||||
path: API_PATH,
|
||||
handler: async (req, res) => {
|
||||
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
|
||||
if ((pathname === `${API_PATH}/host.pickDirectory`
|
||||
|| pathname === `${API_PATH}/host.openPath`)
|
||||
&& !isTrustedNativeDialogRequest(req)) {
|
||||
if (!isTrustedApiRequest(req, trustedHosts)) {
|
||||
res.writeHead(403)
|
||||
res.end('forbidden')
|
||||
return
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
/** Trust check for browser requests that can invoke privileged native host actions. */
|
||||
|
||||
import type { IncomingHttpHeaders } from 'node:http'
|
||||
|
||||
interface NativeDialogRequest {
|
||||
headers: IncomingHttpHeaders
|
||||
socket: { remoteAddress?: string | undefined }
|
||||
}
|
||||
|
||||
function header(headers: IncomingHttpHeaders, name: string): string | undefined {
|
||||
const value = headers[name]
|
||||
return typeof value === 'string' ? value : undefined
|
||||
}
|
||||
|
||||
function isLoopback(address: string | undefined): boolean {
|
||||
if (address === undefined) return false
|
||||
if (address === '::1') return true
|
||||
const ipv4 = address.startsWith('::ffff:') ? address.slice('::ffff:'.length) : address
|
||||
const first = ipv4.split('.')[0]
|
||||
return first === '127'
|
||||
}
|
||||
|
||||
function isLoopbackHostname(hostname: string): boolean {
|
||||
if (hostname === 'localhost' || hostname === '[::1]' || hostname === '::1') return true
|
||||
const parts = hostname.split('.')
|
||||
return parts.length === 4
|
||||
&& parts[0] === '127'
|
||||
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
|
||||
}
|
||||
|
||||
/**
|
||||
* Require a local socket plus browser-controlled same-origin metadata.
|
||||
* @param request - the node HTTP request facts used by the carrier guard.
|
||||
* @returns true only for a same-origin browser request whose peer and URL are loopback.
|
||||
*/
|
||||
export function isTrustedNativeDialogRequest(request: NativeDialogRequest): boolean {
|
||||
if (!isLoopback(request.socket.remoteAddress)) return false
|
||||
if (header(request.headers, 'sec-fetch-site') !== 'same-origin') return false
|
||||
const origin = header(request.headers, 'origin')
|
||||
const host = header(request.headers, 'host')
|
||||
if (origin === undefined || host === undefined) return false
|
||||
try {
|
||||
const parsed = new URL(origin)
|
||||
const hostUrl = new URL(`http://${host}`)
|
||||
return (parsed.protocol === 'http:' || parsed.protocol === 'https:')
|
||||
&& parsed.host === host
|
||||
&& isLoopbackHostname(parsed.hostname)
|
||||
&& isLoopbackHostname(hostUrl.hostname)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
108
packages/client/connection/tests/api-request-trust.spec.ts
Normal file
108
packages/client/connection/tests/api-request-trust.spec.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/** Behavior of the /api browser-trust fence (rebinding + cross-site defense). */
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { assertTrustedAuthority, isTrustedApiRequest } from '../src/api-request-trust.ts'
|
||||
|
||||
function request(headers: Record<string, string | undefined>): { headers: Record<string, string | undefined> } {
|
||||
return { headers }
|
||||
}
|
||||
|
||||
describe('isTrustedApiRequest', () => {
|
||||
it('holds markerless requests to the same Host fence — a plain-HTTP browser read carries no markers', () => {
|
||||
// Over plain HTTP a browser attaches neither Origin nor Fetch-Metadata to
|
||||
// reads (EventSource, images, navigations), so a rebound-origin GET is
|
||||
// markerless and its response readable: no marker shortcut may exist.
|
||||
expect(isTrustedApiRequest(request({ host: '127.0.0.1:3080' }), [])).toBe(true)
|
||||
expect(isTrustedApiRequest(request({ host: '192.168.1.5:3080' }), ['192.168.1.5'])).toBe(true)
|
||||
expect(isTrustedApiRequest(request({ host: '192.168.1.5:3080' }), [])).toBe(false)
|
||||
expect(isTrustedApiRequest(request({ host: 'harness.example' }), [])).toBe(false)
|
||||
expect(isTrustedApiRequest(request({}), [])).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts loopback Hosts in every spelling, with and without ports, for browser requests', () => {
|
||||
for (const host of ['localhost', 'localhost:3080', '127.0.0.1', '127.0.0.1:3080', '127.8.9.10:80', '[::1]', '[::1]:3080', 'LOCALHOST:3080']) {
|
||||
expect(isTrustedApiRequest(request({ host, origin: `http://${host}` }), [])).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses a rebound Host: the attacker domain names the socket it did not expect', () => {
|
||||
expect(isTrustedApiRequest(request({
|
||||
host: 'evil.example:3080',
|
||||
origin: 'http://evil.example:3080',
|
||||
'sec-fetch-site': 'same-origin',
|
||||
}), [])).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts a declared public authority: exact on host:port entries, any port on port-less entries', () => {
|
||||
const headers = { host: 'harness.internal:3080', origin: 'http://harness.internal:3080' }
|
||||
expect(isTrustedApiRequest(request(headers), ['harness.internal:3080'])).toBe(true)
|
||||
expect(isTrustedApiRequest(request(headers), ['harness.internal'])).toBe(true)
|
||||
expect(isTrustedApiRequest(request(headers), ['harness.internal:9999'])).toBe(false)
|
||||
expect(isTrustedApiRequest(request(headers), [])).toBe(false)
|
||||
})
|
||||
|
||||
it('matches Host, Origin, and trusted entries through WHATWG normalization (case, default port)', () => {
|
||||
expect(isTrustedApiRequest(request({ host: 'Harness.INTERNAL:3080', origin: 'http://harness.internal:3080' }), ['harness.internal:3080'])).toBe(true)
|
||||
expect(isTrustedApiRequest(request({ host: 'harness.internal', origin: 'http://harness.internal' }), ['HARNESS.internal:80'])).toBe(true)
|
||||
// An unparsable entry never matches; it must not poison the rest of the list.
|
||||
expect(isTrustedApiRequest(request({ host: 'harness.internal', origin: 'http://harness.internal' }), ['bad entry', 'harness.internal'])).toBe(true)
|
||||
expect(isTrustedApiRequest(request({ host: 'harness.internal', origin: 'http://harness.internal' }), ['bad entry'])).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses cross-origin browser markers even on a loopback Host', () => {
|
||||
// Origin present and different → cross-site request that survived preflight rules.
|
||||
expect(isTrustedApiRequest(request({ host: '127.0.0.1:3080', origin: 'http://evil.example' }), [])).toBe(false)
|
||||
// Explicit cross-site label → refused regardless of Origin.
|
||||
expect(isTrustedApiRequest(request({ host: '127.0.0.1:3080', 'sec-fetch-site': 'cross-site' }), [])).toBe(false)
|
||||
// Opaque origin (sandboxed iframe, file: page) parses to no authority.
|
||||
expect(isTrustedApiRequest(request({ host: '127.0.0.1:3080', origin: 'null' }), [])).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts a same-origin browser request, with or without an Origin header', () => {
|
||||
expect(isTrustedApiRequest(request({
|
||||
host: 'localhost:3080',
|
||||
origin: 'http://localhost:3080',
|
||||
'sec-fetch-site': 'same-origin',
|
||||
}), [])).toBe(true)
|
||||
// Origin-less browser shapes (same-origin GETs) still carry sec-fetch-site.
|
||||
expect(isTrustedApiRequest(request({ host: 'localhost:3080', 'sec-fetch-site': 'same-origin' }), [])).toBe(true)
|
||||
})
|
||||
|
||||
it('assertTrustedAuthority accepts bare authorities and throws on anything more', () => {
|
||||
for (const entry of ['harness.internal', 'harness.internal:3080', 'HARNESS.internal:80', '10.0.0.9', '[::1]:3080']) {
|
||||
expect(() => { assertTrustedAuthority(entry) }).not.toThrow()
|
||||
}
|
||||
// WHATWG parsing would quietly read a hostname out of each of these; the
|
||||
// config boundary must refuse them instead of authorizing the prefix.
|
||||
for (const entry of ['harness.internal/path', 'harness.internal/', 'user@harness.internal', 'harness.internal?x', 'harness.internal#f', 'harness.internal\\path', 'bad entry', '']) {
|
||||
expect(() => { assertTrustedAuthority(entry) }).toThrow(/not a bare host\[:port\] authority/)
|
||||
}
|
||||
// WHATWG trimming would silently strip these; the entry must fail instead.
|
||||
for (const entry of ['harness.internal:3080 ', ' harness.internal', 'harness.internal:30\t80']) {
|
||||
expect(() => { assertTrustedAuthority(entry) }).toThrow(/not a bare host\[:port\] authority/)
|
||||
}
|
||||
// WHATWG parsing would silently rewrite these — a dangling colon or
|
||||
// zero-padded port would broaden an intended exact-port grant to every
|
||||
// port, and non-canonical host spellings would not read back as written.
|
||||
for (const entry of ['harness.internal:', '[::1]:', 'harness.internal:0080', '0x7f.0.0.1', '[0:0:0:0:0:0:0:1]']) {
|
||||
expect(() => { assertTrustedAuthority(entry) }).toThrow(/not a bare host\[:port\] authority/)
|
||||
}
|
||||
})
|
||||
|
||||
it('never lets stray whitespace broaden an exact-port entry to every port', () => {
|
||||
// Defense in depth below the load-time assert: the explicit-port judgment
|
||||
// reads the parsed URL, so a trimmed `host:port ` entry stays exact.
|
||||
const trusted = ['harness.internal:3080 ']
|
||||
expect(isTrustedApiRequest(request({ host: 'harness.internal:9999', origin: 'http://harness.internal:9999' }), trusted)).toBe(false)
|
||||
expect(isTrustedApiRequest(request({ host: 'harness.internal:3080', origin: 'http://harness.internal:3080' }), trusted)).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses malformed or untrusted authorities on browser requests', () => {
|
||||
const markers = { 'sec-fetch-site': 'same-origin' }
|
||||
expect(isTrustedApiRequest(request({ ...markers }), [])).toBe(false)
|
||||
expect(isTrustedApiRequest(request({ ...markers, host: '' }), [])).toBe(false)
|
||||
expect(isTrustedApiRequest(request({ ...markers, host: 'bad host' }), [])).toBe(false)
|
||||
expect(isTrustedApiRequest(request({ ...markers, host: '127.0.0.999' }), [])).toBe(false)
|
||||
expect(isTrustedApiRequest(request({ ...markers, host: '128.0.0.1' }), [])).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -45,6 +45,7 @@ export class FakeApiClient implements IApiClient {
|
||||
// Programmable slots (defaults answer OK-empty); reassign per case.
|
||||
onList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
|
||||
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
|
||||
onRename: (payload: unknown) => Promise<RpcResponse<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
|
||||
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
|
||||
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean; modelTarget: ModelTarget }>> =
|
||||
() => Promise.resolve(ok({
|
||||
@@ -70,6 +71,18 @@ export class FakeApiClient implements IApiClient {
|
||||
onOpenPath: (payload: unknown) => Promise<RpcResponse<{ opened: true }>> =
|
||||
() => Promise.resolve(ok({ opened: true as const }))
|
||||
|
||||
onListDirectory: (payload: unknown) => Promise<RpcResponse<{
|
||||
path: string
|
||||
home: string
|
||||
crumbs: { name: string; path: string; hidden: boolean }[]
|
||||
entries: { name: string; path: string; hidden: boolean }[]
|
||||
truncated: boolean
|
||||
}>> =
|
||||
() => Promise.resolve(ok({ path: '/home/fake', home: '/home/fake', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [], truncated: false }))
|
||||
|
||||
onCreateDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string }>> =
|
||||
() => Promise.resolve(ok({ path: '/home/fake/new' }))
|
||||
|
||||
private readonly muxConns: StreamConn<MuxFrame>[] = []
|
||||
private readonly hostConns: StreamConn<HostFrame>[] = []
|
||||
|
||||
@@ -84,6 +97,7 @@ export class FakeApiClient implements IApiClient {
|
||||
models: (payload: unknown) => this.record('session.models', payload, this.onModels(payload)),
|
||||
selectModel: (payload: ModelTarget & { sessionId: SessionId }) =>
|
||||
this.record('session.selectModel', payload, this.onSelectModel(payload)),
|
||||
rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(payload)),
|
||||
prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
|
||||
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
|
||||
}
|
||||
@@ -91,6 +105,8 @@ export class FakeApiClient implements IApiClient {
|
||||
readonly host: IApiClient['host'] = {
|
||||
describe: payload => this.record('host.describe', payload, this.onDescribe(payload)),
|
||||
pickDirectory: payload => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)),
|
||||
listDirectory: payload => this.record('host.listDirectory', payload, this.onListDirectory(payload)),
|
||||
createDirectory: payload => this.record('host.createDirectory', payload, this.onCreateDirectory(payload)),
|
||||
openPath: payload => this.record('host.openPath', payload, this.onOpenPath(payload)),
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ describe('createFixtureApi commands/skills', () => {
|
||||
expect(response.rpcId).toBe(request.rpcId)
|
||||
if (!response.result.ok) throw new Error('list failed')
|
||||
const commands = response.result.value.commands
|
||||
expect(commands.map(c => c.name)).toEqual(['compact', 'echo', 'goal', 'plan'])
|
||||
expect(commands.map(c => c.name)).toEqual(['compact', 'echo', 'goal', 'permission', 'plan'])
|
||||
// input hint rides only the commands declaring it.
|
||||
const echo = commands.find(c => c.name === 'echo')
|
||||
expect(echo?.input?.hint).toBeTruthy()
|
||||
|
||||
@@ -72,8 +72,19 @@ describe('createFixtureApi', () => {
|
||||
// Fixture composes the todos + plan units (host parallel when tool-todo
|
||||
// and plan-mode are mounted): the empty-log values.
|
||||
expect(empty.result.value).toEqual({
|
||||
events: [], hasMore: false,
|
||||
projections: { asOfSeq: -1, values: { goal: null, todos: null, plan: { active: false, pending: false } } },
|
||||
events: [], hasMore: false, projections: { asOfSeq: -1, values: {
|
||||
todos: null,
|
||||
// Permission unit composed: the composition-default select.
|
||||
permissions: {
|
||||
options: [
|
||||
{ value: 'workspace-write', name: 'workspace-write', description: 'Write inside the workspace and permitted temporary directories; wider retries require approval.' },
|
||||
{ value: 'danger-full-access', name: 'danger-full-access', description: 'Full file access without approval prompts.' },
|
||||
],
|
||||
currentValue: 'workspace-write',
|
||||
},
|
||||
plan: { active: false, pending: false },
|
||||
goal: null,
|
||||
} },
|
||||
})
|
||||
})
|
||||
|
||||
@@ -208,7 +219,7 @@ describe('createFixtureApi', () => {
|
||||
const envelopes: RpcRequest<MuxFrame>[] = []
|
||||
for await (const envelope of api.events.mux(req({}), abort.signal)) {
|
||||
envelopes.push(envelope)
|
||||
if (envelopes.length >= 7) abort.abort()
|
||||
if (envelopes.length >= 8) abort.abort()
|
||||
}
|
||||
return envelopes
|
||||
}
|
||||
@@ -216,15 +227,16 @@ describe('createFixtureApi', () => {
|
||||
const second = await openOnce()
|
||||
expect(first[0]?.payload).toMatchObject({ type: 'session/subscribed', sessionId: 'fx-alpha' })
|
||||
expect((first[0]?.payload as { lastSeq: number }).lastSeq).toBeGreaterThan(0)
|
||||
// Projection baseline frames follow the subscribed frame (title + todos + plan + goal units).
|
||||
// Projection baseline frames follow the subscribed frame (title + todos + permissions + plan + goal units).
|
||||
expect(first[1]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'title', value: 'Fixture 历史会话' })
|
||||
expect(first[2]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'todos' })
|
||||
expect(first[3]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'plan', value: { active: false, pending: false } })
|
||||
expect(first[4]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'goal', value: null })
|
||||
expect(first[5]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
|
||||
expect(second[5]?.rpcId).toBe(first[5]?.rpcId) // stable rpcId across replays (host replay semantics)
|
||||
expect(first[6]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' })
|
||||
expect(second[6]?.rpcId).toBe(first[6]?.rpcId)
|
||||
expect(first[3]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'permissions' })
|
||||
expect(first[4]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'plan', value: { active: false, pending: false } })
|
||||
expect(first[5]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'goal', value: null })
|
||||
expect(first[6]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
|
||||
expect(second[6]?.rpcId).toBe(first[6]?.rpcId) // stable rpcId across replays (host replay semantics)
|
||||
expect(first[7]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' })
|
||||
expect(second[7]?.rpcId).toBe(first[7]?.rpcId)
|
||||
})
|
||||
|
||||
it('steer with no replay in flight falls through to a fresh queued turn; non-text blocks stringify empty', async () => {
|
||||
@@ -311,6 +323,44 @@ describe('createFixtureApi', () => {
|
||||
})).toEqual({ accepted: true })
|
||||
})
|
||||
|
||||
it('respond answers the resident approval once: routing, validation, resolved broadcast, then not-pending', async () => {
|
||||
const api = createFixtureApi()
|
||||
// Discover the resident approval's stable rpcId from the mux baseline.
|
||||
const abort = new AbortController()
|
||||
const seen: { rpcId: string; frame: MuxFrame }[] = []
|
||||
const consuming = (async () => {
|
||||
for await (const envelope of api.events.mux(req({}), abort.signal)) seen.push({ rpcId: envelope.rpcId, frame: envelope.payload })
|
||||
})()
|
||||
await vi.waitFor(() => {
|
||||
expect(seen.some(s => s.frame.type === 'approval/requested')).toBe(true)
|
||||
})
|
||||
const requested = seen.find(s => s.frame.type === 'approval/requested')
|
||||
if (requested === undefined || requested.frame.type !== 'approval/requested') throw new Error('unreachable')
|
||||
const approvalId = requested.frame.approvalId
|
||||
|
||||
// Routed but malformed answers.
|
||||
expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: false, error: { code: 'internal', message: 'x', details: {} } } }))
|
||||
.toEqual({ accepted: false, reason: 'bad-response' })
|
||||
expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: true, value: { approvalId: 'wrong', outcome: 'rejected' } } }))
|
||||
.toEqual({ accepted: false, reason: 'bad-response' })
|
||||
expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: true, value: { approvalId, outcome: 'maybe' } } }))
|
||||
.toEqual({ accepted: false, reason: 'bad-response' })
|
||||
// The real answer settles the question and broadcasts resolved.
|
||||
expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: true, value: { sessionId: sid('fx-alpha'), approvalId, outcome: 'allowed-once' } } }))
|
||||
.toEqual({ accepted: true })
|
||||
await vi.waitFor(() => {
|
||||
expect(seen.some(s => s.frame.type === 'approval/resolved' && s.frame.outcome === 'allowed-once')).toBe(true)
|
||||
})
|
||||
// Settled: a duplicate answer is late, and a fresh mux open replays nothing.
|
||||
expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: true, value: { sessionId: sid('fx-alpha'), approvalId, outcome: 'rejected' } } }))
|
||||
.toEqual({ accepted: false, reason: 'not-pending' })
|
||||
abort.abort()
|
||||
await consuming
|
||||
const abort2 = new AbortController()
|
||||
const replayed = await collect(api.events.mux(req({}), abort2.signal), abort2, frames => frames.length === 2)
|
||||
expect(replayed.some(f => f.type === 'approval/requested')).toBe(false)
|
||||
})
|
||||
|
||||
it('describe answers the fixture identity', async () => {
|
||||
const api = createFixtureApi()
|
||||
const response = await api.host.describe(req({}))
|
||||
@@ -319,6 +369,22 @@ describe('createFixtureApi', () => {
|
||||
expect(empty.result).toMatchObject({ ok: true, value: { attachedSessions: 0 } })
|
||||
})
|
||||
|
||||
it('createDirectory under the root mints /name whose listing and crumbs share the identity', async () => {
|
||||
const api = createFixtureApi()
|
||||
const created = await api.host.createDirectory(req({ path: '/', name: 'srv' }))
|
||||
if (!created.result.ok) throw new Error('create failed')
|
||||
expect(created.result.value.path).toBe('/srv')
|
||||
const listed = await api.host.listDirectory(req({ path: '/srv' }), new AbortController().signal)
|
||||
if (!listed.result.ok) throw new Error('list failed')
|
||||
expect(listed.result.value.crumbs).toEqual([
|
||||
{ name: '/', path: '/', hidden: false },
|
||||
{ name: 'srv', path: '/srv', hidden: false },
|
||||
])
|
||||
const root = await api.host.listDirectory(req({ path: '/' }), new AbortController().signal)
|
||||
if (!root.result.ok) throw new Error('root list failed')
|
||||
expect(root.result.value.entries).toContainEqual({ name: 'srv', path: '/srv', hidden: false })
|
||||
})
|
||||
|
||||
it('workspace.list serves the resident account and create reuses on path collision', async () => {
|
||||
const api = createFixtureApi()
|
||||
const listed = await api.workspace.list(req({}))
|
||||
@@ -398,6 +464,47 @@ describe('createFixtureApi', () => {
|
||||
expect(seen.map(f => f.type)).toEqual(['host/workspace-changed', 'host/workspace-changed'])
|
||||
})
|
||||
|
||||
it('session.rename covers not-found, blank title, and the accepted append + title frame', async () => {
|
||||
const api = createFixtureApi()
|
||||
const abort = new AbortController()
|
||||
const framesPromise = (async () => {
|
||||
const frames: MuxFrame[] = []
|
||||
for await (const envelope of api.events.mux(req({}), abort.signal)) {
|
||||
frames.push(envelope.payload)
|
||||
if (frames.some(f => f.type === 'session/projection' && f.key === 'title' && f.value === '重命名')) abort.abort()
|
||||
}
|
||||
return frames
|
||||
})()
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
|
||||
const missing = await api.sessions.rename(req({ sessionId: sid('fx-void'), title: 'x' }))
|
||||
expect(missing.result).toMatchObject({ ok: false, error: { code: 'session-not-found', details: { sessionId: 'fx-void' } } })
|
||||
|
||||
const blank = await api.sessions.rename(req({ sessionId: sid('fx-alpha'), title: ' ' }))
|
||||
expect(blank.result).toMatchObject({ ok: false, error: { code: 'title-invalid', details: { sessionId: 'fx-alpha' } } })
|
||||
|
||||
const renamed = await api.sessions.rename(req({ sessionId: sid('fx-alpha'), title: ' 重命名 ' }))
|
||||
if (!renamed.result.ok) throw new Error('rename failed')
|
||||
expect(renamed.result.value.title).toBe('重命名')
|
||||
const acceptedSeq = renamed.result.value.seq
|
||||
// The response seq addresses the appended title event (the client plane
|
||||
// has no session/title in its event union — titles ride the projection —
|
||||
// so the event is located by seq and its payload checked structurally).
|
||||
const history = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 100 }))
|
||||
if (!history.result.ok) throw new Error('history failed')
|
||||
const appended = history.result.value.events.find(entry => entry.event.seq === acceptedSeq)
|
||||
expect(appended?.event).toMatchObject({
|
||||
type: 'session/title',
|
||||
data: { title: '重命名', messageSeqs: [], source: { kind: 'user' } },
|
||||
})
|
||||
// Beyond the subscribe-time baseline replay, the append emitted exactly
|
||||
// one title projection frame carrying the new value at the response seq.
|
||||
const frames = await framesPromise
|
||||
const titleFrames = frames.filter(f => f.type === 'session/projection' && f.key === 'title' && f.sessionId === sid('fx-alpha') && f.value === '重命名')
|
||||
expect(titleFrames).toHaveLength(1)
|
||||
expect(titleFrames[0]).toMatchObject({ seq: acceptedSeq })
|
||||
})
|
||||
|
||||
it('workspace.insertSessionBefore moves, appends, no-ops, and rejects invalid ids', async () => {
|
||||
const api = createFixtureApi()
|
||||
const wsid = 'fx-ws-fixture' as WorkspaceId
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import type { IncomingHttpHeaders } from 'node:http'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isTrustedNativeDialogRequest } from '../src/native-dialog-request.ts'
|
||||
|
||||
function request(
|
||||
remoteAddress: string | undefined,
|
||||
headers: IncomingHttpHeaders = {
|
||||
host: '127.0.0.1:3080',
|
||||
origin: 'http://127.0.0.1:3080',
|
||||
'sec-fetch-site': 'same-origin',
|
||||
},
|
||||
) {
|
||||
return { socket: { remoteAddress }, headers }
|
||||
}
|
||||
|
||||
describe('native dialog request trust', () => {
|
||||
it('accepts loopback same-origin browser requests', () => {
|
||||
expect(isTrustedNativeDialogRequest(request('127.0.0.1'))).toBe(true)
|
||||
expect(isTrustedNativeDialogRequest(request('::1', {
|
||||
host: '[::1]:3080', origin: 'http://[::1]:3080', 'sec-fetch-site': 'same-origin',
|
||||
}))).toBe(true)
|
||||
expect(isTrustedNativeDialogRequest(request('::ffff:127.0.0.1'))).toBe(true)
|
||||
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
|
||||
host: 'localhost:3080', origin: 'http://localhost:3080', 'sec-fetch-site': 'same-origin',
|
||||
}))).toBe(true)
|
||||
expect(isTrustedNativeDialogRequest(request('127.0.0.2', {
|
||||
host: '127.0.0.2:3080', origin: 'https://127.0.0.2:3080', 'sec-fetch-site': 'same-origin',
|
||||
}))).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects remote sockets and requests without matching browser metadata', () => {
|
||||
expect(isTrustedNativeDialogRequest(request('192.168.1.5'))).toBe(false)
|
||||
expect(isTrustedNativeDialogRequest(request(undefined))).toBe(false)
|
||||
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
|
||||
host: '127.0.0.1:3080', origin: 'http://evil.example', 'sec-fetch-site': 'cross-site',
|
||||
}))).toBe(false)
|
||||
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
|
||||
host: '127.0.0.1:3080', origin: 'http://localhost:3080', 'sec-fetch-site': 'same-origin',
|
||||
}))).toBe(false)
|
||||
expect(isTrustedNativeDialogRequest(request('127.0.0.1', { host: '127.0.0.1:3080' }))).toBe(false)
|
||||
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
|
||||
origin: 'http://127.0.0.1:3080', 'sec-fetch-site': 'same-origin',
|
||||
}))).toBe(false)
|
||||
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
|
||||
host: 'attacker.example:3080', origin: 'http://attacker.example:3080', 'sec-fetch-site': 'same-origin',
|
||||
}))).toBe(false)
|
||||
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
|
||||
host: '127.0.0.1:3080', origin: 'ftp://127.0.0.1:3080', 'sec-fetch-site': 'same-origin',
|
||||
}))).toBe(false)
|
||||
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
|
||||
host: '127.999.0.1:3080', origin: 'http://127.999.0.1:3080', 'sec-fetch-site': 'same-origin',
|
||||
}))).toBe(false)
|
||||
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
|
||||
host: '[invalid', origin: 'http://[invalid', 'sec-fetch-site': 'same-origin',
|
||||
}))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,6 @@
|
||||
/** Node half: registers the /api prefix route bridging to the api gateway. */
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { Readable } from 'node:stream'
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
@@ -6,48 +8,113 @@ import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { API_PATH, apply, inject } from '../src/index.ts'
|
||||
|
||||
describe('connection node half', () => {
|
||||
it('registers the /api prefix route and removes it with the fiber', async () => {
|
||||
const ctx = new Context()
|
||||
const routes: WebRoute[] = []
|
||||
// Structural fake: the plugin only touches register(); the service class
|
||||
// carries private state a literal cannot (and need not) reproduce.
|
||||
const httpServer: Pick<HttpServerService, 'register' | 'tapIndex' | 'port'> = {
|
||||
register(route) {
|
||||
routes.push(route)
|
||||
return () => { routes.splice(routes.indexOf(route), 1) }
|
||||
},
|
||||
tapIndex: () => () => {},
|
||||
port: 0,
|
||||
}
|
||||
ctx.provide('httpServer', httpServer as HttpServerService)
|
||||
ctx.provide('apiProxy', {} as unknown as ApiProxy)
|
||||
/** Structural httpServer fake: the plugin only touches register(). */
|
||||
function fakeHttpServer(routes: WebRoute[]): Pick<HttpServerService, 'register' | 'tapIndex' | 'port'> {
|
||||
return {
|
||||
register(route) {
|
||||
routes.push(route)
|
||||
return () => { routes.splice(routes.indexOf(route), 1) }
|
||||
},
|
||||
tapIndex: () => () => {},
|
||||
port: 0,
|
||||
}
|
||||
}
|
||||
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
/** Bodyless GET carrying the given headers (enough for the trust fence + bridge). */
|
||||
function fakeRequest(headers: Record<string, string>): IncomingMessage {
|
||||
const request = Readable.from([]) as unknown as IncomingMessage
|
||||
Object.assign(request, { url: `${API_PATH}/session.list`, method: 'GET', headers })
|
||||
return request
|
||||
}
|
||||
|
||||
/** Response recorder compatible with both the fence's short-circuit and the bridge. */
|
||||
function fakeResponse(): { response: ServerResponse; state: { status?: number; body?: unknown } } {
|
||||
const state: { status?: number; body?: unknown } = {}
|
||||
const response = Object.assign(new EventEmitter(), {
|
||||
writableEnded: false,
|
||||
writeHead(value: number) { state.status = value; return this },
|
||||
write() { return true },
|
||||
end(this: { writableEnded: boolean }, value?: unknown) {
|
||||
if (value !== undefined) state.body = value
|
||||
this.writableEnded = true
|
||||
return this
|
||||
},
|
||||
}) as unknown as ServerResponse
|
||||
return { response, state }
|
||||
}
|
||||
|
||||
async function mounted(config?: { trustedHosts?: string[] }): Promise<{ routes: WebRoute[]; dispose: () => Promise<void> }> {
|
||||
const ctx = new Context()
|
||||
const routes: WebRoute[] = []
|
||||
ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService)
|
||||
ctx.provide('apiProxy', {} as unknown as ApiProxy)
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply }, config)
|
||||
await fiber.await()
|
||||
return { routes, dispose: () => fiber.dispose() }
|
||||
}
|
||||
|
||||
describe('connection node half', () => {
|
||||
it('fails the load on a trustedHosts entry that is not a bare authority', async () => {
|
||||
const routes: WebRoute[] = []
|
||||
const ctx = new Context()
|
||||
ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService)
|
||||
ctx.provide('apiProxy', {} as unknown as ApiProxy)
|
||||
// The apply throw also escapes cordis as a late rejection — the shape the
|
||||
// boot's installFailLoud is contracted to catch. Capture it so the run
|
||||
// stays clean, same pattern as the webserver bind-failure test.
|
||||
const rejections: unknown[] = []
|
||||
const onUnhandled = (err: unknown): void => { rejections.push(err) }
|
||||
process.on('unhandledRejection', onUnhandled)
|
||||
try {
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.internal/path'] })
|
||||
await expect(fiber.await()).rejects.toThrow(/not a bare host\[:port\] authority/)
|
||||
expect(routes).toHaveLength(0)
|
||||
for (let i = 0; i < 100 && rejections.length === 0; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
expect(rejections.map(String).join('\n')).toContain('not a bare host[:port] authority')
|
||||
} finally {
|
||||
process.off('unhandledRejection', onUnhandled)
|
||||
}
|
||||
})
|
||||
|
||||
it('registers the /api prefix route and removes it with the fiber', async () => {
|
||||
const { routes, dispose } = await mounted()
|
||||
expect(routes).toHaveLength(1)
|
||||
expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH })
|
||||
|
||||
for (const url of ['/api/host.pickDirectory', '/api/host.openPath']) {
|
||||
let status: number | undefined
|
||||
let body: unknown
|
||||
const deniedRequest = {
|
||||
url,
|
||||
headers: {
|
||||
host: 'harness.example', origin: 'http://harness.example', 'sec-fetch-site': 'same-origin',
|
||||
},
|
||||
socket: { remoteAddress: '192.168.1.8' },
|
||||
} as unknown as IncomingMessage
|
||||
const deniedResponse = {
|
||||
writeHead(value: number) { status = value; return this },
|
||||
end(value?: unknown) { body = value; return this },
|
||||
} as unknown as ServerResponse
|
||||
await routes[0]!.handler(deniedRequest, deniedResponse)
|
||||
expect(status).toBe(403)
|
||||
expect(body).toBe('forbidden')
|
||||
}
|
||||
|
||||
await fiber.dispose()
|
||||
await dispose()
|
||||
expect(routes).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('refuses an untrusted Host on any /api path before the bridge runs', async () => {
|
||||
const { routes, dispose } = await mounted()
|
||||
const { response, state } = fakeResponse()
|
||||
await routes[0]!.handler(fakeRequest({
|
||||
host: 'harness.example', origin: 'http://harness.example', 'sec-fetch-site': 'same-origin',
|
||||
}), response)
|
||||
expect(state.status).toBe(403)
|
||||
expect(state.body).toBe('forbidden')
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it('passes loopback and declared-authority requests through to the bridge', async () => {
|
||||
const { routes, dispose } = await mounted({ trustedHosts: ['harness.example:3080', '192.168.1.5'] })
|
||||
// Loopback, no browser markers (curl shape): the fence passes; the carrier
|
||||
// answers 404 for a GET unary path — proof the bridge ran.
|
||||
const loopback = fakeResponse()
|
||||
await routes[0]!.handler(fakeRequest({ host: '127.0.0.1:3080' }), loopback.response)
|
||||
expect(loopback.state.status).toBe(404)
|
||||
// LAN authority declared as a port-less IP literal — the shape the CLI
|
||||
// derives for `--host 0.0.0.0` — passes markerless curl on any port.
|
||||
const lan = fakeResponse()
|
||||
await routes[0]!.handler(fakeRequest({ host: '192.168.1.5:3080' }), lan.response)
|
||||
expect(lan.state.status).toBe(404)
|
||||
// Declared public authority, same-origin browser shape.
|
||||
const declared = fakeResponse()
|
||||
await routes[0]!.handler(fakeRequest({
|
||||
host: 'harness.example:3080', origin: 'http://harness.example:3080', 'sec-fetch-site': 'same-origin',
|
||||
}), declared.response)
|
||||
expect(declared.state.status).toBe(404)
|
||||
await dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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
|
||||
# pnpm run verify-translation-pairing --write packages/client/hmr/README.md
|
||||
README.md: 2b2f63c25cbf3a46babef78a4dfb52f859156887
|
||||
README.zh.md: 6d94ca4a5e91f390e58575aa4ddf64fc18a509de
|
||||
README.zh.md: 58fbad900d9ab86a9d28979f691f24de29e9b6f4
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
为通过 fetch 到达的客户端插件提供热重载。该静态到达配置项只组合进 `--dev` 图(`dsh web --dev`);生产图省略此行,因此外壳打包的代码保持不活动。
|
||||
为通过 fetch 加载的客户端插件提供热重载。该静态加载配置项只组合进 `--dev` 图(`dsh web --dev`);生产图省略该项,因此打包进 shell 的代码保持不活动。
|
||||
|
||||
浏览器侧订阅系统 SSE 通道(`GET /plugins/events`),每个 `rebuilt` 帧重载一个插件,并通过队列串行执行(组合包交接 slot 只能容纳一个)。每帧的顺序是:`prefetch`(在触碰任何内容前抓取新组合包)、`invalidate`、`registry.delete`(在 fiber 之前执行:只释放 fiber 会触发 vendored Loader 的 self-dispose 分支,把配置项标为禁用)、排空旧 fiber、删除 `entry.fiber`、移除自身拥有的 `<style data-plugin>` 标签、通过 `entry.refresh()` 重新导入并挂载、以 `fiber.await()` 将启动失败高声重新抛出。依赖方由 cordis 自身重载:fiber 的激活 epoch 会串联其服务提供方的 uid,因此替换提供方 fiber 会级联所有依赖方,无需客户端图分析。node 侧使用一个 interval 检测重建:从同步基线开始 stat-poll 每个图组合包;新增一行后立即重新计算 hash;缺失行保持 dirty;只广播真实 rev 变更。因此,任何生成组合包的 tsdown watch 进程都能触发 HMR,无需 builder→host 通道。
|
||||
浏览器侧订阅系统 SSE(Server-Sent Events)通道(`GET /plugins/events`),每个 `rebuilt` 帧重载一个插件,并通过队列串行执行(组合包交接 slot 只能容纳一个)。每帧的顺序是:`prefetch`(在触碰任何内容前抓取新组合包)、`invalidate`、`registry.delete`(在 fiber dispose(资源释放)之前执行:仅 dispose fiber 会触发 vendored Loader 的 self-dispose 分支,把配置项标为禁用)、排空旧 fiber、删除 `entry.fiber`、移除自身拥有的 `<style data-plugin>` 标签、通过 `entry.refresh()` 重新导入并挂载、通过 `fiber.await()` 直接重新抛出启动失败。依赖方由 Cordis 自身重载:fiber 的激活 epoch 会串联其服务提供方的 uid,因此替换提供方 fiber 会级联所有依赖方,无需客户端图分析。node 侧使用一个 interval 检测重建:从同步基线开始 stat-poll 每个图组合包;新增一行后立即重新计算 hash;缺失行保持 dirty;只广播真实 rev 变更。因此,任何生成组合包的 tsdown watch 进程都能触发 HMR(热模块替换),无需 builder→host 通道。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -12,10 +12,10 @@
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无;该包既不组装也不发送提供方请求。
|
||||
无;该包(package)既不组装也不发送提供方请求。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **重载有意保持粗粒度**:会创建全新的 fiber 和组件;重载插件中的 React 状态会丢失,数据层(connection/runtime fiber、Session 对象)不受影响。react-refresh 级状态保留与「重新执行组合包会重新运行 factory」冲突,因此有意排除。
|
||||
- **失败时不回滚**:失败的重载会使配置项处于 FAILED 状态,并在 loader 状态投影中高声报告;自动恢复先前组合包会等到实际需要出现后再实现。
|
||||
- **重建帧不会刷新图 rev**:陈旧 rev 无害(组合包端点以 no-cache 提供内容);rev 刷新会随重新连接握手机制落地。
|
||||
- **重载有意保持粗粒度**:会创建全新的 fiber 和组件;重载插件中的 React 状态会丢失,数据层(连接 fiber、运行时 fiber 和 Session 对象)不受影响。react-refresh 级状态保留与「重新执行组合包会重新运行 factory」冲突,因此有意排除。
|
||||
- **失败时不回滚**:失败的重载会使配置项处于 FAILED 状态,并在 loader 状态投影中明确显示;自动恢复先前组合包会等到实际需要出现后再实现。
|
||||
- **重建帧不会刷新图 rev**:陈旧 rev 无害(组合包端点以 no-cache 提供内容);rev 刷新将在重新连接握手机制中实现。
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/locale/README.md
|
||||
README.md: c2adbcabc77def740094288da4643032873aa5b8
|
||||
README.zh.md: 6232a98168a0a8fef3f8209ef6af8a3ce411f2be
|
||||
README.zh.md: c6ecb31e21d7513a4e7d579b17588107ccd7ea59
|
||||
|
||||
@@ -10,7 +10,7 @@ locale 插件:LocaleService——浏览器 locale 偏好(`zh`/`en`,以 `
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无;该包既不组装也不发送提供方请求。
|
||||
无;该包(package)既不组装也不发送提供方请求。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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
|
||||
# pnpm run verify-translation-pairing --write packages/client/modules/README.md
|
||||
README.md: efba9e2eb0b148677fc7ac18bfad6333fb6f80da
|
||||
README.zh.md: 7d1aa8af08256c47c1ae65343e46c30e910128d0
|
||||
README.zh.md: b057bfdd8c0a269252496d0c6a0fc4184932fd72
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
客户端模块系统:Node 内部 ESM loader 的浏览器端对等实现,以惰性 CJS 表构建。web 外壳挂载 vendored cordis Loader 来治理配置项(fiber 生命周期、inject 等待、update/refresh),并把该包的 `ClientModuleLoader` 作为其 `internal` seam 注入;vendored 一侧唯一的消费点是 `EntryTree.import`,因此替换 `internal` 恰好只会替换「插件代码如何到达」,不会改变其他内容。
|
||||
客户端模块系统:Node 内部 ESM loader 的浏览器端对等实现,以惰性 CJS 表实现。web 外壳挂载 vendored cordis Loader 来治理配置项(fiber 生命周期、inject 等待、update/refresh),并把该包(package)的 `ClientModuleLoader` 作为其 `internal` seam 注入;vendored 一侧唯一的消费点是 `EntryTree.import`,因此替换 `internal` 恰好只会替换「插件代码如何到达」,不会改变其他内容。
|
||||
|
||||
惰性 CJS 模型(web2):执行插件组合包只会注册其 factory(`window.__ModuleLoader__.load({id, factory})`);每个模块主体的副作用(包括 CSS 注入)都位于 factory 闭包中,在物化时运行(`factory(require)` → 导出表层,并在 `loadCache` 中记忆化),不会在脚本执行时运行。如果 factory 请求另一个已注册但尚未物化的模块,系统会递归物化它,因此加载顺序无需外部编排;require 循环会抛出异常(factory 形式的 CJS 无法交付部分导出)。`<id>/client` 与裸 id 指向同一表层(一个插件组合包就是其包的客户端侧)。
|
||||
惰性 CJS 模型(web2):执行插件组合包只会注册其 factory(`window.__ModuleLoader__.load({id, factory})`);每个模块主体的副作用(包括 CSS 注入)都位于 factory 闭包中,在物化时运行(`factory(require)` → 导出表层,并在 `loadCache` 中记忆化),不会在脚本执行时运行。如果 factory 依赖另一个已注册但尚未物化的模块,系统会递归物化它,因此加载顺序无需外部编排;require 循环会抛出异常(factory 形式的 CJS 无法提供部分导出)。`<id>/client` 与裸 id 指向同一表层(一个插件组合包就是其包的客户端侧)。
|
||||
|
||||
解析分支顺序(`import(specifier)`):平台种子词 → 外壳实例;记忆化记录 → 表层;外壳自身的静态注册表(`registerStatic`,app-shell)→ 模块;已注册 factory → 物化;图行(`window.__DSH_BOOT__`)→ 抓取 + 执行 + 物化;其他情况一律抛出异常。这是构建时组合包纯度门禁的运行时镜像。交给 factory 的同步 `require` 采用相同顺序,但不含抓取分支,并把观察到的边记录到模块记录中。`prefetch` 是第一阶段到达 hook(抓取 + 执行,只注册;并发调用共享一个进行中的 task);`invalidate` 会丢弃 factory 与物化记录,使下一次 prefetch/import 重新抓取(HMR hook)。
|
||||
解析分支顺序(`import(specifier)`):平台种子词 → 外壳实例;记忆化记录 → 表层;外壳自身的静态注册表(`registerStatic`,app-shell)→ 模块;已注册 factory → 物化;模块图记录(`window.__DSH_BOOT__`)→ 抓取 + 执行 + 物化;其他情况一律抛出异常。这是构建时组合包纯度门禁的运行时镜像。交给 factory 的同步 `require` 采用相同顺序,但不含抓取分支,并把观察到的边记录到模块记录中。`prefetch` 是第一阶段加载钩子(抓取 + 执行,只注册;并发调用共享一个进行中的任务);`invalidate` 会丢弃 factory 与物化记录,使下一次 prefetch/import 重新抓取;它是 HMR(热模块替换)钩子。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -18,5 +18,5 @@
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **有意采用扁平模块图**:每个组合包是一个模块节点,其边只指向表叶;接口(loadCache/edges/invalidate)按通用模块图塑形,因此可以改变 externalization 粒度而不更改接口。
|
||||
- **自身不记录卸载账目**:样式移除与 fiber 拆卸顺序属于 HMR 驱动器(`@deepseek-ai/dsh-client-hmr`);loader 只逐记录清点自身拥有的样式标签 id。
|
||||
- **有意采用扁平模块图**:每个组合包是一个模块节点,其边只指向表中的叶节点;接口(loadCache/edges/invalidate)按通用模块图塑形,因此可以改变 externalization 粒度而不更改接口。
|
||||
- **自身不记录卸载账目**:样式移除与 fiber 拆卸顺序属于 HMR 驱动器(`@deepseek-ai/dsh-client-hmr`);loader 只在每条记录中登记其拥有的样式标签 id。
|
||||
|
||||
@@ -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/runtime/README.md
|
||||
README.md: 25eb60e2c95059ae918669c9f5169b6b8e9c6816
|
||||
README.zh.md: e3085f91750503aeaffda41d86c40c62943b4ba9
|
||||
README.md: b51cc0276d8635ea9faa506e30246a107c1c1418
|
||||
README.zh.md: 4b2248d875ae37f1b848c51a0009d2497c6b3e61
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list/scope/history state; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into both managers. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`.
|
||||
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`.
|
||||
|
||||
## Workspace and Session lists
|
||||
|
||||
@@ -22,7 +22,7 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
|
||||
|
||||
## Session title projection
|
||||
|
||||
`SessionManager` retains the latest validated `session/title` control snapshot independently of list and session-instance arrival. Newer event seqs replace older snapshots, title timestamps contribute to list recency, and a subscription baseline discards any retained title beyond its `lastSeq` before the optional folded title arrives. Explicit session removal also clears the retained title. The client-facing `SessionSummary.title` is therefore only the actual durable title; `displayTitle` is always present and falls back through the cwd basename and session id. A cold persisted session keeps that fallback until opening or resuming it causes the host to fold and project its log-backed title.
|
||||
`SessionManager` retains the latest validated `session/title` control snapshot independently of list and session-instance arrival. Newer event seqs replace older snapshots, title timestamps contribute to list recency, and a subscription baseline discards any retained title beyond its `lastSeq` before the optional folded title arrives. Explicit session removal also clears the retained title. The client-facing `SessionSummary.title` is therefore only the actual durable title; `displayTitle` is always present and falls back through the cwd basename and session id. A cold persisted session keeps that fallback until opening or resuming it causes the host to fold and project its log-backed title. `ISession.rename` settles the `title` projection cell directly from the unary response's `{title, seq}` under the same higher-seq-wins rule — the list row and every `useProjection('title')` reader update ahead of the push frame, whose later replay of the same seq is a no-op.
|
||||
|
||||
## Session model selection
|
||||
|
||||
|
||||
@@ -2,19 +2,19 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象、列表/scope/history 状态;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给两个 manager。客户端 Session 一律由 Host 出生(一次 `session.create` 同瞬产出 Session+Agent+cwd);客户端不持有任何实体化之前的会话状态——Agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时出生,随 prune 死亡。契约:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史尾页的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。
|
||||
客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态;SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。契约:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。
|
||||
|
||||
## Workspace 与 Session 列表
|
||||
|
||||
Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量更新/移除帧与一元变更回显会在其响应之上回放。第一次成功的基线建立 Host 顺序;后续刷新更新行和成员关系,但不改变已经显示的标识之间的相对顺序。已移除的 Workspace id 会保留进程本地删除标记,避免延迟到达的 changed 帧将其复活;重连仍以 `workspace.list` 作为基线。Workspace 新近程度只在两条基线都 ready 后派生,且绝不改变 Workspace 列表顺序。
|
||||
Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量插入或更新/移除帧与一元变更回显会在其响应之上回放。第一次成功的基线建立 Host 顺序;后续刷新更新行和成员关系,但不改变已经显示的标识之间的相对顺序。已移除的 Workspace id 会保留进程本地删除标记,避免延迟到达的 changed 帧将其复活;重连仍以 `workspace.list` 作为基线。Workspace 新近程度只在两条基线都 ready 后派生,且绝不改变 Workspace 列表顺序。
|
||||
|
||||
`WorkspacesService.delete(workspaceId)` 在一元响应成功后从客户端投影中移除注册记录;对应的 `host/workspace-removed` 帧具有幂等性,并负责同步其他标签页。Session 状态与当前 Session selection 相互独立,因此 Workspace 消失后,其已记账的 Session 会立即投影到 Ungrouped 下。
|
||||
`WorkspacesService.delete(workspaceId)` 在一元响应成功后从客户端投影中移除注册记录;对应的 `host/workspace-removed` 帧具有幂等性,并负责同步其他标签页。Session 状态与当前 Session selection 相互独立,因此 Workspace 消失后,其已纳入客户端投影的 Session 会立即投影到 Ungrouped 下。
|
||||
|
||||
SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 observable;web-react 创建 hook。Workspace 业务状态不会进入 `SessionListState` 或配置项 store。
|
||||
SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 observable;web-react 创建钩子。Workspace 业务状态不会进入 `SessionListState` 或配置项 store。
|
||||
|
||||
## New Session 与 blank 镜像
|
||||
|
||||
`WorkspacesService.connectWorkspace(workspaceId)` 解析 New Session 流程最终落入的会话:先在列表镜像中复用该 workspace 的既有空会话(`blank && cwd == workspace.path`),未命中则调用 `session.create({workspaceId})`,返回会话 id 由调用方 open。`SessionSummary.blank` 镜像主机派生的空日志位,在客户端只降不升:由 `session.list`/`host/session-added` 帧播种,本地首次**受理成功**的 `prompt()`(RPC 成功响应时——受理即证明用户消息已入主机日志;首讯被拒则会话保持 blank、保持可复用)与任何 `running: true` 状态帧翻为 false,每次列表重拉重新对齐。列表表面隐藏 blank 行;store 保留全部行。`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId,失败时抛出 `SessionCreateError`(携带 `requestedSessionId`)。
|
||||
`WorkspacesService.connectWorkspace(workspaceId)` 解析 New Session 流程最终落入的会话:先在列表镜像中复用该 workspace 的既有空会话(`blank && cwd == workspace.path`),未命中则调用 `session.create({workspaceId})`,返回会话 id 由调用方 open。`SessionSummary.blank` 镜像主机派生的空日志位,在客户端只降不升:由 `session.list`/`host/session-added` 帧播种,本地首次获 Host 接受的 `prompt()`(RPC 成功响应时——受理即证明用户消息已入主机日志;首讯被拒则会话保持 blank、保持可复用)与任何 `running: true` 状态帧翻为 false,每次列表重拉重新对齐。列表界面隐藏 blank 行;store 保留全部行。`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId,失败时抛出 `SessionCreateError`(携带 `requestedSessionId`)。
|
||||
|
||||
## Code Mode 子调用索引
|
||||
|
||||
@@ -22,7 +22,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
|
||||
|
||||
## Session 标题投影
|
||||
|
||||
`SessionManager` 独立于列表和 Session 实例到达情况,保留最近一次通过验证的 `session/title` 控制快照。seq 更新的事件会替换旧快照,标题时间戳计入列表新近程度;订阅基线会先丢弃 seq 超过其 `lastSeq` 的任何已保留标题,再接收可选的折叠标题。显式移除 Session 也会清除已保留标题。因此,面向客户端的 `SessionSummary.title` 只包含真实的持久标题;`displayTitle` 始终存在,并依次回退到 cwd basename 和 Session id。冷启动的持久会话会保持该回退值,直到打开或恢复会话,促使主机折叠并投影日志支持的标题。
|
||||
`SessionManager` 独立于列表和 Session 实例到达情况,保留最近一次通过验证的 `session/title` 控制快照。seq 更高的事件会替换旧快照,标题时间戳计入列表新近程度;订阅基线会先丢弃 seq 超过其 `lastSeq` 的任何已保留标题,再接收可选的折叠标题。显式移除 Session 也会清除已保留标题。因此,面向客户端的 `SessionSummary.title` 只包含实际的持久化标题;`displayTitle` 始终存在,并依次回退到 cwd basename 和 Session id。冷态持久化会话会保持该回退值,直到打开或恢复会话,促使主机折叠并投影由日志支撑的标题。`ISession.rename` 用 unary 响应中的 `{title, seq}` 直接结算 `title` 投影格,遵循同一 seq 高者胜规则——列表行和所有 `useProjection('title')` 读者在推送帧到达前即更新;推送帧随后重放同一 seq 时为无操作。
|
||||
|
||||
## 会话模型选择
|
||||
|
||||
@@ -30,14 +30,14 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
|
||||
|
||||
## 模型体验
|
||||
|
||||
无,因为 Session 对象层会选择后续 Host 请求使用的提供方/模型路由,但不添加任何模型可见内容。
|
||||
无,因为会话对象层会选择后续 Host 请求使用的提供方/模型路由,但不添加任何模型可见内容。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
更改目标可能改变提供方侧的缓存复用,或使其失效;该包本身不会改变提示词前缀。
|
||||
更改目标可能改变提供方侧的缓存复用,或使其失效;该包(package)本身不会改变提示词前缀。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **`loader.unload` 是 stub(抛出 not-implemented)**:完整链路(fiber 释放 → 注册级联 → 样式移除)随 HMR 项目落地。
|
||||
- **scope 拆卸由阶段驱动,目前只能有一个占用者**:已 staged 的 Session 精确跟随 `list.current`(staging 就是打开信号:事件窗口打开 ⟺ Session 位于 stage);在 staged 状态下被移除的 Session,其 scope 会冻结保留,直到 stage 转向其他 Session,而非直到真实观察者数量降为零。解析(`binding()`/`scope()`)只是纯寻址,可安全用于渲染;渲染层经 `currentProvideInfo` observable 读取当前 bundle。并发 pane 落地时,staged 状态可以扩展为多 pane 列表。
|
||||
- **插件组合包从该包执行值导入时必须使用 `/client` 子路径**:裸包名不在 loader external 表中,会内联第二个模块实例;其私有 scope-tag Symbol 永远无法匹配(空状态 P0 事故复盘)。
|
||||
- **`loader.unload` 是 stub(抛出 not-implemented)**:完整链路(fiber dispose(资源释放) → 注册级联 → 样式移除)随 HMR(热模块替换)项目落地。
|
||||
- **scope 拆卸由阶段驱动,目前只能有一个占用者**:已 staged 的会话精确跟随 `list.current`(staging 就是打开信号:事件窗口打开 ⟺ 会话位于 stage);在 staged 状态下被移除的会话,其 scope 会冻结保留,直到 stage 转向其他会话,而非直到真实观察者数量降为零。解析(`binding()`/`scope()`)只是纯寻址,可安全用于渲染;渲染层经 `currentProvideInfo` observable 读取当前 bundle。并发 pane 落地时,staged 状态可以扩展为多 pane 列表。
|
||||
- **插件组合包从该包导入值时必须使用 `/client` 子路径**:裸包名不在 loader externals 表中,会内联第二个模块实例;其私有 scope-tag Symbol 永远无法匹配。这是空状态 P0 的事故复盘(postmortem)所记录的问题。
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type {
|
||||
RpcError, SessionId,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionHistoryInspection } from '../sessions/history.ts'
|
||||
import type { ObservableSnapshot } from './store.ts'
|
||||
|
||||
/** Observable state of one independently loaded session history ledger. */
|
||||
export interface SessionHistorySnapshot {
|
||||
state: 'cold' | 'loading' | 'ready' | 'error'
|
||||
error: RpcError | null
|
||||
hasMore: boolean
|
||||
inspection: SessionHistoryInspection
|
||||
}
|
||||
|
||||
/** Read-only history source addressed by session id. */
|
||||
export interface SessionHistoryFace
|
||||
extends ObservableSnapshot<SessionHistorySnapshot> {
|
||||
readonly sessionId: SessionId
|
||||
/**
|
||||
* Load the tail and exhaust every available older page.
|
||||
* @param signal - Consumer lifetime; abort is observed between page requests.
|
||||
* @returns When the available ledger is complete or stops advancing.
|
||||
*/
|
||||
loadAll(signal?: AbortSignal): Promise<void>
|
||||
}
|
||||
|
||||
/** Runtime service resolving independent history sources. */
|
||||
export interface ISessionHistory {
|
||||
/**
|
||||
* Resolve the identity-stable source for a session.
|
||||
* @param sessionId - Host session identity.
|
||||
* @returns The source owned outside Session and SessionManager.
|
||||
*/
|
||||
source(sessionId: SessionId): SessionHistoryFace
|
||||
}
|
||||
@@ -41,11 +41,25 @@ export interface ISession {
|
||||
* @returns acceptance, or the business error.
|
||||
*/
|
||||
cancel(): Promise<RpcResult<{ accepted: true }>>
|
||||
/**
|
||||
* Rename this session (explicit user title; pins it against automatic
|
||||
* regeneration).
|
||||
* @param title - raw title text (the host normalizes acceptance).
|
||||
* @returns the normalized accepted title and its event seq, or the business error.
|
||||
*/
|
||||
rename(title: string): Promise<RpcResult<{ title: string; seq: number }>>
|
||||
/**
|
||||
* Extend the history window backwards (older messages pagination).
|
||||
* @returns completion; failures land in snapshot.openState/loadingOlder.
|
||||
*/
|
||||
loadOlder(): Promise<void>
|
||||
/**
|
||||
* Execute one slash-command line against this session's agent — pure
|
||||
* admission semantics (the host executor durably logs the lifecycle).
|
||||
* @param line - the full command line, leading slash included.
|
||||
* @returns the admission result, or the error branch on transport failure.
|
||||
*/
|
||||
command(line: string): Promise<RpcResult<{ matched: boolean }>>
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* the concrete class. Widening this interface is the explicit act of
|
||||
* widening what features may do to the workspaces domain.
|
||||
*/
|
||||
import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { DirectoryListing, SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { WorkspaceListState } from '../workspaces/service.ts'
|
||||
import type { ObservableSnapshot } from './store.ts'
|
||||
|
||||
@@ -37,6 +37,20 @@ export interface IWorkspaces {
|
||||
* @returns the selected path, or null when the user cancelled.
|
||||
*/
|
||||
pickDirectory(): Promise<string | null>
|
||||
/**
|
||||
* List one directory level through the Host's `browse` capability.
|
||||
* @param path - absolute directory to list; absent lists the Host home directory.
|
||||
* @param signal - aborts the wire request (and the Host's scan) when the caller supersedes it.
|
||||
* @returns the level's listing with breadcrumb ancestry.
|
||||
*/
|
||||
listDirectory(path?: string, signal?: AbortSignal): Promise<DirectoryListing>
|
||||
/**
|
||||
* Create one child directory through the Host's `browse` capability.
|
||||
* @param path - absolute existing parent directory.
|
||||
* @param name - single non-blank path segment.
|
||||
* @returns the created directory's absolute path.
|
||||
*/
|
||||
createDirectory(path: string, name: string): Promise<string>
|
||||
/**
|
||||
* Open a filesystem path with the Host operating system's default application.
|
||||
* @param path - absolute or host-resolvable path.
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { MaybeSnapshotSelectorHook, SnapshotSelectorHook } from '@deepseek-
|
||||
import { SlotsService } from './slots.ts'
|
||||
import { SessionsService } from './sessions/service.ts'
|
||||
import type { SessionListState } from './sessions/service.ts'
|
||||
import { SessionHistoryService } from './session-history/service.ts'
|
||||
import { WorkspacesService } from './workspaces/service.ts'
|
||||
import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from './sessions/conversation.ts'
|
||||
import type { UseProjection } from './sessions/projection-store.ts'
|
||||
@@ -12,15 +13,19 @@ import type { UseProjection } from './sessions/projection-store.ts'
|
||||
export { SlotsService } from './slots.ts'
|
||||
export type { RootOwnerProps } from './slots.ts'
|
||||
export { SessionCreateError, SessionsService, scopeOf, workspaceTitleOf } from './sessions/service.ts'
|
||||
export { SessionHistoryService } from './session-history/service.ts'
|
||||
// The provide channel is shared with the client test runtime (one
|
||||
// materialization/projection implementation; no test-side mirror to drift).
|
||||
export { SessionProvideChannel } from './sessions/provide.ts'
|
||||
export type { SessionProvideChannelHost } from './sessions/provide.ts'
|
||||
export { createScope } from './agents/scope.ts'
|
||||
export type { AgentScopeHandle } from './agents/scope.ts'
|
||||
export { WorkspaceCreateError, WorkspacesService } from './workspaces/service.ts'
|
||||
export { DirectoryBrowseError, WorkspaceCreateError, WorkspacesService } from './workspaces/service.ts'
|
||||
export type { Session } from './sessions/session.ts'
|
||||
export type { ISession, ProjectionsFace, SessionFace } from './contract/session.ts'
|
||||
export type {
|
||||
ISessionHistory, SessionHistoryFace, SessionHistorySnapshot,
|
||||
} from './contract/session-history.ts'
|
||||
export type { ISessions } from './contract/sessions.ts'
|
||||
export type { IWorkspaces } from './contract/workspaces.ts'
|
||||
export type {
|
||||
@@ -29,17 +34,28 @@ export type {
|
||||
export type { SessionListPhase } from './sessions/manager.ts'
|
||||
export type { WorkspaceListPhase } from './workspaces/manager.ts'
|
||||
export type { WorkspaceListState } from './workspaces/service.ts'
|
||||
export type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
export type {
|
||||
DirectoryEntry, DirectoryListing, WorkspaceId, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
// Runtime owns the snapshot store; web-react only binds it to React.
|
||||
export { createSnapshotStore, defineStore, shallowEqual } from './contract/store.ts'
|
||||
export type {
|
||||
EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore,
|
||||
} from './contract/store.ts'
|
||||
export type {
|
||||
AssistantBlock, AssistantMessageNode, CodeSubCall, CommandNode, ComposerPhase, ContextMessageNode, ConversationNode,
|
||||
AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig,
|
||||
AssistantTiming, CodeSubCall, CommandNode, ComposerPhase, ContextMessageNode, ConversationNode,
|
||||
ConversationSnapshot, QueuedMessage, RunningToolCall,
|
||||
SteeringMessageNode, TodoItem, ToolResultNode, UnknownSurfaceNode, UserMessageNode,
|
||||
} from './sessions/conversation.ts'
|
||||
export type {
|
||||
ConversationContext, ConversationContextOriginKind,
|
||||
} from './sessions/conversation-context.ts'
|
||||
export type {
|
||||
ConversationPromptSnapshot, RequestInspectionSnapshot, RequestPromptChange, RequestView,
|
||||
} from './sessions/request-inspection.ts'
|
||||
export type { ConversationHistoryProjection } from './session-history/history-fold.ts'
|
||||
export type { SessionHistoryInspection } from './sessions/history.ts'
|
||||
export { PendingWait } from './sessions/pending.ts'
|
||||
export type { PendingInteraction, PendingKind, PendingPayloads } from './sessions/pending.ts'
|
||||
// Projection value store (session-projection RFC, push model): host-computed
|
||||
@@ -118,6 +134,8 @@ declare module 'cordis' {
|
||||
slots: import('./slots.ts').SlotsService
|
||||
/** The outward face only; the concrete service stays inside the runtime. */
|
||||
sessions: import('./contract/sessions.ts').ISessions
|
||||
/** Read-only history sources isolated from Chat sessions and workspace state. */
|
||||
sessionHistory: import('./contract/session-history.ts').ISessionHistory
|
||||
/** The outward face only; the concrete service stays inside the runtime. */
|
||||
workspaces: import('./contract/workspaces.ts').IWorkspaces
|
||||
}
|
||||
@@ -133,24 +151,55 @@ export function apply(ctx: Context): void {
|
||||
ctx.plugin(SlotsService)
|
||||
const connection = ctx.get('connection') as ConnectionHandle
|
||||
const sessions = new SessionsService(ctx, connection.api)
|
||||
const sessionHistory = new SessionHistoryService(ctx, connection.api)
|
||||
const workspaces = new WorkspacesService(ctx, connection.api, sessions)
|
||||
ctx.effect(
|
||||
() => workspaces.startInitialSelection(),
|
||||
'runtime: initial Workspace selection',
|
||||
)
|
||||
const loop = connection.start({
|
||||
onMuxEnvelope: (envelope) => { sessions.handleMuxEnvelope(envelope) },
|
||||
onMuxEnvelope: (envelope) => {
|
||||
sessions.handleMuxEnvelope(envelope)
|
||||
try {
|
||||
sessionHistory.handleMuxEnvelope(envelope)
|
||||
} catch (error) {
|
||||
console.error('[web-runtime] history frame routing failed:', error)
|
||||
}
|
||||
},
|
||||
onHostEnvelope: (envelope) => {
|
||||
sessions.handleHostEnvelope(envelope)
|
||||
workspaces.handleHostEnvelope(envelope)
|
||||
// Typed-event bridge: the session layer ignores registry frames (no
|
||||
// session routing); consumers (command directory caches) subscribe on ctx.
|
||||
if (envelope.payload.type === 'host/commands-changed') ctx.emit('commands/changed')
|
||||
try {
|
||||
sessionHistory.handleHostEnvelope(envelope)
|
||||
} catch (error) {
|
||||
console.error('[web-runtime] history host-frame routing failed:', error)
|
||||
}
|
||||
},
|
||||
onConnected: () => {
|
||||
sessions.handleConnected()
|
||||
workspaces.handleConnected()
|
||||
ctx.emit('connection/reset')
|
||||
try {
|
||||
sessionHistory.handleConnected()
|
||||
} catch (error) {
|
||||
console.error('[web-runtime] history reconnect failed:', error)
|
||||
}
|
||||
},
|
||||
onStateChange: (state) => {
|
||||
// Generation death fires before any next-generation frame can arrive
|
||||
// (reconnect replays flow from stream open, ahead of onConnected):
|
||||
// the only safe moment to drop generation-scoped interaction state.
|
||||
if (state === 'reconnecting') {
|
||||
sessions.handleDisconnected()
|
||||
try {
|
||||
sessionHistory.handleDisconnected()
|
||||
} catch (error) {
|
||||
console.error('[web-runtime] history disconnect failed:', error)
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
ctx.effect(() => () => { loop.stop() }, 'runtime: connection stream loop')
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import {
|
||||
SurfaceManager, isSurfaceEligibleType, isSurfaceEvent,
|
||||
} from '@deepseek-ai/dsh-session/surface'
|
||||
import type {
|
||||
HistoryEntry, ToolCallView, ToolResultView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
AssistantRequestConfig, AssistantTiming, CodeSubCall, ConversationNode,
|
||||
PartialAssistant, RunningToolCall,
|
||||
} from '../sessions/conversation.ts'
|
||||
import { toAssistantBlocks } from '../sessions/conversation.ts'
|
||||
import type {
|
||||
ConversationContext, ConversationContextOriginKind,
|
||||
} from '../sessions/conversation-context.ts'
|
||||
import type { ConversationPromptSnapshot } from '../sessions/request-inspection.ts'
|
||||
import { PartialAccumulator } from '../sessions/partial.ts'
|
||||
|
||||
interface CallIndexEntry {
|
||||
name: string
|
||||
argsRaw: string
|
||||
time: number
|
||||
callView: ToolCallView | null
|
||||
}
|
||||
|
||||
interface FoldedContext {
|
||||
generation: number
|
||||
nodes: readonly number[]
|
||||
originSeq?: number
|
||||
}
|
||||
|
||||
interface AssistantStepMetadata {
|
||||
stepStartTime: number | null
|
||||
firstTokenTime: number | null
|
||||
}
|
||||
|
||||
/** Immutable conversation projections derived only from the history source. */
|
||||
export interface ConversationHistoryProjection {
|
||||
eventNodes: readonly ConversationNode[]
|
||||
contexts: readonly ConversationContext[]
|
||||
interruptedNodes: readonly ConversationNode[]
|
||||
partial: PartialAssistant | null
|
||||
runningCalls: readonly RunningToolCall[]
|
||||
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
|
||||
}
|
||||
|
||||
function assistantStepKey(turn: number, step: number): string {
|
||||
return `${turn}\u0000${step}`
|
||||
}
|
||||
|
||||
// Trajectory owns surface-window reconstruction so its immutable ledger does
|
||||
// not depend on Chat's live fold adapter or Session's mutable state.
|
||||
/* jscpd:ignore-start */
|
||||
function paddingEvent(seq: number): SessionEvent {
|
||||
return { type: 'noop/padding', seq, time: 0, data: {} } as unknown as SessionEvent
|
||||
}
|
||||
|
||||
function replacementCrossesWindowHead(event: SessionEvent, baseSeq: number): boolean {
|
||||
if (!isSurfaceEvent(event) || event.surfaceOp === 'append') return false
|
||||
return event.surfaceOp.start < baseSeq || event.surfaceOp.end < baseSeq
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
function contextOriginKind(event: SessionEvent | undefined): ConversationContextOriginKind {
|
||||
if (event?.type !== 'user/message') return 'rewrite'
|
||||
const source = event.data.source
|
||||
if (typeof source === 'object' && 'kind' in source && 'plugin' in source) {
|
||||
if (source.plugin === 'compact') return 'compaction'
|
||||
if (source.plugin === 'rewind') return 'rewind'
|
||||
}
|
||||
return 'rewrite'
|
||||
}
|
||||
|
||||
function isTokenDelta(chunk: SessionEvent<'assistant/chunk'>['data']['chunk']): boolean {
|
||||
switch (chunk.type) {
|
||||
case 'text-delta':
|
||||
case 'reasoning-delta':
|
||||
return chunk.text !== ''
|
||||
case 'tool-call-delta':
|
||||
return chunk.argumentsDelta !== '' || chunk.name !== undefined
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function foldContexts(events: readonly SessionEvent[]): readonly FoldedContext[] {
|
||||
const replay: SessionEvent[] = []
|
||||
const surface = new SurfaceManager(replay)
|
||||
const contexts: FoldedContext[] = []
|
||||
let generation = 0
|
||||
let originSeq: number | undefined
|
||||
for (const event of events) {
|
||||
if (isSurfaceEvent(event) && event.surfaceOp !== 'append') {
|
||||
contexts.push({
|
||||
generation,
|
||||
nodes: [...surface.nodes],
|
||||
...(originSeq === undefined ? {} : { originSeq }),
|
||||
})
|
||||
generation++
|
||||
originSeq = event.seq
|
||||
}
|
||||
replay.push(event)
|
||||
}
|
||||
contexts.push({
|
||||
generation,
|
||||
nodes: [...surface.nodes],
|
||||
...(originSeq === undefined ? {} : { originSeq }),
|
||||
})
|
||||
return contexts
|
||||
}
|
||||
|
||||
// History projection owns its node mapping so Chat's live adapter remains free
|
||||
// of inspection metadata and lifecycle coupling.
|
||||
/* jscpd:ignore-start */
|
||||
function materializeNode(
|
||||
event: SessionEvent,
|
||||
callIndex: ReadonlyMap<string, CallIndexEntry>,
|
||||
resultView: ToolResultView | null,
|
||||
assistantTiming: AssistantTiming | undefined,
|
||||
requestConfig: AssistantRequestConfig | undefined,
|
||||
): ConversationNode {
|
||||
switch (event.type) {
|
||||
case 'user/message':
|
||||
if (event.data.source.kind !== 'user') {
|
||||
return {
|
||||
kind: 'context', seq: event.seq, time: event.time,
|
||||
content: event.data.content, source: event.data.source,
|
||||
}
|
||||
}
|
||||
return {
|
||||
kind: 'user', seq: event.seq, time: event.time,
|
||||
content: event.data.content, source: event.data.source,
|
||||
}
|
||||
case 'assistant/message':
|
||||
return {
|
||||
kind: 'assistant', seq: event.seq, time: event.time,
|
||||
turn: event.data.turn, step: event.data.step,
|
||||
blocks: toAssistantBlocks(event.data.message.content), usage: event.data.usage,
|
||||
provenance: {
|
||||
provider: event.data.message.source.provider,
|
||||
model: event.data.message.source.model,
|
||||
},
|
||||
...(requestConfig === undefined ? {} : { requestConfig }),
|
||||
...(assistantTiming === undefined ? {} : { timing: assistantTiming }),
|
||||
}
|
||||
case 'steering/message':
|
||||
return {
|
||||
kind: 'steering', seq: event.seq, time: event.time, turn: event.data.turn,
|
||||
content: event.data.message.content, source: event.data.message.source,
|
||||
}
|
||||
case 'tool/result': {
|
||||
const result = event.data.message.content[0]
|
||||
const callId = String(event.data.message.source.callId)
|
||||
const call = callIndex.get(callId)
|
||||
return {
|
||||
kind: 'tool-result', seq: event.seq, time: event.time,
|
||||
callId,
|
||||
call: call === undefined ? null : { name: call.name, argsRaw: call.argsRaw },
|
||||
callTime: call?.time ?? null,
|
||||
content: result.content, isError: result.isError === true,
|
||||
...(event.data.error === undefined ? {} : { error: event.data.error }),
|
||||
meta: event.data.meta,
|
||||
callView: call?.callView ?? null,
|
||||
resultView,
|
||||
}
|
||||
}
|
||||
default:
|
||||
return {
|
||||
kind: 'unknown', seq: event.seq, time: event.time,
|
||||
type: event.type, data: (event as { data?: unknown }).data,
|
||||
}
|
||||
}
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
function projectTransient(entries: readonly HistoryEntry[]): Pick<
|
||||
ConversationHistoryProjection,
|
||||
'interruptedNodes' | 'partial' | 'runningCalls' | 'codeDispatches'
|
||||
> {
|
||||
let partial: PartialAccumulator | null = null
|
||||
const openCalls = new Map<string, RunningToolCall>()
|
||||
const interruptedNodes: ConversationNode[] = []
|
||||
const codeDispatches = new Map<string, readonly CodeSubCall[]>()
|
||||
|
||||
for (const entry of entries) {
|
||||
const { event } = entry
|
||||
if ((event.type as string) === 'tool/code-dispatch-start') {
|
||||
const data = event.data as unknown as {
|
||||
parentCallId: string
|
||||
subCallId: string
|
||||
name: string
|
||||
arguments: unknown
|
||||
}
|
||||
const siblings = codeDispatches.get(data.parentCallId) ?? []
|
||||
// The independent replay emits the same public running-call shape as
|
||||
// Chat without reading or mutating Session's live index.
|
||||
/* jscpd:ignore-start */
|
||||
codeDispatches.set(data.parentCallId, [...siblings, {
|
||||
callId: data.subCallId,
|
||||
name: data.name,
|
||||
argsRaw: JSON.stringify(data.arguments),
|
||||
turn: 0,
|
||||
step: 0,
|
||||
time: event.time,
|
||||
callView: null,
|
||||
}])
|
||||
/* jscpd:ignore-end */
|
||||
continue
|
||||
}
|
||||
if ((event.type as string) === 'tool/code-dispatch') {
|
||||
const data = event.data as unknown as {
|
||||
parentCallId: string
|
||||
subCallId: string
|
||||
name: string
|
||||
arguments: unknown
|
||||
isError: boolean
|
||||
content: ContentBlock[]
|
||||
}
|
||||
const siblings = codeDispatches.get(data.parentCallId) ?? []
|
||||
const at = siblings.findIndex(sub => sub.callId === data.subCallId)
|
||||
const started = at === -1 ? undefined : siblings[at]
|
||||
// History independently reproduces the public settled-call shape instead
|
||||
// of consuming Session's live code-dispatch projection.
|
||||
/* jscpd:ignore-start */
|
||||
const settled: CodeSubCall = {
|
||||
kind: 'tool-result', seq: event.seq, time: event.time,
|
||||
callId: data.subCallId,
|
||||
call: { name: data.name, argsRaw: JSON.stringify(data.arguments) },
|
||||
callTime: started?.time ?? null,
|
||||
content: data.content,
|
||||
isError: data.isError,
|
||||
callView: null,
|
||||
resultView: null,
|
||||
}
|
||||
codeDispatches.set(
|
||||
data.parentCallId,
|
||||
at === -1
|
||||
? [...siblings, settled]
|
||||
: siblings.map((sub, index) => index === at ? settled : sub),
|
||||
)
|
||||
/* jscpd:ignore-end */
|
||||
continue
|
||||
}
|
||||
switch (event.type) {
|
||||
case 'assistant/chunk': {
|
||||
const { turn, step, chunk } = event.data
|
||||
if (partial === null || partial.turn !== turn || partial.step !== step) {
|
||||
partial = new PartialAccumulator(turn, step)
|
||||
}
|
||||
partial.push(chunk)
|
||||
break
|
||||
}
|
||||
case 'assistant/message':
|
||||
if (partial?.turn === event.data.turn && partial.step === event.data.step) partial = null
|
||||
break
|
||||
case 'tool/call':
|
||||
// History reconstructs its own in-flight index; this intentionally
|
||||
// mirrors the published Chat node shape, not Chat's mutable state.
|
||||
/* jscpd:ignore-start */
|
||||
openCalls.set(String(event.data.callId), {
|
||||
callId: String(event.data.callId),
|
||||
name: event.data.name,
|
||||
argsRaw: event.data.arguments,
|
||||
turn: event.data.turn,
|
||||
step: event.data.step,
|
||||
time: event.time,
|
||||
callView: entry.view?.for === 'call' ? entry.view.view : null,
|
||||
})
|
||||
/* jscpd:ignore-end */
|
||||
break
|
||||
case 'tool/result':
|
||||
openCalls.delete(String(event.data.message.source.callId))
|
||||
break
|
||||
case 'turn/end': {
|
||||
if (partial !== null && partial.turn === event.data.turn) {
|
||||
const { blocks } = partial.toPartial()
|
||||
const visible = blocks.some(block =>
|
||||
block.kind === 'text' || block.kind === 'reasoning' ? block.text !== '' : true)
|
||||
if (visible) {
|
||||
interruptedNodes.push({
|
||||
kind: 'assistant', seq: event.seq - 0.9, time: event.time,
|
||||
turn: partial.turn, step: partial.step, blocks, interrupted: true,
|
||||
})
|
||||
}
|
||||
partial = null
|
||||
}
|
||||
let callOffset = 0
|
||||
for (const [callId, call] of openCalls) {
|
||||
if (call.turn !== event.data.turn) continue
|
||||
openCalls.delete(callId)
|
||||
// Interrupted terminal nodes are reconstructed independently so a
|
||||
// Trajectory replay cannot observe Session's frozen-node lifecycle.
|
||||
/* jscpd:ignore-start */
|
||||
interruptedNodes.push({
|
||||
kind: 'tool-result', seq: event.seq - 0.8 + callOffset++ * 0.01,
|
||||
time: event.time,
|
||||
callId,
|
||||
call: { name: call.name, argsRaw: call.argsRaw },
|
||||
callTime: call.time,
|
||||
content: [],
|
||||
isError: true,
|
||||
error: { name: 'Interrupted', code: 'interrupted' },
|
||||
callView: call.callView,
|
||||
resultView: null,
|
||||
})
|
||||
/* jscpd:ignore-end */
|
||||
}
|
||||
break
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
interruptedNodes,
|
||||
partial: partial?.toPartial() ?? null,
|
||||
runningCalls: [...openCalls.values()],
|
||||
codeDispatches,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Project one immutable history ledger without reading or mutating Chat state.
|
||||
* @param entries - Contiguous history entries in sequence order.
|
||||
* @returns Event order, context lineage, and transient tail state.
|
||||
*/
|
||||
export function projectConversationHistory(
|
||||
entries: readonly HistoryEntry[],
|
||||
): ConversationHistoryProjection {
|
||||
const events = entries.map(entry => entry.event)
|
||||
const baseSeq = events[0]?.seq ?? 0
|
||||
const padded = [
|
||||
...Array.from({ length: baseSeq }, (_, seq) => paddingEvent(seq)),
|
||||
...events,
|
||||
]
|
||||
const callIndex = new Map<string, CallIndexEntry>()
|
||||
const resultViews = new Map<number, ToolResultView>()
|
||||
const assistantSteps = new Map<string, AssistantStepMetadata>()
|
||||
const assistantTimings = new Map<number, AssistantTiming>()
|
||||
const assistantRequestConfigs = new Map<number, AssistantRequestConfig>()
|
||||
const promptsByContext = new Map<number, ConversationPromptSnapshot>()
|
||||
let activeRequestConfig: AssistantRequestConfig | undefined
|
||||
let activePrompt: ConversationPromptSnapshot | undefined
|
||||
let contextGeneration = 0
|
||||
|
||||
for (const [index, event] of events.entries()) {
|
||||
const view = entries[index]?.view
|
||||
if (event.type === 'tool/call') {
|
||||
callIndex.set(String(event.data.callId), {
|
||||
name: event.data.name,
|
||||
argsRaw: event.data.arguments,
|
||||
time: event.time,
|
||||
callView: view?.for === 'call' ? view.view : null,
|
||||
})
|
||||
} else if (event.type === 'tool/result' && view?.for === 'result') {
|
||||
resultViews.set(event.seq, view.view)
|
||||
}
|
||||
if (isSurfaceEvent(event) && event.surfaceOp !== 'append') {
|
||||
contextGeneration++
|
||||
if (activePrompt !== undefined) promptsByContext.set(contextGeneration, activePrompt)
|
||||
}
|
||||
if (event.type === 'request/header') {
|
||||
activeRequestConfig = event.data.header.config
|
||||
activePrompt = {
|
||||
config: event.data.header.config,
|
||||
system: event.data.header.system ?? '',
|
||||
tools: event.data.header.tools ?? [],
|
||||
}
|
||||
promptsByContext.set(contextGeneration, activePrompt)
|
||||
} else if (event.type === 'step/start') {
|
||||
assistantSteps.set(
|
||||
assistantStepKey(event.data.turn, event.data.step),
|
||||
{ stepStartTime: event.time, firstTokenTime: null },
|
||||
)
|
||||
} else if (event.type === 'assistant/chunk' && isTokenDelta(event.data.chunk)) {
|
||||
const key = assistantStepKey(event.data.turn, event.data.step)
|
||||
const current = assistantSteps.get(key) ?? {
|
||||
stepStartTime: null,
|
||||
firstTokenTime: null,
|
||||
}
|
||||
if (current.firstTokenTime === null) {
|
||||
assistantSteps.set(key, { ...current, firstTokenTime: event.time })
|
||||
}
|
||||
} else if (event.type === 'assistant/message') {
|
||||
assistantTimings.set(
|
||||
event.seq,
|
||||
{
|
||||
...(assistantSteps.get(assistantStepKey(event.data.turn, event.data.step)) ?? {
|
||||
stepStartTime: null,
|
||||
firstTokenTime: null,
|
||||
}),
|
||||
completedTime: event.time,
|
||||
},
|
||||
)
|
||||
if (activeRequestConfig !== undefined) {
|
||||
assistantRequestConfigs.set(event.seq, activeRequestConfig)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nodeCache = new Map<number, ConversationNode>()
|
||||
const materialize = (seq: number): ConversationNode | undefined => {
|
||||
const cached = nodeCache.get(seq)
|
||||
if (cached !== undefined) return cached
|
||||
const event = padded[seq]
|
||||
if (event === undefined || !isSurfaceEligibleType(event.type)) return
|
||||
const node = materializeNode(
|
||||
event,
|
||||
callIndex,
|
||||
resultViews.get(seq) ?? null,
|
||||
assistantTimings.get(seq),
|
||||
assistantRequestConfigs.get(seq),
|
||||
)
|
||||
nodeCache.set(seq, node)
|
||||
return node
|
||||
}
|
||||
const eventNodes = events.flatMap((event) => {
|
||||
const node = materialize(event.seq)
|
||||
return node === undefined ? [] : [node]
|
||||
})
|
||||
|
||||
let contexts: readonly ConversationContext[]
|
||||
if (events.some(event => replacementCrossesWindowHead(event, baseSeq))) {
|
||||
contexts = [{
|
||||
id: 0,
|
||||
...(activePrompt === undefined ? {} : { prompt: activePrompt }),
|
||||
nodes: eventNodes,
|
||||
}]
|
||||
} else {
|
||||
try {
|
||||
contexts = foldContexts(padded).map((context): ConversationContext => {
|
||||
const nodes = context.nodes.flatMap((seq) => {
|
||||
const node = materialize(seq)
|
||||
return node === undefined ? [] : [node]
|
||||
})
|
||||
const prompt = promptsByContext.get(context.generation)
|
||||
if (context.originSeq === undefined) {
|
||||
return {
|
||||
id: context.generation,
|
||||
...(prompt === undefined ? {} : { prompt }),
|
||||
nodes,
|
||||
}
|
||||
}
|
||||
const originEvent = padded[context.originSeq]
|
||||
return {
|
||||
id: context.generation,
|
||||
parentId: context.generation - 1,
|
||||
origin: contextOriginKind(originEvent),
|
||||
originSeq: context.originSeq,
|
||||
...(originEvent === undefined ? {} : { createdAt: originEvent.time }),
|
||||
...(prompt === undefined ? {} : { prompt }),
|
||||
nodes,
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[web-runtime] history surface fold failed, using event order:', error)
|
||||
contexts = [{
|
||||
id: 0,
|
||||
...(activePrompt === undefined ? {} : { prompt: activePrompt }),
|
||||
nodes: eventNodes,
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
eventNodes,
|
||||
contexts,
|
||||
...projectTransient(entries),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { Context } from 'cordis'
|
||||
import type {
|
||||
HostFrame, IApiClient, MuxFrame, RpcRequest, SessionId,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
ISessionHistory, SessionHistoryFace,
|
||||
} from '../contract/session-history.ts'
|
||||
import { SessionHistorySource } from './source.ts'
|
||||
|
||||
/** Root registry and frame router for independent inspection histories. */
|
||||
export class SessionHistoryService implements ISessionHistory {
|
||||
private readonly sources = new Map<SessionId, SessionHistorySource>()
|
||||
|
||||
/**
|
||||
* @param ctx - Client root context.
|
||||
* @param api - Shared wire client.
|
||||
*/
|
||||
constructor(ctx: Context, private readonly api: IApiClient) {
|
||||
ctx.reflect.provide('sessionHistory', this, undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one identity-stable history source.
|
||||
* @param sessionId - Host session identity.
|
||||
* @returns Source independent from SessionManager.
|
||||
*/
|
||||
source(sessionId: SessionId): SessionHistoryFace {
|
||||
let source = this.sources.get(sessionId)
|
||||
if (source === undefined) {
|
||||
source = new SessionHistorySource(sessionId, this.api)
|
||||
this.sources.set(sessionId, source)
|
||||
}
|
||||
return source
|
||||
}
|
||||
|
||||
/**
|
||||
* Route history-relevant mux frames only to an existing source.
|
||||
* @param envelope - Validated mux envelope.
|
||||
*/
|
||||
handleMuxEnvelope(envelope: RpcRequest<MuxFrame>): void {
|
||||
const frame = envelope.payload
|
||||
if (frame.type === 'stream/error') return
|
||||
this.sources.get(frame.sessionId)?.handleMuxFrame(frame)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a removed session's independent history source.
|
||||
* @param envelope - Validated host envelope.
|
||||
*/
|
||||
handleHostEnvelope(envelope: RpcRequest<HostFrame>): void {
|
||||
const frame = envelope.payload
|
||||
if (frame.type !== 'host/session-removed') return
|
||||
this.sources.get(frame.sessionId)?.dispose()
|
||||
this.sources.delete(frame.sessionId)
|
||||
}
|
||||
|
||||
/** Invalidate requests from the dead connection generation. */
|
||||
handleDisconnected(): void {
|
||||
for (const source of this.sources.values()) source.handleDisconnected()
|
||||
}
|
||||
|
||||
/** Rebuild every previously activated source from the new generation. */
|
||||
handleConnected(): void {
|
||||
for (const source of this.sources.values()) source.resync()
|
||||
}
|
||||
}
|
||||
352
packages/client/runtime/src/client/session-history/source.ts
Normal file
352
packages/client/runtime/src/client/session-history/source.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
import type {
|
||||
HistoryEntry, IApiClient, MuxFrame, RpcError, SessionId,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type {
|
||||
SessionHistoryFace, SessionHistorySnapshot,
|
||||
} from '../contract/session-history.ts'
|
||||
import { createHistoryInspection } from '../sessions/history.ts'
|
||||
import { Notifier } from '../sessions/notifier.ts'
|
||||
|
||||
const HISTORY_PAGE_MESSAGES = 50
|
||||
|
||||
function isAborted(signal: AbortSignal | undefined): boolean {
|
||||
return signal?.aborted === true
|
||||
}
|
||||
|
||||
/** Independent raw-history owner used only by inspection consumers. */
|
||||
export class SessionHistorySource implements SessionHistoryFace {
|
||||
private entries: readonly HistoryEntry[] = []
|
||||
private baseSeq = 0
|
||||
private hasMore = false
|
||||
private state: SessionHistorySnapshot['state'] = 'cold'
|
||||
private error: RpcError | null = null
|
||||
private generation = 0
|
||||
private persistentConsumer = false
|
||||
private readonly consumerSignals = new Set<AbortSignal>()
|
||||
private openPromise: Promise<void> | null = null
|
||||
private olderPromise: Promise<void> | null = null
|
||||
private stitching = false
|
||||
private liveBuffer: HistoryEntry[] = []
|
||||
private subscribedLastSeq: number | null = null
|
||||
private inspectionCache: {
|
||||
entries: readonly HistoryEntry[]
|
||||
value: SessionHistorySnapshot['inspection']
|
||||
} | null = null
|
||||
private snapshotCache: SessionHistorySnapshot
|
||||
private readonly notifier = new Notifier(() => {
|
||||
this.snapshotCache = this.buildSnapshot()
|
||||
})
|
||||
|
||||
/**
|
||||
* @param sessionId - Host session identity.
|
||||
* @param api - Shared wire client.
|
||||
*/
|
||||
constructor(
|
||||
readonly sessionId: SessionId,
|
||||
private readonly api: IApiClient,
|
||||
) {
|
||||
this.snapshotCache = this.buildSnapshot()
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to ledger changes.
|
||||
* @param listener - Change callback.
|
||||
* @returns Unsubscribe function.
|
||||
*/
|
||||
subscribe(listener: () => void): () => void {
|
||||
return this.notifier.subscribe(listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the cached ledger snapshot.
|
||||
* @returns Stable snapshot until the source changes.
|
||||
*/
|
||||
getSnapshot(): SessionHistorySnapshot {
|
||||
this.notifier.ensureFresh()
|
||||
return this.snapshotCache
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the tail and exhaust all available older pages.
|
||||
* @param signal - Consumer lifetime.
|
||||
* @returns When paging completes, fails to advance, or is aborted.
|
||||
*/
|
||||
async loadAll(signal?: AbortSignal): Promise<void> {
|
||||
if (signal?.aborted === true) return
|
||||
this.trackConsumer(signal)
|
||||
await this.open()
|
||||
while (
|
||||
!isAborted(signal)
|
||||
&& this.state === 'ready'
|
||||
&& this.hasMore
|
||||
) {
|
||||
const previousBaseSeq = this.baseSeq
|
||||
await this.loadOlder()
|
||||
if (isAborted(signal) || this.baseSeq === previousBaseSeq) return
|
||||
}
|
||||
}
|
||||
|
||||
/** Rebuild and page for whichever mounted consumers survive a reconnect. */
|
||||
private async loadForConsumers(): Promise<void> {
|
||||
await this.open()
|
||||
while (
|
||||
this.hasConsumer()
|
||||
&& this.state === 'ready'
|
||||
&& this.hasMore
|
||||
) {
|
||||
const previousBaseSeq = this.baseSeq
|
||||
await this.loadOlder()
|
||||
if (!this.hasConsumer() || this.baseSeq === previousBaseSeq) return
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a relevant mux frame without involving the Chat session.
|
||||
* @param frame - Session-addressed frame.
|
||||
*/
|
||||
handleMuxFrame(frame: MuxFrame): void {
|
||||
if (frame.type === 'session/subscribed') {
|
||||
this.subscribedLastSeq = frame.lastSeq
|
||||
return
|
||||
}
|
||||
if (frame.type !== 'session/event') return
|
||||
this.acceptLive({ event: frame.event, ...(frame.view === undefined ? {} : { view: frame.view }) })
|
||||
}
|
||||
|
||||
/** Invalidate dead-generation requests while retaining the last readable snapshot. */
|
||||
handleDisconnected(): void {
|
||||
this.generation++
|
||||
this.openPromise = null
|
||||
this.olderPromise = null
|
||||
this.stitching = false
|
||||
this.liveBuffer = []
|
||||
this.subscribedLastSeq = null
|
||||
if (this.state !== 'cold') {
|
||||
this.state = 'cold'
|
||||
this.error = null
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
}
|
||||
|
||||
/** Rebuild an activated ledger from the new connection generation. */
|
||||
resync(): void {
|
||||
if (!this.hasConsumer()) return
|
||||
this.generation++
|
||||
this.openPromise = null
|
||||
this.olderPromise = null
|
||||
this.stitching = false
|
||||
this.liveBuffer = []
|
||||
this.subscribedLastSeq = null
|
||||
this.entries = []
|
||||
this.baseSeq = 0
|
||||
this.hasMore = false
|
||||
this.state = 'cold'
|
||||
this.error = null
|
||||
this.notifier.markDirty()
|
||||
void this.loadForConsumers()
|
||||
}
|
||||
|
||||
/** Stop future refresh work after the host removes the session. */
|
||||
dispose(): void {
|
||||
this.persistentConsumer = false
|
||||
this.consumerSignals.clear()
|
||||
this.generation++
|
||||
this.openPromise = null
|
||||
this.olderPromise = null
|
||||
this.liveBuffer = []
|
||||
}
|
||||
|
||||
private open(): Promise<void> {
|
||||
if (this.state === 'ready') return Promise.resolve()
|
||||
if (this.openPromise !== null) return this.openPromise
|
||||
const generation = this.generation
|
||||
const operation = this.doOpen(generation)
|
||||
const settled = operation.finally(() => {
|
||||
if (this.openPromise === settled) this.openPromise = null
|
||||
})
|
||||
this.openPromise = settled
|
||||
return settled
|
||||
}
|
||||
|
||||
private trackConsumer(signal: AbortSignal | undefined): void {
|
||||
if (signal === undefined) {
|
||||
this.persistentConsumer = true
|
||||
return
|
||||
}
|
||||
if (this.consumerSignals.has(signal)) return
|
||||
this.consumerSignals.add(signal)
|
||||
signal.addEventListener('abort', () => {
|
||||
this.consumerSignals.delete(signal)
|
||||
}, { once: true })
|
||||
}
|
||||
|
||||
private hasConsumer(): boolean {
|
||||
return this.persistentConsumer || this.consumerSignals.size > 0
|
||||
}
|
||||
|
||||
private async doOpen(generation: number): Promise<void> {
|
||||
this.state = 'loading'
|
||||
this.error = null
|
||||
this.notifier.markDirty()
|
||||
try {
|
||||
let { result } = await this.api.sessions.history({
|
||||
sessionId: this.sessionId,
|
||||
maxMessages: HISTORY_PAGE_MESSAGES,
|
||||
})
|
||||
if (generation !== this.generation) return
|
||||
if (!result.ok) {
|
||||
this.state = 'error'
|
||||
this.error = result.error
|
||||
return
|
||||
}
|
||||
this.installTail(result.value.events, result.value.hasMore, true)
|
||||
const tailSeq = this.tailSeq()
|
||||
if (
|
||||
this.subscribedLastSeq !== null
|
||||
&& tailSeq !== null
|
||||
&& this.subscribedLastSeq > tailSeq
|
||||
) {
|
||||
result = (await this.api.sessions.history({
|
||||
sessionId: this.sessionId,
|
||||
maxMessages: HISTORY_PAGE_MESSAGES,
|
||||
})).result
|
||||
if (generation !== this.generation) return
|
||||
if (result.ok) this.installTail(result.value.events, result.value.hasMore, true)
|
||||
}
|
||||
this.state = 'ready'
|
||||
} catch (error) {
|
||||
if (generation !== this.generation) return
|
||||
this.state = 'error'
|
||||
const folded = transportError<never>(error)
|
||||
/* v8 ignore next -- transportError always returns the error branch. */
|
||||
this.error = folded.ok ? null : folded.error
|
||||
} finally {
|
||||
if (generation === this.generation) this.notifier.markDirty()
|
||||
}
|
||||
}
|
||||
|
||||
private loadOlder(): Promise<void> {
|
||||
if (this.olderPromise !== null) return this.olderPromise
|
||||
if (this.state !== 'ready' || !this.hasMore) return Promise.resolve()
|
||||
const generation = this.generation
|
||||
const operation = (async () => {
|
||||
try {
|
||||
const { result } = await this.api.sessions.history({
|
||||
sessionId: this.sessionId,
|
||||
beforeSeq: this.baseSeq,
|
||||
maxMessages: HISTORY_PAGE_MESSAGES,
|
||||
})
|
||||
if (generation !== this.generation || this.state !== 'ready' || !result.ok) return
|
||||
const older = result.value.events
|
||||
if (older.length === 0) {
|
||||
this.hasMore = result.value.hasMore
|
||||
return
|
||||
}
|
||||
const tail = older.at(-1)
|
||||
if (tail === undefined || tail.event.seq + 1 !== this.baseSeq) {
|
||||
console.error(
|
||||
`[web-runtime] inspection history page discontinuous: tail seq ${tail?.event.seq} vs baseSeq ${this.baseSeq}`,
|
||||
)
|
||||
this.hasMore = false
|
||||
return
|
||||
}
|
||||
this.entries = [...older, ...this.entries]
|
||||
this.baseSeq = older[0]?.event.seq ?? this.baseSeq
|
||||
this.hasMore = result.value.hasMore
|
||||
} catch (error) {
|
||||
console.error('[web-runtime] inspection history paging failed:', error)
|
||||
}
|
||||
})()
|
||||
const settled = operation.finally(() => {
|
||||
if (this.olderPromise !== settled) return
|
||||
this.olderPromise = null
|
||||
this.notifier.markDirty()
|
||||
})
|
||||
this.olderPromise = settled
|
||||
return settled
|
||||
}
|
||||
|
||||
private installTail(
|
||||
tail: readonly HistoryEntry[],
|
||||
hasMore: boolean,
|
||||
replace: boolean,
|
||||
): void {
|
||||
if (replace) {
|
||||
this.entries = [...tail]
|
||||
this.hasMore = hasMore
|
||||
} else {
|
||||
const firstSeq = tail[0]?.event.seq
|
||||
const prefix = firstSeq === undefined
|
||||
? this.entries
|
||||
: this.entries.filter(entry => entry.event.seq < firstSeq)
|
||||
this.entries = [...prefix, ...tail]
|
||||
}
|
||||
this.baseSeq = this.entries[0]?.event.seq ?? 0
|
||||
const buffered = this.liveBuffer
|
||||
this.liveBuffer = []
|
||||
for (const entry of buffered) this.appendLive(entry)
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
private acceptLive(entry: HistoryEntry): void {
|
||||
if (this.state === 'loading' || this.stitching) {
|
||||
this.liveBuffer.push(entry)
|
||||
return
|
||||
}
|
||||
if (this.state !== 'ready') return
|
||||
const tailSeq = this.tailSeq()
|
||||
if (tailSeq !== null && entry.event.seq > tailSeq + 1) {
|
||||
this.liveBuffer.push(entry)
|
||||
void this.repairGap()
|
||||
return
|
||||
}
|
||||
this.appendLive(entry)
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
private appendLive(entry: HistoryEntry): void {
|
||||
const tailSeq = this.tailSeq()
|
||||
if (tailSeq !== null && entry.event.seq <= tailSeq) return
|
||||
this.entries = [...this.entries, entry]
|
||||
}
|
||||
|
||||
private async repairGap(): Promise<void> {
|
||||
if (this.stitching) return
|
||||
this.stitching = true
|
||||
const generation = this.generation
|
||||
try {
|
||||
const { result } = await this.api.sessions.history({
|
||||
sessionId: this.sessionId,
|
||||
maxMessages: HISTORY_PAGE_MESSAGES,
|
||||
})
|
||||
if (result.ok && generation === this.generation && this.state === 'ready') {
|
||||
this.installTail(result.value.events, result.value.hasMore, false)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[web-runtime] inspection history gap repair failed:', error)
|
||||
} finally {
|
||||
if (generation === this.generation) this.stitching = false
|
||||
}
|
||||
}
|
||||
|
||||
private tailSeq(): number | null {
|
||||
return this.entries.at(-1)?.event.seq ?? null
|
||||
}
|
||||
|
||||
private buildSnapshot(): SessionHistorySnapshot {
|
||||
if (this.inspectionCache?.entries !== this.entries) {
|
||||
const entries = this.entries
|
||||
this.inspectionCache = {
|
||||
entries,
|
||||
value: createHistoryInspection(() => entries),
|
||||
}
|
||||
}
|
||||
return {
|
||||
state: this.state,
|
||||
error: this.error,
|
||||
hasMore: this.hasMore,
|
||||
inspection: this.inspectionCache.value,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ConversationNode } from './conversation.ts'
|
||||
import type { ConversationPromptSnapshot } from './request-inspection.ts'
|
||||
|
||||
/** Operation that started a new append-only model context. */
|
||||
export type ConversationContextOriginKind = 'compaction' | 'rewind' | 'rewrite'
|
||||
|
||||
/** One immutable model-context generation reconstructed from surface replacements. */
|
||||
export interface ConversationContext {
|
||||
/** Zero-based generation within the session; stable across later appends. */
|
||||
id: number
|
||||
/** Previous generation in this session; absent for the initial context. */
|
||||
parentId?: number
|
||||
/** Why this generation exists; absent for the initial context. */
|
||||
origin?: ConversationContextOriginKind
|
||||
/** Event seq of the replacement that created this generation. */
|
||||
originSeq?: number
|
||||
/** Unix epoch ms of the replacement that created this generation. */
|
||||
createdAt?: number
|
||||
/** Latest request header observed in this generation, inherited until a later header replaces it. */
|
||||
prompt?: ConversationPromptSnapshot
|
||||
/** Final frozen nodes for historical generations, or current folded nodes for the tail. */
|
||||
nodes: readonly ConversationNode[]
|
||||
}
|
||||
@@ -10,9 +10,26 @@ import type {
|
||||
RpcError, SessionId, ToolCallView, ToolResultView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { PendingInteraction } from './pending.ts'
|
||||
|
||||
export type { TodoItem }
|
||||
|
||||
/** Request configuration recorded for one provider call. */
|
||||
export interface AssistantRequestConfig {
|
||||
provider: string
|
||||
model: string
|
||||
purpose?: string
|
||||
thinking?: string
|
||||
reasoningEffort?: string
|
||||
temperature?: number
|
||||
maxTokens?: number
|
||||
stop?: readonly string[]
|
||||
}
|
||||
|
||||
/** Stable provider/model identity reported for one completed request. */
|
||||
export interface AssistantProvenanceView {
|
||||
provider: string
|
||||
model: string
|
||||
}
|
||||
|
||||
/** Assistant content blocks sorted by what the UI cares about
|
||||
* (text body / collapsible reasoning / tool-call card head / other fallback). */
|
||||
export type AssistantBlock =
|
||||
@@ -54,6 +71,16 @@ export interface UserMessageNode {
|
||||
source: unknown
|
||||
}
|
||||
|
||||
/** Recorded boundaries used to derive assistant latency and throughput. */
|
||||
export interface AssistantTiming {
|
||||
/** Matching step/start timestamp, or null when it is outside the current event window. */
|
||||
stepStartTime: number | null
|
||||
/** First non-empty text/reasoning/tool delta timestamp, or null when no token delta was recorded. */
|
||||
firstTokenTime: number | null
|
||||
/** Final assistant/message timestamp. */
|
||||
completedTime: number
|
||||
}
|
||||
|
||||
/** A finalized (or interruption-frozen) assistant message. */
|
||||
export interface AssistantMessageNode {
|
||||
kind: 'assistant'
|
||||
@@ -64,6 +91,10 @@ export interface AssistantMessageNode {
|
||||
step: number
|
||||
blocks: readonly AssistantBlock[]
|
||||
usage?: unknown
|
||||
provenance?: AssistantProvenanceView
|
||||
requestConfig?: AssistantRequestConfig
|
||||
/** Timing derived from the recorded step/chunk/message event sequence. */
|
||||
timing?: AssistantTiming
|
||||
/** Frozen partial of an aborted turn (no finalize ever arrives): rendered with a 已停止 marker.
|
||||
* Synthetic seq (fractional, derived from the turn/end seq) keeps it ordered inside the flow. */
|
||||
interrupted?: true
|
||||
|
||||
@@ -7,7 +7,9 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
// Subpath export (package.json exports "./surface", alias added for this): all value imports
|
||||
// go through it — the package root points at lib/index.js (needs a build) which the vite
|
||||
// browser bundle cannot resolve; surface.ts has no Node dependencies.
|
||||
import { SurfaceManager, isSurfaceEligibleType } from '@deepseek-ai/dsh-session/surface'
|
||||
import {
|
||||
SurfaceManager, isSurfaceEligibleType, isSurfaceEvent,
|
||||
} from '@deepseek-ai/dsh-session/surface'
|
||||
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { CommandNode, ConversationNode } from './conversation.ts'
|
||||
@@ -33,6 +35,11 @@ function paddingEvent(seq: number): SessionEvent {
|
||||
return { type: 'noop/padding', seq, time: 0, data: {} } as unknown as SessionEvent
|
||||
}
|
||||
|
||||
function replacementCrossesWindowHead(event: SessionEvent, baseSeq: number): boolean {
|
||||
if (!isSurfaceEvent(event) || event.surfaceOp === 'append') return false
|
||||
return event.surfaceOp.start < baseSeq || event.surfaceOp.end < baseSeq
|
||||
}
|
||||
|
||||
/** One event -> UI node (pure function; the six-variant ConversationNode union). */
|
||||
function materializeNode(
|
||||
event: SessionEvent,
|
||||
@@ -137,7 +144,7 @@ export class FoldAdapter {
|
||||
for (const event of events) this.padded.push(event)
|
||||
this.surface = new SurfaceManager(this.padded)
|
||||
this.nodeCache.clear()
|
||||
this.degraded = false
|
||||
this.degraded = events.some(event => replacementCrossesWindowHead(event, baseSeq))
|
||||
this.callIdx = new Map()
|
||||
this.resultViews.clear()
|
||||
this.commandIdx = new Map()
|
||||
@@ -160,6 +167,7 @@ export class FoldAdapter {
|
||||
append(event: SessionEvent, view?: ToolEventView): void {
|
||||
this.rev++
|
||||
this.padded.push(event)
|
||||
if (replacementCrossesWindowHead(event, this.baseSeq)) this.degraded = true
|
||||
this.indexCall(event, view)
|
||||
this.indexCommand(event)
|
||||
}
|
||||
|
||||
66
packages/client/runtime/src/client/sessions/history.ts
Normal file
66
packages/client/runtime/src/client/sessions/history.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { ToolSchema } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
CodeSubCall, ConversationNode, PartialAssistant, RunningToolCall,
|
||||
} from './conversation.ts'
|
||||
import type { ConversationContext } from './conversation-context.ts'
|
||||
import { projectConversationHistory } from '../session-history/history-fold.ts'
|
||||
import { inspectRequests, type RequestView } from './request-inspection.ts'
|
||||
|
||||
/** Lazily derived inspection data for one immutable session-history window. */
|
||||
export interface SessionHistoryInspection {
|
||||
eventNodes: readonly ConversationNode[]
|
||||
contexts: readonly ConversationContext[]
|
||||
requests: readonly RequestView[]
|
||||
callSchemas: ReadonlyMap<string, ToolSchema>
|
||||
interruptedNodes: readonly ConversationNode[]
|
||||
partial: PartialAssistant | null
|
||||
runningCalls: readonly RunningToolCall[]
|
||||
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a lazy inspection projection over an immutable history window.
|
||||
* Conversation consumers retain the cheap wrapper; only Trajectory snapshots
|
||||
* the entries and replays event order and request lifecycle state.
|
||||
* @param loadEntries - Lazily snapshots contiguous raw entries in sequence order.
|
||||
* @returns Lazy, memoized inspection fields for that exact window.
|
||||
*/
|
||||
export function createHistoryInspection(
|
||||
loadEntries: () => readonly HistoryEntry[],
|
||||
): SessionHistoryInspection {
|
||||
let entries: readonly HistoryEntry[] | undefined
|
||||
let conversation: ReturnType<typeof projectConversationHistory> | undefined
|
||||
let requests: ReturnType<typeof inspectRequests> | undefined
|
||||
const historyEntries = () => entries ??= loadEntries()
|
||||
const conversationProjection = () =>
|
||||
conversation ??= projectConversationHistory(historyEntries())
|
||||
const requestProjection = () =>
|
||||
requests ??= inspectRequests(historyEntries())
|
||||
return {
|
||||
get eventNodes() {
|
||||
return conversationProjection().eventNodes
|
||||
},
|
||||
get contexts() {
|
||||
return conversationProjection().contexts
|
||||
},
|
||||
get interruptedNodes() {
|
||||
return conversationProjection().interruptedNodes
|
||||
},
|
||||
get partial() {
|
||||
return conversationProjection().partial
|
||||
},
|
||||
get runningCalls() {
|
||||
return conversationProjection().runningCalls
|
||||
},
|
||||
get codeDispatches() {
|
||||
return conversationProjection().codeDispatches
|
||||
},
|
||||
get requests() {
|
||||
return requestProjection().requests
|
||||
},
|
||||
get callSchemas() {
|
||||
return requestProjection().callSchemas
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ export interface TitledSessionSummary extends SessionSummary {
|
||||
title?: string
|
||||
}
|
||||
|
||||
/** One flattened session-list row (summary + lineage indent depth). */
|
||||
/** One flattened session-list row (summary + lineage indent depth + live pending-approval bit). */
|
||||
export interface SessionListEntry {
|
||||
sessionId: SessionId
|
||||
title?: string
|
||||
@@ -19,6 +19,8 @@ export interface SessionListEntry {
|
||||
blank: boolean
|
||||
parentSessionId?: SessionId
|
||||
cwd?: string
|
||||
/** An approval question is pending on this session (mux-frame derived; the sidebar's amber dot). */
|
||||
waitingApproval: boolean
|
||||
/** Lineage indent depth: root = 0; the UI just multiplies by the indent width. */
|
||||
depth: number
|
||||
}
|
||||
@@ -28,9 +30,10 @@ export interface SessionListEntry {
|
||||
* follows the established input order; this projection never re-sorts a
|
||||
* hydrated list from mutable timestamps.
|
||||
* @param summaries - the host's session.list items.
|
||||
* @param waitingApproval - sessions with a pending approval question (manager-owned live fact; absent = false).
|
||||
* @returns display rows in render order.
|
||||
*/
|
||||
export function flattenLineage(summaries: readonly TitledSessionSummary[]): SessionListEntry[] {
|
||||
export function flattenLineage(summaries: readonly TitledSessionSummary[], waitingApproval?: ReadonlySet<SessionId>): SessionListEntry[] {
|
||||
const byId = new Map<SessionId, TitledSessionSummary>()
|
||||
for (const s of summaries) byId.set(s.sessionId, s)
|
||||
|
||||
@@ -54,7 +57,7 @@ export function flattenLineage(summaries: readonly TitledSessionSummary[]): Sess
|
||||
return
|
||||
}
|
||||
visited.add(s.sessionId)
|
||||
out.push({ ...s, depth })
|
||||
out.push({ ...s, waitingApproval: waitingApproval?.has(s.sessionId) ?? false, depth })
|
||||
const kids = children.get(s.sessionId)
|
||||
if (kids === undefined) return
|
||||
for (const kid of kids) walk(kid, depth + 1)
|
||||
|
||||
@@ -57,6 +57,11 @@ export class SessionManager {
|
||||
* drop-and-backfill path; replayed and cleared on instantiation. Bounded per session (these
|
||||
* frames are low-frequency; overflow drops oldest) and dropped on session-removed (audit S7). */
|
||||
private readonly pendingBuffers = new Map<SessionId, RpcRequest<MuxFrame>[]>()
|
||||
/** Outstanding approval questions per session, keyed by approvalId (idempotent under mux-open
|
||||
* replays of the same requested frame). Manager-owned rather than read off Session instances
|
||||
* because the sidebar must light up for sessions never instantiated. Cleared per connection
|
||||
* generation — the reopen replay re-adds still-pending questions — and on session-removed. */
|
||||
private readonly waitingApprovals = new Map<SessionId, Set<string>>()
|
||||
/** Per-session projection value stores, retained independently of instance arrival (the
|
||||
* title-snapshot precedent, generalized): push frames land here whether or not the Session
|
||||
* is instantiated (list rows read the 'title' key), and an instantiated Session adopts the
|
||||
@@ -360,6 +365,22 @@ export class SessionManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
// List-level waiting-approval bit (the sidebar amber dot): tracked here for
|
||||
// every session, instantiated or not; approvalId keys make replays idempotent.
|
||||
if (frame.type === 'approval/requested') {
|
||||
let ids = this.waitingApprovals.get(frame.sessionId)
|
||||
if (ids === undefined) this.waitingApprovals.set(frame.sessionId, ids = new Set())
|
||||
if (!ids.has(frame.approvalId)) {
|
||||
ids.add(frame.approvalId)
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
} else if (frame.type === 'approval/resolved') {
|
||||
const ids = this.waitingApprovals.get(frame.sessionId)
|
||||
if (ids !== undefined && ids.delete(frame.approvalId)) {
|
||||
if (ids.size === 0) this.waitingApprovals.delete(frame.sessionId)
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
}
|
||||
const session = this.sessions.get(frame.sessionId)
|
||||
if (session === undefined) {
|
||||
// Approval/question/queued frames never hit history: buffer for replay on
|
||||
@@ -404,6 +425,7 @@ export class SessionManager {
|
||||
this.recordMutation({ kind: 'remove', sessionId: frame.sessionId })
|
||||
this.sessions.get(frame.sessionId)?.handleRemoved() // instance survives (resident-instance rule), only flagged in the snapshot
|
||||
this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation
|
||||
this.waitingApprovals.delete(frame.sessionId) // a removed session cannot wait on anyone
|
||||
this.projectionStores.delete(frame.sessionId) // removed sessions drop their projection rows with the instance
|
||||
return
|
||||
}
|
||||
@@ -421,6 +443,30 @@ export class SessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The moment a connection generation dies (before any next-generation frame
|
||||
* can arrive — onConnected waits for the readiness handshake while replayed
|
||||
* frames flow from stream open, so clearing there would race the replay):
|
||||
* drop generation-scoped live state. Approvals resolved while disconnected
|
||||
* send no frame, so the stale bits and the buffered answerable frames must
|
||||
* not survive into the next generation — the mux-open replay re-adds every
|
||||
* still-pending question with its live rpcId.
|
||||
*/
|
||||
handleDisconnected(): void {
|
||||
if (this.waitingApprovals.size > 0) {
|
||||
this.waitingApprovals.clear()
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
for (const [sessionId, buffer] of [...this.pendingBuffers]) {
|
||||
const kept = buffer.filter(item =>
|
||||
item.payload.type !== 'approval/requested' && item.payload.type !== 'approval/resolved'
|
||||
&& item.payload.type !== 'question/requested' && item.payload.type !== 'question/resolved')
|
||||
if (kept.length === buffer.length) continue
|
||||
if (kept.length === 0) this.pendingBuffers.delete(sessionId)
|
||||
else this.pendingBuffers.set(sessionId, kept)
|
||||
}
|
||||
}
|
||||
|
||||
/** After each connection generation: refresh the session baseline and rebuild opened windows. */
|
||||
handleConnected(): void {
|
||||
void this.refreshList()
|
||||
@@ -436,7 +482,7 @@ export class SessionManager {
|
||||
? { ...summary, title }
|
||||
: summary
|
||||
})
|
||||
const fresh = flattenLineage(merged)
|
||||
const fresh = flattenLineage(merged, new Set(this.waitingApprovals.keys()))
|
||||
const items = fresh.map((entry) => {
|
||||
const prev = this.entryCache.get(entry.sessionId)
|
||||
if (
|
||||
@@ -444,6 +490,7 @@ export class SessionManager {
|
||||
&& prev.blank === entry.blank
|
||||
&& prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd
|
||||
&& prev.title === entry.title && prev.depth === entry.depth
|
||||
&& prev.waitingApproval === entry.waitingApproval
|
||||
) return prev
|
||||
this.entryCache.set(entry.sessionId, entry)
|
||||
return entry
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
// Request-centric inspection read model. Ordinary generation and compaction
|
||||
// calls share one chronological projection; presentation-specific grouping
|
||||
// remains in the trajectory consumer.
|
||||
|
||||
import type { ContentBlock, TokenUsage, ToolSchema } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
AssistantProvenanceView, AssistantRequestConfig,
|
||||
} from './conversation.ts'
|
||||
|
||||
export type {
|
||||
AssistantProvenanceView, AssistantRequestConfig,
|
||||
} from './conversation.ts'
|
||||
|
||||
/** Complete model-visible request header in force for an ordinary generation. */
|
||||
export interface ConversationPromptSnapshot {
|
||||
/** Provider/model and sampling configuration from the effective request header. */
|
||||
config: AssistantRequestConfig
|
||||
/** Rendered system prompt text; empty when the request had no system prompt. */
|
||||
system: string
|
||||
/** Complete tool catalog sent with the request, including tools that were never called. */
|
||||
tools: readonly ToolSchema[]
|
||||
}
|
||||
|
||||
/** System/tool change introduced while preparing one ordinary request. */
|
||||
export interface RequestPromptChange {
|
||||
/** Sequence of the request/header event that introduced this state. */
|
||||
seq: number
|
||||
/** Unix epoch ms from the request/header event. */
|
||||
time: number
|
||||
/** How the model-visible prompt differs from the previous recorded state. */
|
||||
kind: 'initial' | 'system' | 'tools' | 'system-and-tools'
|
||||
/** State immediately before this change; absent for the initial header. */
|
||||
previous?: ConversationPromptSnapshot
|
||||
}
|
||||
|
||||
/** One provider request reconstructed from durable request lifecycle events. */
|
||||
export interface RequestView {
|
||||
/** Request category; compaction is a purpose, not a separate projection. */
|
||||
purpose: 'assistant' | 'compaction'
|
||||
/** Sequence that opened the operation represented by this request. */
|
||||
startSeq: number
|
||||
turn: number
|
||||
/** Agent-loop step, or zero for a direct compaction request. */
|
||||
step: number
|
||||
startedAt: number
|
||||
completedAt: number | null
|
||||
status: 'running' | 'complete' | 'error'
|
||||
error?: string
|
||||
/** Effective ordinary request input, inherited until a later header changes it. */
|
||||
prompt?: ConversationPromptSnapshot
|
||||
/** Prompt change logged while preparing this request. */
|
||||
promptChange?: RequestPromptChange
|
||||
provenance?: AssistantProvenanceView
|
||||
requestConfig?: AssistantRequestConfig
|
||||
usage?: unknown
|
||||
/** Assistant message or compaction summary sequence produced by this request. */
|
||||
resultSeq?: number
|
||||
/** Compaction replacement message sequence, when one was committed. */
|
||||
replacementSeq?: number
|
||||
/** Safe compaction summary projection. */
|
||||
summary?: readonly ContentBlock[]
|
||||
/** Complete compaction provider output before the safe projection. */
|
||||
rawOutput?: readonly ContentBlock[]
|
||||
/** Retry ordinal scheduled after a failed ordinary request. */
|
||||
retry?: number
|
||||
maxRetries?: number
|
||||
retryDelayMs?: number
|
||||
}
|
||||
|
||||
/** Immutable request-centric projection derived from one history window. */
|
||||
export interface RequestInspectionSnapshot {
|
||||
requests: readonly RequestView[]
|
||||
callSchemas: ReadonlyMap<string, ToolSchema>
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the request-centric read model from one immutable history window.
|
||||
* Compaction participates as a request purpose rather than a parallel
|
||||
* top-level collection.
|
||||
* @param entries - Contiguous raw session history.
|
||||
* @returns Requests and call-time schemas derived from that history.
|
||||
*/
|
||||
export function inspectRequests(
|
||||
entries: readonly HistoryEntry[],
|
||||
): RequestInspectionSnapshot {
|
||||
const events = entries.map(entry => entry.event)
|
||||
return {
|
||||
requests: deriveRequests(events),
|
||||
callSchemas: deriveCallSchemas(events),
|
||||
}
|
||||
}
|
||||
|
||||
interface RetryEvent {
|
||||
type: 'llm/retry'
|
||||
seq: number
|
||||
time: number
|
||||
data: {
|
||||
turn: number
|
||||
step: number
|
||||
retry: number
|
||||
maxRetries: number
|
||||
delayMs: number
|
||||
failure: { message: string }
|
||||
}
|
||||
}
|
||||
|
||||
interface CompactionStartEvent {
|
||||
type: 'compact/start'
|
||||
seq: number
|
||||
time: number
|
||||
data: { turn: number }
|
||||
}
|
||||
|
||||
interface CompactionSummaryEvent {
|
||||
type: 'compact/summary'
|
||||
seq: number
|
||||
time: number
|
||||
data: {
|
||||
summary: readonly ContentBlock[]
|
||||
rawOutput?: readonly ContentBlock[]
|
||||
provider: string
|
||||
model: string
|
||||
maxTokens?: number
|
||||
usage?: unknown
|
||||
}
|
||||
}
|
||||
|
||||
interface CompactionEndEvent {
|
||||
type: 'compact/end'
|
||||
seq: number
|
||||
time: number
|
||||
data: { turn: number; error?: string }
|
||||
}
|
||||
|
||||
function requestKey(turn: number, step: number): string {
|
||||
return `${turn}\u0000${step}`
|
||||
}
|
||||
|
||||
function addTokenUsage(current: unknown, next: TokenUsage): TokenUsage {
|
||||
const previous = current as TokenUsage | undefined
|
||||
return {
|
||||
inputTokens: (previous?.inputTokens ?? 0) + next.inputTokens,
|
||||
outputTokens: (previous?.outputTokens ?? 0) + next.outputTokens,
|
||||
...(previous?.cacheReadTokens === undefined && next.cacheReadTokens === undefined
|
||||
? {}
|
||||
: {
|
||||
cacheReadTokens:
|
||||
(previous?.cacheReadTokens ?? 0) + (next.cacheReadTokens ?? 0),
|
||||
}),
|
||||
...(previous?.cacheWriteTokens === undefined && next.cacheWriteTokens === undefined
|
||||
? {}
|
||||
: {
|
||||
cacheWriteTokens:
|
||||
(previous?.cacheWriteTokens ?? 0) + (next.cacheWriteTokens ?? 0),
|
||||
}),
|
||||
...(previous?.reasoningTokens === undefined && next.reasoningTokens === undefined
|
||||
? {}
|
||||
: {
|
||||
reasoningTokens:
|
||||
(previous?.reasoningTokens ?? 0) + (next.reasoningTokens ?? 0),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
function deriveCallSchemas(
|
||||
events: readonly SessionEvent[],
|
||||
): ReadonlyMap<string, ToolSchema> {
|
||||
let active = new Map<string, ToolSchema>()
|
||||
const calls = new Map<string, ToolSchema>()
|
||||
const capture = (callId: string, name: string): void => {
|
||||
if (calls.has(callId)) return
|
||||
const schema = active.get(name)
|
||||
if (schema !== undefined) calls.set(callId, schema)
|
||||
}
|
||||
for (const event of events) {
|
||||
if (event.type === 'request/header') {
|
||||
const tools: unknown = event.data.header.tools
|
||||
active = new Map(
|
||||
Array.isArray(tools)
|
||||
? (tools as ToolSchema[]).map(schema => [schema.name, schema])
|
||||
: [],
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (event.type === 'tool/call') {
|
||||
capture(String(event.data.callId), event.data.name)
|
||||
continue
|
||||
}
|
||||
const type = event.type as string
|
||||
if (type === 'tool/code-dispatch-start' || type === 'tool/code-dispatch') {
|
||||
const data = event.data as unknown as { subCallId: string; name: string }
|
||||
capture(data.subCallId, data.name)
|
||||
}
|
||||
}
|
||||
return calls
|
||||
}
|
||||
|
||||
function promptChange(
|
||||
previous: ConversationPromptSnapshot | undefined,
|
||||
prompt: ConversationPromptSnapshot,
|
||||
event: SessionEvent<'request/header'>,
|
||||
): RequestPromptChange | undefined {
|
||||
const systemChanged = previous !== undefined && previous.system !== prompt.system
|
||||
const toolsChanged = previous !== undefined
|
||||
&& JSON.stringify(previous.tools) !== JSON.stringify(prompt.tools)
|
||||
if (previous !== undefined && !systemChanged && !toolsChanged) return
|
||||
return {
|
||||
seq: event.seq,
|
||||
time: event.time,
|
||||
kind: previous === undefined
|
||||
? 'initial'
|
||||
: systemChanged && toolsChanged
|
||||
? 'system-and-tools'
|
||||
: systemChanged
|
||||
? 'system'
|
||||
: 'tools',
|
||||
...(previous === undefined ? {} : { previous }),
|
||||
}
|
||||
}
|
||||
|
||||
/** Project ordinary and compaction provider calls into one chronological request stream. */
|
||||
function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[] {
|
||||
const requests: RequestView[] = []
|
||||
const ordinaryByStep = new Map<string, number>()
|
||||
let activeStep: string | undefined
|
||||
let activePrompt: ConversationPromptSnapshot | undefined
|
||||
let activeCompaction: number | undefined
|
||||
|
||||
const update = (index: number | undefined, change: Partial<RequestView>): void => {
|
||||
if (index === undefined) return
|
||||
const request = requests[index]
|
||||
if (request !== undefined) requests[index] = { ...request, ...change }
|
||||
}
|
||||
|
||||
for (const sourceEvent of events) {
|
||||
if (sourceEvent.type === 'step/start') {
|
||||
const { turn, step } = sourceEvent.data
|
||||
const key = requestKey(turn, step)
|
||||
ordinaryByStep.set(key, requests.length)
|
||||
requests.push({
|
||||
purpose: 'assistant',
|
||||
startSeq: sourceEvent.seq,
|
||||
turn,
|
||||
step,
|
||||
startedAt: sourceEvent.time,
|
||||
completedAt: null,
|
||||
status: 'running',
|
||||
...(activePrompt === undefined
|
||||
? {}
|
||||
: { prompt: activePrompt, requestConfig: activePrompt.config }),
|
||||
})
|
||||
activeStep = key
|
||||
continue
|
||||
}
|
||||
if (sourceEvent.type === 'request/header') {
|
||||
const tools: unknown = sourceEvent.data.header.tools
|
||||
const prompt: ConversationPromptSnapshot = {
|
||||
config: sourceEvent.data.header.config,
|
||||
system: sourceEvent.data.header.system ?? '',
|
||||
tools: Array.isArray(tools) ? tools as ToolSchema[] : [],
|
||||
}
|
||||
const change = promptChange(activePrompt, prompt, sourceEvent)
|
||||
activePrompt = prompt
|
||||
update(activeStep === undefined ? undefined : ordinaryByStep.get(activeStep), {
|
||||
prompt,
|
||||
requestConfig: prompt.config,
|
||||
...(change === undefined ? {} : { promptChange: change }),
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (
|
||||
sourceEvent.type === 'assistant/chunk'
|
||||
&& sourceEvent.data.chunk.type === 'usage'
|
||||
) {
|
||||
const index = ordinaryByStep.get(
|
||||
requestKey(sourceEvent.data.turn, sourceEvent.data.step),
|
||||
)
|
||||
const request = index === undefined ? undefined : requests[index]
|
||||
update(index, {
|
||||
usage: addTokenUsage(request?.usage, sourceEvent.data.chunk.usage),
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (sourceEvent.type === 'assistant/message') {
|
||||
const index = ordinaryByStep.get(
|
||||
requestKey(sourceEvent.data.turn, sourceEvent.data.step),
|
||||
)
|
||||
const request = index === undefined ? undefined : requests[index]
|
||||
update(index, {
|
||||
completedAt: sourceEvent.time,
|
||||
status: 'complete',
|
||||
resultSeq: sourceEvent.seq,
|
||||
provenance: {
|
||||
provider: sourceEvent.data.message.source.provider,
|
||||
model: sourceEvent.data.message.source.model,
|
||||
},
|
||||
...(request?.usage !== undefined || sourceEvent.data.usage === undefined
|
||||
? {}
|
||||
: { usage: sourceEvent.data.usage }),
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (sourceEvent.type === 'step/end') {
|
||||
const key = requestKey(sourceEvent.data.turn, sourceEvent.data.step)
|
||||
const index = ordinaryByStep.get(key)
|
||||
const request = index === undefined ? undefined : requests[index]
|
||||
if (request?.status === 'running') {
|
||||
update(index, {
|
||||
completedAt: sourceEvent.time,
|
||||
status: 'error',
|
||||
})
|
||||
}
|
||||
if (activeStep === key) activeStep = undefined
|
||||
continue
|
||||
}
|
||||
if ((sourceEvent.type as string) === 'llm/retry') {
|
||||
const event = sourceEvent as unknown as RetryEvent
|
||||
update(ordinaryByStep.get(requestKey(event.data.turn, event.data.step)), {
|
||||
status: 'error',
|
||||
error: event.data.failure.message,
|
||||
retry: event.data.retry,
|
||||
maxRetries: event.data.maxRetries,
|
||||
retryDelayMs: event.data.delayMs,
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (sourceEvent.type === 'turn/end' && sourceEvent.data.reason.kind === 'error') {
|
||||
const reason = sourceEvent.data.reason
|
||||
update(ordinaryByStep.get(requestKey(sourceEvent.data.turn, reason.step)), {
|
||||
status: 'error',
|
||||
error: 'failure' in reason ? reason.failure.message : reason.message,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const type = sourceEvent.type as string
|
||||
if (type === 'compact/start') {
|
||||
const event = sourceEvent as unknown as CompactionStartEvent
|
||||
activeCompaction = requests.length
|
||||
requests.push({
|
||||
purpose: 'compaction',
|
||||
startSeq: event.seq,
|
||||
turn: event.data.turn,
|
||||
step: 0,
|
||||
startedAt: event.time,
|
||||
completedAt: null,
|
||||
status: 'running',
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (type === 'compact/summary' && activeCompaction !== undefined) {
|
||||
const event = sourceEvent as unknown as CompactionSummaryEvent
|
||||
update(activeCompaction, {
|
||||
resultSeq: event.seq,
|
||||
summary: event.data.summary,
|
||||
...(event.data.rawOutput === undefined ? {} : { rawOutput: event.data.rawOutput }),
|
||||
provenance: {
|
||||
provider: event.data.provider,
|
||||
model: event.data.model,
|
||||
},
|
||||
requestConfig: {
|
||||
provider: event.data.provider,
|
||||
model: event.data.model,
|
||||
purpose: 'compaction',
|
||||
...(event.data.maxTokens === undefined ? {} : { maxTokens: event.data.maxTokens }),
|
||||
},
|
||||
...(event.data.usage === undefined ? {} : { usage: event.data.usage }),
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (
|
||||
sourceEvent.type === 'user/message'
|
||||
&& activeCompaction !== undefined
|
||||
&& isCompactionSource(sourceEvent.data.source)
|
||||
) {
|
||||
update(activeCompaction, { replacementSeq: sourceEvent.seq })
|
||||
continue
|
||||
}
|
||||
if (type !== 'compact/end' || activeCompaction === undefined) continue
|
||||
const event = sourceEvent as unknown as CompactionEndEvent
|
||||
update(activeCompaction, {
|
||||
completedAt: event.time,
|
||||
status: event.data.error === undefined ? 'complete' : 'error',
|
||||
...(event.data.error === undefined ? {} : { error: event.data.error }),
|
||||
})
|
||||
activeCompaction = undefined
|
||||
}
|
||||
|
||||
return requests.sort((left, right) => left.startSeq - right.startSeq)
|
||||
}
|
||||
|
||||
function isCompactionSource(source: unknown): boolean {
|
||||
return typeof source === 'object'
|
||||
&& source !== null
|
||||
&& 'kind' in source
|
||||
&& source.kind === 'plugin'
|
||||
&& 'plugin' in source
|
||||
&& source.plugin === 'compact'
|
||||
}
|
||||
@@ -40,6 +40,8 @@ export interface SessionSummary {
|
||||
cwd?: string
|
||||
parentId?: SessionId
|
||||
running: boolean
|
||||
/** An approval question is pending on this session (sidebar amber-dot state). */
|
||||
waitingApproval: boolean
|
||||
/**
|
||||
* Empty-log bit (host summary derivation mirror). New Session reuses a blank
|
||||
* one targeting the same workspace. Filtering stays with the consumer: the
|
||||
@@ -292,6 +294,11 @@ export class SessionsService implements ISessions {
|
||||
this.manager.handleConnected()
|
||||
}
|
||||
|
||||
/** Drop generation-scoped live interaction state the moment a connection generation dies. */
|
||||
handleDisconnected(): void {
|
||||
this.manager.handleDisconnected()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a session on the host. Resolution guarantee: by the time the
|
||||
* promise resolves, the created session is in the list store and
|
||||
@@ -463,6 +470,7 @@ export class SessionsService implements ISessions {
|
||||
id: entry.sessionId,
|
||||
displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId),
|
||||
running: entry.running,
|
||||
waitingApproval: entry.waitingApproval,
|
||||
blank: entry.blank,
|
||||
updatedAt: entry.updatedAt,
|
||||
...(entry.title !== undefined ? { title: entry.title } : {}),
|
||||
|
||||
@@ -252,6 +252,40 @@ export class Session implements SessionFace {
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename: contract session.rename 1:1. On success settle the 'title'
|
||||
* projection cell from the response's `{title, seq}` under the store's
|
||||
* higher-seq-wins rule (the push frame arriving later is a no-op replay),
|
||||
* so the list row and any useProjection('title') reader update without
|
||||
* waiting for the mux frame.
|
||||
* @param title - raw title text (the host normalizes acceptance).
|
||||
* @returns the rename result (normalized accepted title + title event seq).
|
||||
*/
|
||||
async rename(title: string): Promise<RpcResult<{ title: string; seq: number }>> {
|
||||
try {
|
||||
const { result } = await this.api.sessions.rename({ sessionId: this.sessionId, title })
|
||||
if (result.ok) this.projections.apply('title', result.value.title, result.value.seq)
|
||||
return result
|
||||
} catch (error) {
|
||||
return transportError(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute one slash-command line against this session's agent — pure
|
||||
* admission semantics (the host executor durably logs the lifecycle;
|
||||
* outcomes render as flow nodes, never as a response echo).
|
||||
* @param line - the full command line, leading slash included.
|
||||
* @returns the admission result, or the error branch on transport failure.
|
||||
*/
|
||||
async command(line: string): Promise<RpcResult<{ matched: boolean }>> {
|
||||
try {
|
||||
return (await this.api.commands.execute({ sessionId: this.sessionId, line })).result
|
||||
} catch (error) {
|
||||
return transportError(error)
|
||||
}
|
||||
}
|
||||
|
||||
/** First open: pull the tail page (idempotent — in-flight/already-open returns the existing promise). */
|
||||
open(): Promise<void> {
|
||||
if (this.openState === 'open') return Promise.resolve()
|
||||
@@ -812,7 +846,10 @@ export class Session implements SessionFace {
|
||||
queue: this.queueCache.value,
|
||||
running: this.running,
|
||||
composerPhase: derivePhase(
|
||||
nodes.length > 0 || partial !== null || this.running || this.pendingCache.value.length > 0,
|
||||
// Command lifecycle nodes are not conversation: running /permission
|
||||
// or /plan on a fresh session keeps the hero (the client mirror of
|
||||
// the host's no-turn sessionBlank predicate).
|
||||
nodes.some(node => node.kind !== 'command') || partial !== null || this.running || this.pendingCache.value.length > 0,
|
||||
this.promptAttempted,
|
||||
),
|
||||
removed: this.removed,
|
||||
@@ -833,7 +870,9 @@ export class Session implements SessionFace {
|
||||
* object: `hasContent` only grows within a window and `promptAttempted` is
|
||||
* sticky, so blank → engaging → active never steps back; a failed first
|
||||
* prompt stays engaging (retry semantics — see ComposerPhase).
|
||||
* @param hasContent - any conversation material exists (nodes, partial, running turn, pending waits).
|
||||
* @param hasContent - any conversation material exists (non-command nodes,
|
||||
* partial, running turn, pending waits; command lifecycle rows alone keep
|
||||
* the session blank).
|
||||
* @param promptAttempted - a prompt was initiated on this session object.
|
||||
* @returns the derived phase.
|
||||
*/
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type {
|
||||
IApiClient, RpcError, SessionId, WorkspaceId, WorkspaceView,
|
||||
DirectoryListing, IApiClient, RpcError,
|
||||
SessionId, WorkspaceId, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SnapshotStore } from '../contract/store.ts'
|
||||
import { createSnapshotStore } from '../contract/store.ts'
|
||||
@@ -30,6 +31,14 @@ export class WorkspaceCreateError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** Structured browse failure so the directory browser can branch on Host business codes. */
|
||||
export class DirectoryBrowseError extends Error {
|
||||
constructor(readonly rpcError: RpcError) {
|
||||
super(`directory browse failed: ${rpcError.code}: ${rpcError.message}`)
|
||||
this.name = 'DirectoryBrowseError'
|
||||
}
|
||||
}
|
||||
|
||||
/** Real Workspace object layer and Host actions. */
|
||||
export class WorkspacesService implements IWorkspaces {
|
||||
/** UI-facing immutable projection; the manager remains wire truth. */
|
||||
@@ -172,7 +181,7 @@ export class WorkspacesService implements IWorkspaces {
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the Host's native directory picker.
|
||||
* Open the Host's native directory picker (the `native` capability).
|
||||
* @returns the selected path, or null when the user cancelled.
|
||||
*/
|
||||
async pickDirectory(): Promise<string | null> {
|
||||
@@ -183,6 +192,30 @@ export class WorkspacesService implements IWorkspaces {
|
||||
return response.result.value.path
|
||||
}
|
||||
|
||||
/**
|
||||
* List one directory level through the Host's `browse` capability.
|
||||
* @param path - absolute directory to list; absent lists the Host home directory.
|
||||
* @param signal - aborts the wire request (and the Host's scan) when the caller supersedes it.
|
||||
* @returns the level's listing with breadcrumb ancestry.
|
||||
*/
|
||||
async listDirectory(path?: string, signal?: AbortSignal): Promise<DirectoryListing> {
|
||||
const response = await this.api.host.listDirectory(path === undefined ? {} : { path }, signal)
|
||||
if (!response.result.ok) throw new DirectoryBrowseError(response.result.error)
|
||||
return response.result.value
|
||||
}
|
||||
|
||||
/**
|
||||
* Create one child directory through the Host's `browse` capability.
|
||||
* @param path - absolute existing parent directory.
|
||||
* @param name - single non-blank path segment.
|
||||
* @returns the created directory's absolute path.
|
||||
*/
|
||||
async createDirectory(path: string, name: string): Promise<string> {
|
||||
const response = await this.api.host.createDirectory({ path, name })
|
||||
if (!response.result.ok) throw new DirectoryBrowseError(response.result.error)
|
||||
return response.result.value.path
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a filesystem path with the Host operating system's default application.
|
||||
* @param path - absolute or host-resolvable path.
|
||||
|
||||
@@ -63,6 +63,7 @@ export class FakeApiClient implements IApiClient {
|
||||
onList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
|
||||
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
|
||||
readonly defaultModel: ModelTarget = { provider: 'deepseek', model: 'deepseek-v4-flash' }
|
||||
onRename: (payload: unknown) => Promise<RpcResponse<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
|
||||
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
|
||||
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean }>> =
|
||||
() => Promise.resolve(ok({ events: [], hasMore: false }))
|
||||
@@ -81,6 +82,7 @@ export class FakeApiClient implements IApiClient {
|
||||
payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } }))
|
||||
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
|
||||
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
|
||||
() => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 }))
|
||||
onPickDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string | null }>> =
|
||||
@@ -88,6 +90,18 @@ export class FakeApiClient implements IApiClient {
|
||||
onOpenPath: (payload: unknown) => Promise<RpcResponse<{ opened: true }>> =
|
||||
() => Promise.resolve(ok({ opened: true as const }))
|
||||
|
||||
onListDirectory: (payload: unknown) => Promise<RpcResponse<{
|
||||
path: string
|
||||
home: string
|
||||
crumbs: { name: string; path: string; hidden: boolean }[]
|
||||
entries: { name: string; path: string; hidden: boolean }[]
|
||||
truncated: boolean
|
||||
}>> =
|
||||
() => Promise.resolve(ok({ path: '/home/fake', home: '/home/fake', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [], truncated: false }))
|
||||
|
||||
onCreateDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string }>> =
|
||||
() => Promise.resolve(ok({ path: '/home/fake/new' }))
|
||||
|
||||
private readonly muxConns: StreamConn<MuxFrame>[] = []
|
||||
private readonly hostConns: StreamConn<HostFrame>[] = []
|
||||
|
||||
@@ -102,6 +116,7 @@ export class FakeApiClient implements IApiClient {
|
||||
models: (payload: unknown) => this.record('session.models', payload, this.onModels(payload)),
|
||||
selectModel: (payload: { provider: string; model: string }) =>
|
||||
this.record('session.selectModel', payload, this.onSelectModel(payload)),
|
||||
rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(payload)),
|
||||
prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
|
||||
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
|
||||
}
|
||||
@@ -109,6 +124,8 @@ export class FakeApiClient implements IApiClient {
|
||||
readonly host: IApiClient['host'] = {
|
||||
describe: (payload: unknown) => this.record('host.describe', payload, this.onDescribe(payload)),
|
||||
pickDirectory: (payload: unknown) => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)),
|
||||
listDirectory: (payload: unknown) => this.record('host.listDirectory', payload, this.onListDirectory(payload)),
|
||||
createDirectory: (payload: unknown) => this.record('host.createDirectory', payload, this.onCreateDirectory(payload)),
|
||||
openPath: (payload: unknown) => this.record('host.openPath', payload, this.onOpenPath(payload)),
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { createUserMessage, CallId, createMessage, createToolResultMessage } fro
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import { FoldAdapter } from '../src/client/sessions/fold-adapter.ts'
|
||||
import { projectConversationHistory } from '../src/client/session-history/history-fold.ts'
|
||||
import { ev, plainTurn } from './event-script.ts'
|
||||
|
||||
const at = (seq: number, e: Record<string, unknown>): SessionEvent =>
|
||||
@@ -27,6 +28,7 @@ describe('FoldAdapter', () => {
|
||||
const adapter = new FoldAdapter()
|
||||
adapter.reset(plainTurn(0, 0, 'a', 'b'), 0)
|
||||
const first = adapter.nodes()
|
||||
expect(adapter.nodes()).toBe(first)
|
||||
adapter.append(ev.user(6, '追加'))
|
||||
const second = adapter.nodes()
|
||||
expect(second.nodes).toHaveLength(3)
|
||||
@@ -35,6 +37,52 @@ describe('FoldAdapter', () => {
|
||||
expect(second.nodes).not.toBe(first.nodes) // array itself fresh per call
|
||||
})
|
||||
|
||||
it('projects frozen surface generations without widening the core live surface', () => {
|
||||
const events = [
|
||||
ev.user(0, 'a'),
|
||||
ev.user(1, 'b'),
|
||||
at(2, {
|
||||
type: 'assistant/message',
|
||||
surfaceOp: { op: 'replace', start: 0, end: 0 },
|
||||
sourceEventSeqs: [0],
|
||||
data: {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'summary' }],
|
||||
source: { kind: 'model', provider: 'fake', model: 'fake' },
|
||||
}),
|
||||
},
|
||||
}),
|
||||
at(3, {
|
||||
type: 'assistant/message',
|
||||
surfaceOp: { op: 'replace', start: 2, end: 1 },
|
||||
sourceEventSeqs: [2, 1],
|
||||
data: {
|
||||
turn: 1,
|
||||
step: 2,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'summary 2' }],
|
||||
source: { kind: 'model', provider: 'fake', model: 'fake' },
|
||||
}),
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
expect(projectConversationHistory(events.map(event => ({ event }))).contexts.map(context => ({
|
||||
id: context.id,
|
||||
parentId: context.parentId,
|
||||
originSeq: context.originSeq,
|
||||
nodes: context.nodes.map(node => node.seq),
|
||||
}))).toEqual([
|
||||
{ id: 0, parentId: undefined, originSeq: undefined, nodes: [0, 1] },
|
||||
{ id: 1, parentId: 0, originSeq: 2, nodes: [2, 1] },
|
||||
{ id: 2, parentId: 1, originSeq: 3, nodes: [3] },
|
||||
])
|
||||
})
|
||||
|
||||
it('materializes all six node variants with field mapping', () => {
|
||||
const adapter = new FoldAdapter()
|
||||
const events = [
|
||||
@@ -114,6 +162,68 @@ describe('FoldAdapter', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('silently degrades when a replacement needs an earlier history page', () => {
|
||||
const adapter = new FoldAdapter()
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
try {
|
||||
adapter.reset([
|
||||
at(10, {
|
||||
type: 'assistant/message',
|
||||
surfaceOp: { op: 'replace', start: 1, end: 3 },
|
||||
sourceEventSeqs: [1, 3],
|
||||
data: {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'partial summary' }],
|
||||
source: { kind: 'model', provider: 'fake', model: 'fake' },
|
||||
}),
|
||||
},
|
||||
}),
|
||||
ev.user(11, 'newer message'),
|
||||
], 10)
|
||||
|
||||
expect(adapter.nodes()).toMatchObject({
|
||||
degraded: true,
|
||||
nodes: [{ seq: 10 }, { seq: 11 }],
|
||||
})
|
||||
expect(errorSpy).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
errorSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('silently degrades when a live replacement needs an earlier history page', () => {
|
||||
const adapter = new FoldAdapter()
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
try {
|
||||
adapter.reset([ev.user(10, 'window head')], 10)
|
||||
adapter.append(at(11, {
|
||||
type: 'assistant/message',
|
||||
surfaceOp: { op: 'replace', start: 1, end: 1 },
|
||||
sourceEventSeqs: [1],
|
||||
data: {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'live summary' }],
|
||||
source: { kind: 'model', provider: 'fake', model: 'fake' },
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
expect(adapter.nodes()).toMatchObject({
|
||||
degraded: true,
|
||||
nodes: [{ seq: 10 }, { seq: 11 }],
|
||||
})
|
||||
expect(errorSpy).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
errorSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('materializes a tool-result error field when present', () => {
|
||||
const adapter = new FoldAdapter()
|
||||
adapter.reset([
|
||||
@@ -130,6 +240,44 @@ describe('FoldAdapter', () => {
|
||||
expect(adapter.nodes().nodes[0]).toMatchObject({ kind: 'tool-result', isError: true, error: { code: 'boom' } })
|
||||
})
|
||||
|
||||
it('projects assistant timing and the active request header from history', () => {
|
||||
const projection = projectConversationHistory([
|
||||
ev.stepStart(0, 1, 2),
|
||||
at(1, { type: 'request/header', data: {
|
||||
reason: 'initial',
|
||||
header: {
|
||||
config: { provider: 'fake', model: 'first' },
|
||||
tools: [],
|
||||
},
|
||||
} }),
|
||||
ev.chunkStart(2, 1, 2),
|
||||
ev.chunkText(3, 1, 'token', 2),
|
||||
ev.assistant(4, 1, 'done', 2),
|
||||
ev.stepStart(5, 2, 1),
|
||||
ev.chunkText(6, 2, 'next', 1),
|
||||
ev.assistant(7, 2, 'next done', 1),
|
||||
].map(event => ({ event })))
|
||||
|
||||
expect(projection.eventNodes[0]).toMatchObject({
|
||||
kind: 'assistant',
|
||||
timing: {
|
||||
stepStartTime: 1_700_000_000_000,
|
||||
firstTokenTime: 1_700_000_000_003,
|
||||
completedTime: 1_700_000_000_004,
|
||||
},
|
||||
requestConfig: { provider: 'fake', model: 'first' },
|
||||
})
|
||||
|
||||
expect(projection.eventNodes.at(-1)).toMatchObject({
|
||||
timing: {
|
||||
stepStartTime: 1_700_000_000_005,
|
||||
firstTokenTime: 1_700_000_000_006,
|
||||
completedTime: 1_700_000_000_007,
|
||||
},
|
||||
requestConfig: { provider: 'fake', model: 'first' },
|
||||
})
|
||||
})
|
||||
|
||||
it('exposes the in-window call index for runningCalls material', () => {
|
||||
const adapter = new FoldAdapter()
|
||||
adapter.reset([ev.toolCall(0, 1, 'c9', 'slow', '{}')], 0)
|
||||
|
||||
@@ -376,3 +376,62 @@ describe('connected generation', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('waiting-approval list bit', () => {
|
||||
it('lights on requested, survives replay duplicates, and clears on resolved — without instantiation', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false)
|
||||
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
|
||||
// Mux-open replay of the same question (same approvalId) is idempotent.
|
||||
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
|
||||
manager.handleMuxEnvelope({ rpcId: 'rx' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'ap1' as never, outcome: 'allowed-once' as never } })
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false)
|
||||
})
|
||||
|
||||
it('clears only when the last outstanding question resolves; session-removed drops the bit', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
|
||||
manager.handleMuxEnvelope({ rpcId: 'r1' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a1' as never, toolName: 'rm' } })
|
||||
manager.handleMuxEnvelope({ rpcId: 'r2' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a2' as never, toolName: 'rm' } })
|
||||
manager.handleMuxEnvelope({ rpcId: 'rx' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'a1' as never, outcome: 'rejected' as never } })
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
|
||||
manager.handleMuxEnvelope({ rpcId: 'ry' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'a2' as never, outcome: 'rejected' as never } })
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false)
|
||||
// Removed sessions drop their bit outright.
|
||||
manager.handleMuxEnvelope({ rpcId: 'r3' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a3' as never, toolName: 'rm' } })
|
||||
manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-removed', sessionId: S1 } })
|
||||
expect(manager.getListSnapshot().items).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('drops stale bits at generation death — BEFORE the reopen replay re-adds still-pending questions', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
|
||||
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
|
||||
// Generation death clears (resolved-while-disconnected questions send no frame)…
|
||||
manager.handleDisconnected()
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false)
|
||||
// …and a replayed frame arriving before onConnected (stream open precedes
|
||||
// the readiness handshake) survives the later handleConnected untouched.
|
||||
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
|
||||
manager.handleConnected()
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
|
||||
})
|
||||
|
||||
it('generation death drops buffered answerable frames (a dead generation cannot be answered)', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
|
||||
// Buffered pre-instantiation: an approval pair and a queued row.
|
||||
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
|
||||
manager.handleMuxEnvelope({ rpcId: 'q1' as never, payload: { type: 'question/requested', sessionId: S1, questions: [] } })
|
||||
manager.handleDisconnected()
|
||||
// Instantiate after the death sweep: no zombie interaction replays (the
|
||||
// pendingBuffers held only dead-generation rpcIds), so the session mints
|
||||
// no pending waits.
|
||||
const session = manager.get(S1)
|
||||
expect(session.getSnapshot().pending).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
184
packages/client/runtime/tests/request-inspection.spec.ts
Normal file
184
packages/client/runtime/tests/request-inspection.spec.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import { inspectRequests } from '../src/client/sessions/request-inspection.ts'
|
||||
|
||||
const at = (seq: number, type: string, data: unknown): SessionEvent =>
|
||||
({ seq, time: 1_700_000_000_000 + seq, type, data }) as SessionEvent
|
||||
|
||||
const entriesOf = (events: readonly SessionEvent[]): HistoryEntry[] =>
|
||||
events.map(event => ({ event }))
|
||||
|
||||
describe('inspectRequests', () => {
|
||||
it('projects ordinary and compaction calls into one chronological request stream', () => {
|
||||
const events = [
|
||||
at(0, 'step/start', { turn: 1, step: 1 }),
|
||||
at(1, 'request/header', {
|
||||
reason: 'initial',
|
||||
header: {
|
||||
config: { provider: 'fake', model: 'model' },
|
||||
system: 'system',
|
||||
tools: [{
|
||||
name: 'read',
|
||||
description: 'Read a file.',
|
||||
parameters: { type: 'object' },
|
||||
}],
|
||||
},
|
||||
}),
|
||||
at(2, 'tool/call', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId: 'call-1',
|
||||
name: 'read',
|
||||
arguments: '{}',
|
||||
}),
|
||||
at(3, 'assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createAssistantMessage({
|
||||
content: [{ type: 'text', text: 'done' }],
|
||||
source: { provider: 'fake', model: 'model' },
|
||||
}),
|
||||
usage: { inputTokens: 5, outputTokens: 2 },
|
||||
}),
|
||||
at(4, 'step/end', { turn: 1, step: 1 }),
|
||||
at(5, 'compact/start', { turn: 1 }),
|
||||
at(6, 'compact/summary', {
|
||||
summary: [{ type: 'text', text: 'summary' }],
|
||||
rawOutput: [
|
||||
{ type: 'reasoning', text: 'thought' },
|
||||
{ type: 'text', text: 'summary' },
|
||||
],
|
||||
provider: 'fake',
|
||||
model: 'compact-model',
|
||||
usage: { inputTokens: 8, outputTokens: 3 },
|
||||
}),
|
||||
at(7, 'user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'checkpoint' }],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
})),
|
||||
at(8, 'compact/end', { turn: 1 }),
|
||||
]
|
||||
const snapshot = inspectRequests(entriesOf(events))
|
||||
expect(snapshot.requests).toMatchObject([
|
||||
{
|
||||
purpose: 'assistant',
|
||||
startSeq: 0,
|
||||
resultSeq: 3,
|
||||
status: 'complete',
|
||||
prompt: {
|
||||
config: { provider: 'fake', model: 'model' },
|
||||
system: 'system',
|
||||
},
|
||||
promptChange: { seq: 1, kind: 'initial' },
|
||||
},
|
||||
{
|
||||
purpose: 'compaction',
|
||||
startSeq: 5,
|
||||
resultSeq: 6,
|
||||
replacementSeq: 7,
|
||||
status: 'complete',
|
||||
summary: [{ type: 'text', text: 'summary' }],
|
||||
},
|
||||
])
|
||||
expect(snapshot.callSchemas.get('call-1')?.name).toBe('read')
|
||||
})
|
||||
|
||||
it('captures schemas for nested tool dispatches from the active request header', () => {
|
||||
const snapshot = inspectRequests(entriesOf([
|
||||
at(0, 'request/header', {
|
||||
reason: 'initial',
|
||||
header: {
|
||||
config: { provider: 'fake', model: 'model' },
|
||||
tools: [{
|
||||
name: 'read',
|
||||
description: 'Read a file.',
|
||||
parameters: { type: 'object' },
|
||||
}],
|
||||
},
|
||||
}),
|
||||
at(1, 'tool/code-dispatch-start', {
|
||||
parentCallId: 'parent',
|
||||
subCallId: 'nested',
|
||||
name: 'read',
|
||||
arguments: {},
|
||||
}),
|
||||
]))
|
||||
|
||||
expect(snapshot.callSchemas.get('nested')?.name).toBe('read')
|
||||
})
|
||||
|
||||
it('keeps chunk-reported usage through request failure and prefers it to message fallback', () => {
|
||||
const chunkUsage = { inputTokens: 21, outputTokens: 3 }
|
||||
const retryUsage = {
|
||||
inputTokens: 5,
|
||||
outputTokens: 2,
|
||||
cacheReadTokens: 8,
|
||||
reasoningTokens: 1,
|
||||
}
|
||||
const snapshot = inspectRequests(entriesOf([
|
||||
at(0, 'step/start', { turn: 1, step: 1 }),
|
||||
at(1, 'assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'usage', usage: chunkUsage },
|
||||
}),
|
||||
at(2, 'llm/retry', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
retry: 1,
|
||||
maxRetries: 2,
|
||||
delayMs: 100,
|
||||
failure: { message: 'rate limited' },
|
||||
}),
|
||||
at(3, 'assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'usage', usage: retryUsage },
|
||||
}),
|
||||
at(4, 'assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createAssistantMessage({
|
||||
content: [{ type: 'text', text: 'recovered' }],
|
||||
source: { provider: 'fake', model: 'model' },
|
||||
}),
|
||||
usage: { inputTokens: 1, outputTokens: 1 },
|
||||
}),
|
||||
]))
|
||||
|
||||
expect(snapshot.requests[0]).toMatchObject({
|
||||
status: 'complete',
|
||||
usage: {
|
||||
inputTokens: 26,
|
||||
outputTokens: 5,
|
||||
cacheReadTokens: 8,
|
||||
reasoningTokens: 1,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('treats a scrubbed durable-fixture tool catalog as unavailable', () => {
|
||||
const snapshot = inspectRequests(entriesOf([
|
||||
at(0, 'step/start', { turn: 1, step: 1 }),
|
||||
at(1, 'request/header', {
|
||||
reason: 'initial',
|
||||
header: {
|
||||
config: { provider: 'fake', model: 'model' },
|
||||
tools: '{{tools}}',
|
||||
},
|
||||
}),
|
||||
at(2, 'tool/call', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId: 'call-1',
|
||||
name: 'read',
|
||||
arguments: '{}',
|
||||
}),
|
||||
]))
|
||||
|
||||
expect(snapshot.callSchemas).toEqual(new Map())
|
||||
expect(snapshot.requests[0]?.prompt?.tools).toEqual([])
|
||||
})
|
||||
})
|
||||
98
packages/client/runtime/tests/session-history-source.spec.ts
Normal file
98
packages/client/runtime/tests/session-history-source.spec.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { SessionHistorySource } from '../src/client/session-history/source.ts'
|
||||
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
|
||||
import { entries, ev, plainTurn } from './event-script.ts'
|
||||
|
||||
const SID = 'history-s1' as SessionId
|
||||
|
||||
function histResponse(events: SessionEvent[], hasMore = false) {
|
||||
return Promise.resolve(ok({ events: entries(events) as never[], hasMore }))
|
||||
}
|
||||
|
||||
describe('SessionHistorySource', () => {
|
||||
it('loads every older page without changing a Chat session', async () => {
|
||||
const pages = [
|
||||
plainTurn(0, 0, '最早问', '最早答'),
|
||||
plainTurn(6, 1, '中间问', '中间答'),
|
||||
plainTurn(12, 2, '最新问', '最新答'),
|
||||
]
|
||||
const api = new FakeApiClient()
|
||||
api.onHistory = (payload) => {
|
||||
if (payload.beforeSeq === undefined) return histResponse(pages[2]!, true)
|
||||
if (payload.beforeSeq === 12) return histResponse(pages[1]!, true)
|
||||
return histResponse(pages[0]!, false)
|
||||
}
|
||||
const source = new SessionHistorySource(SID, api)
|
||||
|
||||
await source.loadAll()
|
||||
|
||||
expect(api.callsOf('session.history')).toHaveLength(3)
|
||||
expect(source.getSnapshot().hasMore).toBe(false)
|
||||
expect(source.getSnapshot().inspection.eventNodes.map(node => node.seq))
|
||||
.toEqual([1, 3, 7, 9, 13, 15])
|
||||
})
|
||||
|
||||
it('pins a lazy inspection to the entries in its source snapshot', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答'))
|
||||
const source = new SessionHistorySource(SID, api)
|
||||
await source.loadAll()
|
||||
const before = source.getSnapshot()
|
||||
|
||||
source.handleMuxFrame({
|
||||
type: 'session/event',
|
||||
sessionId: SID,
|
||||
event: ev.user(6, 'later'),
|
||||
})
|
||||
|
||||
expect(before.inspection.eventNodes.map(node => node.seq)).toEqual([1, 3])
|
||||
expect(source.getSnapshot().inspection.eventNodes.map(node => node.seq))
|
||||
.toEqual([1, 3, 6])
|
||||
})
|
||||
|
||||
it('stops loading when an older page fails to advance', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onHistory = payload => payload.beforeSeq === undefined
|
||||
? histResponse(plainTurn(6, 1, '新问', '新答'), true)
|
||||
: Promise.resolve(err({
|
||||
code: 'internal',
|
||||
message: 'page unavailable',
|
||||
details: {},
|
||||
}))
|
||||
const source = new SessionHistorySource(SID, api)
|
||||
|
||||
await source.loadAll()
|
||||
|
||||
expect(api.callsOf('session.history')).toHaveLength(2)
|
||||
expect(source.getSnapshot().hasMore).toBe(true)
|
||||
})
|
||||
|
||||
it('observes consumer cancellation between older pages', async () => {
|
||||
const middle = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
|
||||
const olderStarted = deferred<undefined>()
|
||||
const api = new FakeApiClient()
|
||||
api.onHistory = (payload) => {
|
||||
if (payload.beforeSeq === undefined) {
|
||||
return histResponse(plainTurn(12, 2, '最新问', '最新答'), true)
|
||||
}
|
||||
olderStarted.resolve(undefined)
|
||||
return middle.promise
|
||||
}
|
||||
const source = new SessionHistorySource(SID, api)
|
||||
const controller = new AbortController()
|
||||
const complete = source.loadAll(controller.signal)
|
||||
await olderStarted.promise
|
||||
controller.abort()
|
||||
middle.resolve(ok({
|
||||
events: entries(plainTurn(6, 1, '中间问', '中间答')) as never[],
|
||||
hasMore: true,
|
||||
}))
|
||||
|
||||
await complete
|
||||
|
||||
expect(api.callsOf('session.history')).toHaveLength(2)
|
||||
expect(source.getSnapshot().hasMore).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -126,6 +126,21 @@ describe('live event path', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('command lifecycle rows alone keep the composer blank (hero survives a /permission or /plan switch)', async () => {
|
||||
// A fresh session whose only window content is a command pair (plus the
|
||||
// knob events a /permission switch appends — not surface-eligible, so
|
||||
// they never become nodes) stays phase 'blank': selecting a preset from
|
||||
// the hero must not enter the conversation view.
|
||||
const { session } = await opened([])
|
||||
expect(session.getSnapshot().composerPhase).toBe('blank')
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
feed(ev.commandRun(0, 'cmd-perm', 'permission', ' danger-full-access'))
|
||||
feed(ev.commandDone(1, 'cmd-perm', 'success', 'Permission preset: danger-full-access.'))
|
||||
const snapshot = session.getSnapshot()
|
||||
expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'command', name: 'permission' })
|
||||
expect(snapshot.composerPhase).toBe('blank')
|
||||
})
|
||||
|
||||
it('accumulates chunks into partial, then finalize swaps partial out as the node lands', async () => {
|
||||
const { session } = await opened()
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
@@ -286,6 +301,32 @@ describe('prompt and cancel errors', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('rename', () => {
|
||||
it('settles the title projection cell from the unary response (higher-seq-wins vs the push frame)', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onRename = () => Promise.resolve(ok({ title: '正名', seq: 7 }))
|
||||
const result = await session.rename(' 正名 ')
|
||||
expect(result).toMatchObject({ ok: true, value: { title: '正名', seq: 7 } })
|
||||
expect(api.callsOf('session.rename')).toMatchObject([{ sessionId: SID, title: ' 正名 ' }])
|
||||
expect(session.projections.faceOf('title').getSnapshot()).toBe('正名')
|
||||
// A stale lower-seq apply (the push-frame path routes into this same
|
||||
// store) must not roll the settled value back.
|
||||
session.projections.apply('title', '旧名', 3)
|
||||
expect(session.projections.faceOf('title').getSnapshot()).toBe('正名')
|
||||
})
|
||||
|
||||
it('returns the business error untouched and folds a transport throw to internal', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onRename = () => Promise.resolve(err({ code: 'title-invalid', message: 'empty', details: { sessionId: SID } }))
|
||||
const rejected = await session.rename(' ')
|
||||
expect(rejected).toMatchObject({ ok: false, error: { code: 'title-invalid' } })
|
||||
expect(session.projections.faceOf('title').getSnapshot()).toBeUndefined()
|
||||
api.onRename = () => Promise.reject(new Error('rename transport down'))
|
||||
const folded = await session.rename('x')
|
||||
expect(folded).toMatchObject({ ok: false, error: { code: 'internal' } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('pending interactions', () => {
|
||||
it('adds approval/question on requested and removes them on resolved', async () => {
|
||||
const { session } = makeSession()
|
||||
@@ -686,6 +727,7 @@ describe('resync', () => {
|
||||
expect(snapshot.openState).toBe('open') // stale failure did not settle the fresh generation into error
|
||||
expect(snapshot.nodes.map(n => n.seq)).toEqual([7, 9])
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('run_code sub-dispatch indexing', () => {
|
||||
@@ -799,20 +841,23 @@ describe('reference stability (the memo contract)', () => {
|
||||
await session.open()
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
feed(ev.turnStart(6, 1))
|
||||
feed(ev.toolCall(7, 1, 'c1', 'echo', '{}'))
|
||||
feed(ev.stepStart(7, 1))
|
||||
feed(ev.toolCall(8, 1, 'c1', 'echo', '{}'))
|
||||
session.handleMuxEnvelope('ra' as never, { type: 'approval/requested', sessionId: SID, approvalId: 'ap1' as never, toolName: 'rm' })
|
||||
const before = session.getSnapshot()
|
||||
// A chunk storm touches partial/nodes only: runningCalls and pending must keep identity.
|
||||
feed(ev.chunkStart(8, 1))
|
||||
feed(ev.chunkText(9, 1, '与工具无关的流式'))
|
||||
// A chunk storm touches partial/nodes only: unrelated projections keep identity.
|
||||
feed(ev.chunkStart(9, 1))
|
||||
feed(ev.chunkText(10, 1, '与工具无关的流式'))
|
||||
const after = session.getSnapshot()
|
||||
expect(after).not.toBe(before)
|
||||
expect(after.runningCalls).toBe(before.runningCalls)
|
||||
expect(after.pending).toBe(before.pending)
|
||||
// And a mutation on the tracked domain swaps that array.
|
||||
feed(ev.toolResult(10, 1, 'c1', 'ECHO'))
|
||||
feed(ev.toolResult(11, 1, 'c1', 'ECHO'))
|
||||
const resolved = session.getSnapshot()
|
||||
expect(resolved.runningCalls).not.toBe(after.runningCalls)
|
||||
expect(resolved.pending).toBe(after.pending)
|
||||
feed(ev.assistant(12, 1, '完成'))
|
||||
expect(session.getSnapshot()).not.toBe(resolved)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { SessionsService } from '../src/client/sessions/service.ts'
|
||||
import { WorkspaceManager } from '../src/client/workspaces/manager.ts'
|
||||
import { WorkspaceCreateError, WorkspacesService } from '../src/client/workspaces/service.ts'
|
||||
import { DirectoryBrowseError, WorkspaceCreateError, WorkspacesService } from '../src/client/workspaces/service.ts'
|
||||
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
|
||||
|
||||
const sid = (id: string): SessionId => id as SessionId
|
||||
@@ -234,6 +234,29 @@ describe('WorkspacesService', () => {
|
||||
api.onPickDirectory = () => Promise.resolve(ok({ path: null }))
|
||||
await expect(workspaces.pickDirectory()).resolves.toBeNull()
|
||||
expect(api.callsOf('host.pickDirectory')).toEqual([{}, {}])
|
||||
api.onPickDirectory = () => Promise.resolve(err({ code: 'internal', message: 'no chooser', details: {} }))
|
||||
await expect(workspaces.pickDirectory()).rejects.toThrow(/no chooser/)
|
||||
})
|
||||
|
||||
it('passes listings and creation through the browse wire, wrapping business failures', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const workspaces = new WorkspacesService(ctx, api, new SessionsService(ctx, api))
|
||||
const listing = { path: '/home/u', home: '/home/u', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [{ name: 'p', path: '/home/u/p', hidden: false }], truncated: false }
|
||||
api.onListDirectory = () => Promise.resolve(ok(listing))
|
||||
await expect(workspaces.listDirectory()).resolves.toEqual(listing)
|
||||
await expect(workspaces.listDirectory('/home/u')).resolves.toEqual(listing)
|
||||
// The optional path is omitted from the payload, not sent as undefined.
|
||||
expect(api.callsOf('host.listDirectory')).toEqual([{}, { path: '/home/u' }])
|
||||
api.onListDirectory = () => Promise.resolve(err({ code: 'directory-unreadable', message: 'denied', details: { path: '/x' } }))
|
||||
const listFailure = workspaces.listDirectory('/x')
|
||||
await expect(listFailure).rejects.toBeInstanceOf(DirectoryBrowseError)
|
||||
await expect(listFailure).rejects.toMatchObject({ rpcError: { code: 'directory-unreadable' } })
|
||||
|
||||
await expect(workspaces.createDirectory('/home/u', 'fresh')).resolves.toBe('/home/fake/new')
|
||||
expect(api.callsOf('host.createDirectory')).toEqual([{ path: '/home/u', name: 'fresh' }])
|
||||
api.onCreateDirectory = () => Promise.resolve(err({ code: 'directory-exists', message: 'taken', details: { path: '/home/u/fresh' } }))
|
||||
await expect(workspaces.createDirectory('/home/u', 'fresh')).rejects.toMatchObject({ rpcError: { code: 'directory-exists' } })
|
||||
})
|
||||
|
||||
it('opens a filesystem path through the host without local state', async () => {
|
||||
@@ -263,3 +286,78 @@ describe('WorkspacesService', () => {
|
||||
await expect(workspaces.delete(wid('ghost'))).rejects.toThrow(/workspace-not-found: gone/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('startInitialSelection', () => {
|
||||
function bench() {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api)
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
return { api, sessions, workspaces }
|
||||
}
|
||||
|
||||
it('connects the recent Workspace blank session once baselines are ready and opens it', async () => {
|
||||
const b = bench()
|
||||
const stop = b.workspaces.startInitialSelection()
|
||||
// Nothing happens before both baselines land.
|
||||
expect(b.api.callsOf('session.create')).toHaveLength(0)
|
||||
|
||||
b.api.onWorkspaceList = () => Promise.resolve(ok({
|
||||
items: [workspace('recent', [], '2026-01-02T00:00:00.000Z')] as never[],
|
||||
}))
|
||||
b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-new') }))
|
||||
await b.workspaces.refresh()
|
||||
await b.sessions.refresh()
|
||||
// Store notifications and the connect round trip are microtask-batched.
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(b.api.callsOf('session.create')).toEqual([{ workspaceId: 'recent' }])
|
||||
expect(b.sessions.list.getSnapshot().current).toBe('s-new')
|
||||
stop()
|
||||
})
|
||||
|
||||
it('stays idle when a session is already current or no recent Workspace exists', async () => {
|
||||
const withCurrent = bench()
|
||||
withCurrent.api.onList = () => Promise.resolve(ok({
|
||||
items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }] as never[],
|
||||
}))
|
||||
await withCurrent.sessions.refresh()
|
||||
withCurrent.sessions.open(sid('s1'))
|
||||
withCurrent.api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('w1', [sid('s1')])] as never[] }))
|
||||
const stopCurrent = withCurrent.workspaces.startInitialSelection()
|
||||
await withCurrent.workspaces.refresh()
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(withCurrent.api.callsOf('session.create')).toHaveLength(0)
|
||||
stopCurrent()
|
||||
|
||||
const noRecent = bench()
|
||||
const stopEmpty = noRecent.workspaces.startInitialSelection()
|
||||
await noRecent.workspaces.refresh()
|
||||
await noRecent.sessions.refresh()
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(noRecent.api.callsOf('session.create')).toHaveLength(0)
|
||||
expect(() => noRecent.workspaces.startInitialSelection()).toThrow(/already started/)
|
||||
stopEmpty()
|
||||
})
|
||||
|
||||
it('a failed connect returns to waiting and retries on the next list change', async () => {
|
||||
const b = bench()
|
||||
b.api.onWorkspaceList = () => Promise.resolve(ok({
|
||||
items: [workspace('recent', [], '2026-01-02T00:00:00.000Z')] as never[],
|
||||
}))
|
||||
b.api.onCreate = () => Promise.resolve(err({ code: 'internal', message: 'attach exploded', details: {} }))
|
||||
const stop = b.workspaces.startInitialSelection()
|
||||
await b.workspaces.refresh()
|
||||
await b.sessions.refresh()
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(b.api.callsOf('session.create')).toHaveLength(1)
|
||||
expect(b.sessions.list.getSnapshot().current).toBeUndefined()
|
||||
|
||||
// Recovery: the next workspace-list change re-runs the reconcile.
|
||||
b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-retry') }))
|
||||
await b.workspaces.refresh()
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(b.api.callsOf('session.create')).toHaveLength(2)
|
||||
expect(b.sessions.list.getSnapshot().current).toBe('s-retry')
|
||||
stop()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -92,6 +92,14 @@ export class FixtureSession implements SessionFace {
|
||||
throw new Error(`test session "${this.sessionId}": cancel is not stubbed — supply it on the fixture's session face`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail-loud stub; supply `command` on the fixture's session face to exercise it.
|
||||
* @returns never — always throws.
|
||||
*/
|
||||
command(): never {
|
||||
throw new Error(`test session "${this.sessionId}": command is not stubbed — supply it on the fixture's session face`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail-loud stub; supply `loadOlder` on the fixture's session face to exercise it.
|
||||
* @returns never — always throws.
|
||||
@@ -99,6 +107,14 @@ export class FixtureSession implements SessionFace {
|
||||
loadOlder(): never {
|
||||
throw new Error(`test session "${this.sessionId}": loadOlder is not stubbed — supply it on the fixture's session face`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail-loud stub; supply `rename` on the fixture's session face to exercise it.
|
||||
* @returns never — always throws.
|
||||
*/
|
||||
rename(): never {
|
||||
throw new Error(`test session "${this.sessionId}": rename is not stubbed — supply it on the fixture's session face`)
|
||||
}
|
||||
}
|
||||
|
||||
/** One live test session: fixture-derived stores plus its minted scope state. */
|
||||
@@ -183,6 +199,7 @@ export class TestSessions implements ISessions {
|
||||
id,
|
||||
displayTitle: fixture.id,
|
||||
running: false,
|
||||
waitingApproval: false,
|
||||
blank: false,
|
||||
updatedAt: this.records.size + 1,
|
||||
...fixture.summary,
|
||||
@@ -220,6 +237,20 @@ export class TestSessions implements ISessions {
|
||||
await this.stabilize(() => { record.snapshot.update(mutate) })
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a session's list row (the wire-echo stand-in: title settles,
|
||||
* running flips — components subscribed via useSessions re-render).
|
||||
* @param id - session id.
|
||||
* @param patch - summary fields to merge over the row.
|
||||
*/
|
||||
async updateSummary(id: string, patch: Partial<Omit<SessionSummary, 'id'>>): Promise<void> {
|
||||
const record = this.require(id)
|
||||
record.summary = { ...record.summary, ...patch }
|
||||
await this.stabilize(() => {
|
||||
this.list.update((draft) => { draft.byId[id as SessionId] = record.summary })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the current selection (undefined = the no-session empty state).
|
||||
* @param id - session id to select, or undefined to clear.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Test-owned workspaces face: the renderer standard-kit observable plus recorded actions. */
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
IWorkspaces, SessionId, SnapshotStore, WorkspaceId, WorkspaceListState, WorkspaceView,
|
||||
DirectoryListing, IWorkspaces, SessionId, SnapshotStore, WorkspaceId, WorkspaceListState, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { workspaceListState } from './fixtures.ts'
|
||||
import type { Stabilizer } from './fixtures.ts'
|
||||
@@ -109,6 +109,48 @@ export class TestWorkspaces implements IWorkspaces {
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Browse listing (recorded). The default serves an empty home level; stub
|
||||
* to shape a tree.
|
||||
* @param path - absolute directory to list; absent lists the home level.
|
||||
* @returns the level's listing.
|
||||
*/
|
||||
async listDirectory(path?: string, signal?: AbortSignal): Promise<DirectoryListing> {
|
||||
// The signal is recorded and forwarded like the production face passes
|
||||
// it to the wire, so cancellation integration tests can observe or
|
||||
// reject on a superseded scan.
|
||||
this.calls.push({ method: 'listDirectory', args: [path, signal] })
|
||||
const stub = this.stubs.get('listDirectory')
|
||||
if (stub !== undefined) return await (stub(path, signal) as Promise<DirectoryListing>)
|
||||
// The chain runs root-to-target inclusive, per the DirectoryListing
|
||||
// contract — a bare root crumb would mislabel the level in browsers
|
||||
// driven by this double.
|
||||
return {
|
||||
path: '/home/test',
|
||||
home: '/home/test',
|
||||
crumbs: [
|
||||
{ name: '/', path: '/', hidden: false },
|
||||
{ name: 'home', path: '/home', hidden: false },
|
||||
{ name: 'test', path: '/home/test', hidden: false },
|
||||
],
|
||||
entries: [],
|
||||
truncated: false,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Browse child creation (recorded). The default joins parent and name.
|
||||
* @param path - absolute existing parent directory.
|
||||
* @param name - single path segment.
|
||||
* @returns the created directory's absolute path.
|
||||
*/
|
||||
async createDirectory(path: string, name: string): Promise<string> {
|
||||
this.calls.push({ method: 'createDirectory', args: [path, name] })
|
||||
const stub = this.stubs.get('createDirectory')
|
||||
if (stub !== undefined) return await (stub(path, name) as Promise<string>)
|
||||
return `${path}/${name}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a Workspace (recorded). The default echoes a minimal view.
|
||||
* @param workspaceId - target workspace.
|
||||
|
||||
@@ -322,6 +322,32 @@ describe('workspaces', () => {
|
||||
expect(stub).toHaveBeenCalledOnce()
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
it('records the browse calls: listDirectory serves an empty home, createDirectory joins, stubs override', async () => {
|
||||
const runtime = await runtimeWithFrame()
|
||||
// Defaults: an empty home level and parent/name joining.
|
||||
await expect(runtime.workspaces.listDirectory()).resolves.toMatchObject({ path: '/home/test', entries: [] })
|
||||
await expect(runtime.workspaces.listDirectory('/home/test')).resolves.toMatchObject({ path: '/home/test' })
|
||||
await expect(runtime.workspaces.createDirectory('/home/test', 'fresh')).resolves.toBe('/home/test/fresh')
|
||||
// The recorded signal seat mirrors the production face (undefined here;
|
||||
// cancellation tests pass and observe a real one).
|
||||
expect(runtime.workspaces.calls).toEqual([
|
||||
{ method: 'listDirectory', args: [undefined, undefined] },
|
||||
{ method: 'listDirectory', args: ['/home/test', undefined] },
|
||||
{ method: 'createDirectory', args: ['/home/test', 'fresh'] },
|
||||
])
|
||||
// Stubs replace the defaults like every sibling method.
|
||||
const listing = { path: '/x', home: '/x', crumbs: [], entries: [] }
|
||||
const listStub = vi.fn(() => Promise.resolve(listing as never))
|
||||
runtime.workspaces.stub('listDirectory', listStub)
|
||||
runtime.workspaces.stub('createDirectory', vi.fn(() => Promise.resolve('/x/made' as never)))
|
||||
const scan = new AbortController()
|
||||
await expect(runtime.workspaces.listDirectory('/x', scan.signal)).resolves.toBe(listing)
|
||||
// The stub receives the signal too, like the production face gives the wire.
|
||||
expect(listStub).toHaveBeenLastCalledWith('/x', scan.signal)
|
||||
await expect(runtime.workspaces.createDirectory('/x', 'made')).resolves.toBe('/x/made')
|
||||
await runtime.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('feature mount and disposal', () => {
|
||||
@@ -442,7 +468,9 @@ describe('fixture session face', () => {
|
||||
const bare = runtime.sessions.behavior('s1')
|
||||
expect(() => bare.prompt()).toThrow(/prompt is not stubbed/)
|
||||
expect(() => bare.cancel()).toThrow(/cancel is not stubbed/)
|
||||
expect(() => bare.command()).toThrow(/command is not stubbed/)
|
||||
expect(() => bare.loadOlder()).toThrow(/loadOlder is not stubbed/)
|
||||
expect(() => bare.rename()).toThrow(/rename is not stubbed/)
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* lightningcss inside the bundle: importing `x.module.css` yields the
|
||||
* hashed class map, and the css text auto-injects a <style data-plugin="<id>">
|
||||
* tag at factory execution (the loader removes plugin-owned tags on unload).
|
||||
* The virtual loader registers each real stylesheet as a watch dependency.
|
||||
*/
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { basename, dirname, resolve as resolvePath } from 'node:path'
|
||||
@@ -127,6 +128,7 @@ export function clientBundle(id: string, libEntry: readonly string[]): UserConfi
|
||||
async load(virtualId: string) {
|
||||
if (!virtualId.startsWith(CSS_VIRTUAL_PREFIX)) return null
|
||||
const fileId = virtualId.slice(CSS_VIRTUAL_PREFIX.length, -CSS_VIRTUAL_SUFFIX.length)
|
||||
this.addWatchFile(fileId)
|
||||
const source = await readFile(fileId)
|
||||
const { code, exports: cssExports } = transform({
|
||||
filename: fileId,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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
|
||||
README.md: 17bc4edd7d002d6bba4470c9418a9179b2cb131b
|
||||
README.zh.md: 1291556409b993aa893e102386f75c45bb195adf
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-command/README.md
|
||||
README.md: 64d06f1d9baae98ef31c7e2a62242eda6a8174da
|
||||
README.zh.md: 0cec3b8c8f7baf2fb1c408bccfe8937aa78e4bf9
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
Client command surface (`ctx.command`): the session-keyed command-directory cache, the `/` command source with matchSpace/matchEnter adjudication hooks, three-kind dispatch (execute / popupSelect / leadingInput), and the popupSelect registration face for business packages. Contract: the [web command surfaces Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md).
|
||||
|
||||
`src/client/contract.ts` is the frozen business face: `CommandServiceContract.register(name, spec)` is everything a business package consumes; `CommandUiSpec{options, onSelect}` keeps popup data self-served — the shell component is this package's and business never sees it. Command kinds derive per dispatch, never per registration: a host descriptor with `input` is leadingInput, a registered `CommandUiSpec` is popupSelect, everything else is execute.
|
||||
`src/client/contract.ts` is the frozen business face: `CommandServiceContract.register(name, spec)` and `decorate(name, spec)` are everything a business package consumes; `CommandUiSpec{options, onSelect}` keeps popup data self-served — the shell component is this package's and business never sees it. A contribution is a client-owned command (a host-name collision fails loud); a decoration hangs a bare-invocation popup on an EXISTING host command — the host keeps its catalog row, argument claim (space / argued enter), and lifecycle logging, and a decorated name with no host row in the session's directory simply never fires. Command kinds derive per dispatch, never per registration: a host descriptor with `input` is leadingInput, a registered `CommandUiSpec` is popupSelect, everything else is execute.
|
||||
|
||||
`CommandDirectory` (`src/client/directory.ts`) is the one wire-derived cache, keyed by session: every session is agent-backed, so `command.list({sessionId})` is the only address shape and the source's scope-birth `warm` hook prewarms the session's entry. Entries are soft-invalidated by the `commands/changed` typed event (old snapshot serves while the repull flies), hard-invalidated by `connection/reset`, epoch-guarded so a superseded pull can never overwrite a newer one. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure — a `/` line is never silently downgraded to a plain prompt.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
客户端命令业务面(`ctx.command`):以会话为 key 的命令目录缓存、带 matchSpace/matchEnter 裁决钩子的 `/` 命令 source、三型派发(execute/popupSelect/leadingInput),以及面向业务包的 popupSelect 注册面。契约:[Web 命令业务面 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md)。
|
||||
|
||||
`src/client/contract.ts` 是冻结的业务表层:`CommandServiceContract.register(name, spec)` 是业务包消费的全部内容;`CommandUiSpec{options, onSelect}` 让 popup 数据自给自足——壳组件归本包所有,业务永远见不到它。命令三型按每次派发派生,绝不在注册时定型:带 `input` 的 host descriptor 是 leadingInput,注册了 `CommandUiSpec` 的是 popupSelect,其余全部是 execute。
|
||||
`src/client/contract.ts` 是冻结的业务表层:`CommandServiceContract.register(name, spec)` 与 `decorate(name, spec)` 是业务包消费的全部内容;`CommandUiSpec{options, onSelect}` 让 popup 数据自给自足——壳组件归本包所有,业务永远见不到它。contribution 是 client 自有命令(与 host 同名碰撞即 fail-loud);decoration(装饰)则把裸调用 popup 挂在**已存在的** host 命令上——host 保留目录行、带参 claim(space / 带参 enter)与生命周期记账,被装饰的名字若在会话目录中无 host 行则装饰永不触发。命令三型按每次派发派生,绝不在注册时定型:带 `input` 的 host descriptor 是 leadingInput,注册了 `CommandUiSpec` 的是 popupSelect,其余全部是 execute。
|
||||
|
||||
`CommandDirectory`(`src/client/directory.ts`)是唯一的 wire 派生缓存,以会话为 key:每个会话恒为 agent-backed,因此 `command.list({sessionId})` 是唯一的寻址形状,source 的 scope 出生 `warm` 钩子会预热该会话的缓存项。缓存项由 `commands/changed` 类型化事件软失效(重拉在途期间旧快照继续服务),由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝——`/` 开头的一行绝不会被静默降级为普通提示词。
|
||||
|
||||
|
||||
@@ -13,8 +13,10 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 220px;
|
||||
/* Height cap: the 320px design maximum, clamped at runtime to the space
|
||||
* above the composer (inline max-height set in PopupSelectView.tsx). */
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
overflow: hidden;
|
||||
/* Elevated surface: the scrollbar thumb takes the l2 elevation tokens
|
||||
(see ui-theme styles/scrollbar.css for the rebinding contract). */
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
||||
@@ -26,6 +28,13 @@
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.viewport {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -34,11 +43,11 @@
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--dsw-alias-text-primary);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.rowActive {
|
||||
background: var(--dsw-alias-fill-hover);
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.label {
|
||||
@@ -50,19 +59,20 @@
|
||||
|
||||
.detail {
|
||||
font-size: 12px;
|
||||
color: var(--dsw-alias-text-tertiary);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.check {
|
||||
display: inline-flex;
|
||||
color: var(--dsw-alias-text-secondary);
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--dsw-alias-text-tertiary);
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.search {
|
||||
@@ -72,7 +82,7 @@
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
color: var(--dsw-alias-text-primary);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@@ -97,6 +107,6 @@
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
font-size: 12px;
|
||||
color: var(--dsw-alias-text-primary);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -3,19 +3,23 @@
|
||||
* store into the conversation.input.overlay anchor. Unlike the slash menu
|
||||
* (combobox — textarea keeps focus), this shell HOLDS focus while open: the
|
||||
* inner search input takes focus, plain typing filters the loaded options
|
||||
* locally, Enter/↑↓ drive the filtered highlight, Escape dismisses back to
|
||||
* the composer, and ←→ keep the search input's native caret. Any pointer
|
||||
* interaction outside the box dismisses (the click's own target takes
|
||||
* focus). Closed state renders null; the overlay slot stays mounted.
|
||||
* locally, Enter/↑↓ drive the filtered highlight (scrolled into view), Escape
|
||||
* dismisses back to the composer, and ←→ keep the search input's native
|
||||
* caret. Any pointer interaction outside the box dismisses (the click's own
|
||||
* target takes focus). Closed state renders null; the overlay slot stays
|
||||
* mounted. The card height clamps to the space above the composer.
|
||||
*/
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconCheckOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconCheckOutline16, useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { filterOptions } from './popup.ts'
|
||||
import type { PopupSelectController } from './popup.ts'
|
||||
import css from './PopupSelectView.module.css'
|
||||
|
||||
/** Design cap on the card height (same MenuDropdown family as the slash menu). */
|
||||
const MAX_HEIGHT = 320
|
||||
|
||||
/** Injected business face of the popupSelect overlay entry. */
|
||||
export interface PopupSelectInjected {
|
||||
/** The session's shell controller (state store + verbs; the view never touches the open-context type). */
|
||||
@@ -34,6 +38,17 @@ export function PopupSelectView({ popup }: PopupSelectInjected) {
|
||||
)
|
||||
const cardRef = useRef<HTMLDivElement>(null)
|
||||
const searchRef = useRef<HTMLInputElement>(null)
|
||||
// The card is bottom-anchored above the composer; clamp the design cap to
|
||||
// the space above it, re-measured on every store update.
|
||||
const maxHeight = useAnchoredMaxHeight(cardRef, MAX_HEIGHT, state)
|
||||
const active = state.open ? state.active : null
|
||||
|
||||
// The search input keeps focus while arrows move a virtual highlight, so
|
||||
// the browser never scrolls the active row into view — do it here.
|
||||
useEffect(() => {
|
||||
if (active === null) return
|
||||
cardRef.current?.querySelector('[aria-selected="true"]')?.scrollIntoView({ block: 'nearest' })
|
||||
}, [active])
|
||||
|
||||
// Focus ownership: the search input grabs on open (the design's
|
||||
// transient-layer rule), and ANY outside pointer interaction dismisses —
|
||||
@@ -42,7 +57,6 @@ export function PopupSelectView({ popup }: PopupSelectInjected) {
|
||||
// takes focus naturally, so no focusComposer here.
|
||||
useEffect(() => {
|
||||
if (!state.open) return
|
||||
searchRef.current?.focus()
|
||||
const onPointerDown = (ev: PointerEvent): void => {
|
||||
if (cardRef.current !== null && ev.target instanceof Node && cardRef.current.contains(ev.target)) return
|
||||
popup.dismiss()
|
||||
@@ -51,6 +65,11 @@ export function PopupSelectView({ popup }: PopupSelectInjected) {
|
||||
return () => { document.removeEventListener('pointerdown', onPointerDown, true) }
|
||||
}, [state.open, popup])
|
||||
|
||||
// Focus the search input after it mounts (separate effect so the ref is populated).
|
||||
useEffect(() => {
|
||||
if (state.open) searchRef.current?.focus()
|
||||
}, [state.open])
|
||||
|
||||
if (!state.open) return null
|
||||
|
||||
const rows = filterOptions(state.options, state.search)
|
||||
@@ -83,6 +102,7 @@ export function PopupSelectView({ popup }: PopupSelectInjected) {
|
||||
<div
|
||||
ref={cardRef}
|
||||
className={css.card}
|
||||
style={{ maxHeight }}
|
||||
aria-label={`/${String(state.command)} options`}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
@@ -108,7 +128,7 @@ export function PopupSelectView({ popup }: PopupSelectInjected) {
|
||||
{state.submitting && <div className={css.status}>Applying…</div>}
|
||||
{state.status === 'ready' && rows.length === 0 && <div className={css.status}>No options</div>}
|
||||
{state.status === 'ready' && (
|
||||
<div role="listbox" aria-label={`/${String(state.command)} matches`}>
|
||||
<div role="listbox" aria-label={`/${String(state.command)} matches`} className={css.viewport}>
|
||||
{rows.map((option, index) => (
|
||||
<div
|
||||
key={option.id}
|
||||
|
||||
@@ -43,6 +43,24 @@ export interface CommandContribution {
|
||||
readonly ui: CommandUiSpec
|
||||
}
|
||||
|
||||
/**
|
||||
* A UI decoration hung on one HOST command: what its BARE invocation does on
|
||||
* this client. Not a second command — the host command keeps its catalog
|
||||
* row, its argument claim (space / argued enter), and its lifecycle logging;
|
||||
* the decoration replaces only the bare menu-pick/enter with a popup whose
|
||||
* onSelect typically submits a completed line back through command.execute.
|
||||
* A decoration never manufactures a row: a name with no host catalog entry
|
||||
* in the session's directory simply never reaches the decoration.
|
||||
*/
|
||||
export interface CommandDecoration {
|
||||
/** The HOST command name this decorates (without the leading slash). */
|
||||
readonly name: string
|
||||
/** Capability filter, called with a fresh projection per bare invocation. */
|
||||
available(session: ClientSessionContext): boolean
|
||||
/** The bare-invocation UI (this phase: popupSelect only). */
|
||||
readonly ui: CommandUiSpec
|
||||
}
|
||||
|
||||
/** The `ctx.command` service face visible to business packages. */
|
||||
export interface CommandServiceContract {
|
||||
/**
|
||||
@@ -50,6 +68,11 @@ export interface CommandServiceContract {
|
||||
* names throw at registration.
|
||||
*/
|
||||
register(contribution: CommandContribution): () => void
|
||||
/**
|
||||
* Hang a bare-invocation decoration on one host command; effect disposer.
|
||||
* Duplicate names throw at registration.
|
||||
*/
|
||||
decorate(decoration: CommandDecoration): () => void
|
||||
/** Resolve the per-session popup controller for one session scope (wiring/overlay layer). */
|
||||
popupFor(actx: ClientContext): unknown
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ export { filterOptions, PopupSelectController } from './popup.ts'
|
||||
export type { PopupSelectDeps, PopupSpec, PopupState, TokenSegment } from './popup.ts'
|
||||
export type { PopupSelectInjected } from './PopupSelectView.tsx'
|
||||
export type {
|
||||
CommandContribution, CommandServiceContract, CommandUiSpec, SelectOption,
|
||||
CommandContribution, CommandDecoration, CommandServiceContract, CommandUiSpec, SelectOption,
|
||||
} from './contract.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
|
||||
@@ -14,7 +14,7 @@ import type {
|
||||
CandidateRequest, ClientSessionContext, CommandClaim, PickOutcome, SlashCandidate, SlashPick,
|
||||
SubmitOutcome,
|
||||
} from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import type { CommandContribution, CommandServiceContract } from './contract.ts'
|
||||
import type { CommandContribution, CommandDecoration, CommandServiceContract } from './contract.ts'
|
||||
import type { CommandDescriptor } from './directory.ts'
|
||||
import { CommandDirectory } from './directory.ts'
|
||||
import { PopupSelectController } from './popup.ts'
|
||||
@@ -23,6 +23,7 @@ import type { TokenSegment } from './popup.ts'
|
||||
/** Live mutable state in one holder (service methods run behind the caller-ctx tracker). */
|
||||
interface LiveState {
|
||||
readonly contributions: Map<string, CommandContribution>
|
||||
readonly decorations: Map<string, CommandDecoration>
|
||||
readonly popups: Map<SessionId, PopupSelectController<ClientSessionContext>>
|
||||
}
|
||||
|
||||
@@ -31,7 +32,7 @@ export class CommandService extends Service implements CommandServiceContract {
|
||||
static inject = ['slash', 'sessions', 'connection']
|
||||
|
||||
private readonly directory: CommandDirectory
|
||||
private readonly live: LiveState = { contributions: new Map(), popups: new Map() }
|
||||
private readonly live: LiveState = { contributions: new Map(), decorations: new Map(), popups: new Map() }
|
||||
|
||||
/**
|
||||
* @param ctx - owning root context (plugin fiber; the service registers
|
||||
@@ -79,6 +80,24 @@ export class CommandService extends Service implements CommandServiceContract {
|
||||
return () => { void dispose() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Hang a bare-invocation decoration on one host command; effect disposer
|
||||
* (rides the caller's fiber). Duplicate names throw.
|
||||
* @param decoration - host command name + availability + popup spec.
|
||||
* @returns the disposer removing the registration.
|
||||
*/
|
||||
decorate(decoration: CommandDecoration): () => void {
|
||||
const dispose = this.ctx.effect(() => {
|
||||
const { decorations } = this.live
|
||||
if (decorations.has(decoration.name)) {
|
||||
throw new Error(`ui-command: duplicate decoration for /${decoration.name}`)
|
||||
}
|
||||
decorations.set(decoration.name, decoration)
|
||||
return () => { decorations.delete(decoration.name) }
|
||||
}, 'command.decorate()')
|
||||
return () => { void dispose() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the per-session popup controller (lazy; dies with the session
|
||||
* scope). The controller's consume callback dispatches the scoped
|
||||
@@ -148,16 +167,24 @@ export class CommandService extends Service implements CommandServiceContract {
|
||||
.filter(c => req.position === 'leading' || c.hint === undefined)
|
||||
}
|
||||
|
||||
/** Decision table, menu column: contribution → popup; host input → claim; host bare → detached execute. */
|
||||
/** Decision table, menu column: contribution/decorated-host → popup; host input → claim; host bare → detached execute. */
|
||||
private dispatch(pick: SlashPick): PickOutcome {
|
||||
const name = pick.candidate.name
|
||||
const contribution = this.live.contributions.get(name)
|
||||
if (contribution !== undefined && contribution.available(pick.session)) {
|
||||
this.openPopup(contribution, pick.session, { via: 'menu', span: pick.span })
|
||||
this.openPopup(name, contribution.ui, pick.session, { via: 'menu', span: pick.span })
|
||||
return 'handled'
|
||||
}
|
||||
const desc = this.directory.resolve(pick.session.sessionId, name)
|
||||
if (desc === undefined) return undefined // snapshot swapped between menu and pick → miss
|
||||
// A decoration replaces the HOST row's bare invocation with its popup;
|
||||
// it decorates only a resolvable host command (checked above), never
|
||||
// manufactures one, and never touches the argument claim below.
|
||||
const decoration = this.live.decorations.get(name)
|
||||
if (decoration !== undefined && decoration.available(pick.session)) {
|
||||
this.openPopup(name, decoration.ui, pick.session, { via: 'menu', span: pick.span })
|
||||
return 'handled'
|
||||
}
|
||||
if (desc.input !== undefined) return { claim: this.leadingClaim(desc, pick.session) }
|
||||
// Menu-pick execute consumes the trigger span before the detached run
|
||||
// (scoped event; the input owns the CAS guard).
|
||||
@@ -193,12 +220,21 @@ export class CommandService extends Service implements CommandServiceContract {
|
||||
const contribution = this.live.contributions.get(name)
|
||||
if (contribution !== undefined && contribution.available(session)) {
|
||||
if (!bare) return undefined
|
||||
this.openPopup(contribution, session, { via: 'enter', token })
|
||||
this.openPopup(name, contribution.ui, session, { via: 'enter', token })
|
||||
return 'handled'
|
||||
}
|
||||
await this.directory.ensureReady(session.sessionId, signal)
|
||||
const desc = this.directory.resolve(session.sessionId, name)
|
||||
if (desc === undefined) return undefined
|
||||
// Bare enter on a decorated host command opens its popup; an argued line
|
||||
// never consults the decoration (the claim/detached paths below own it).
|
||||
if (bare) {
|
||||
const decoration = this.live.decorations.get(name)
|
||||
if (decoration !== undefined && decoration.available(session)) {
|
||||
this.openPopup(name, decoration.ui, session, { via: 'enter', token })
|
||||
return 'handled'
|
||||
}
|
||||
}
|
||||
if (desc.input !== undefined) return { claim: this.leadingClaim(desc, session) }
|
||||
if (!bare) return undefined
|
||||
this.consumeVia(session.sessionId, { via: 'enter', token })
|
||||
@@ -206,15 +242,16 @@ export class CommandService extends Service implements CommandServiceContract {
|
||||
return 'handled'
|
||||
}
|
||||
|
||||
/** Open the session's popup for one contribution (menu pick / bare enter). */
|
||||
/** Open the session's popup for one contribution or decoration (menu pick / bare enter). */
|
||||
private openPopup(
|
||||
contribution: CommandContribution,
|
||||
name: string,
|
||||
ui: CommandContribution['ui'],
|
||||
session: ClientSessionContext,
|
||||
segment: TokenSegment,
|
||||
): void {
|
||||
const actx = this.scopeFor(session.sessionId)
|
||||
if (actx === undefined) return
|
||||
this.popupFor(actx).open(contribution.name, contribution.ui, session, segment)
|
||||
this.popupFor(actx).open(name, ui, session, segment)
|
||||
}
|
||||
|
||||
/** Build the leadingInput claim: token `/name ` + the command.execute submit transaction. */
|
||||
|
||||
@@ -4,17 +4,28 @@
|
||||
* focus on open and plain typing filters locally, ↑↓ move the filtered
|
||||
* highlight while ←→ stay native to the input, Enter selects single-flight,
|
||||
* Escape dismisses back through focusComposer, outside pointerdown dismisses
|
||||
* plainly, and the submitting/failed states render pending text and a
|
||||
* working retry button.
|
||||
* plainly, the submitting/failed states render pending text and a working
|
||||
* retry button, the highlighted row scrolls into view, and the card height
|
||||
* clamps to the space above the composer.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import type { SelectOption } from '../src/client/contract.ts'
|
||||
import type { PopupSpec, TokenSegment } from '../src/client/popup.ts'
|
||||
import { PopupSelectController } from '../src/client/popup.ts'
|
||||
import { PopupSelectView } from '../src/client/PopupSelectView.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
// jsdom has no scrollIntoView; the view calls it on the highlighted row.
|
||||
const scrollIntoView = vi.fn()
|
||||
beforeEach(() => {
|
||||
Element.prototype.scrollIntoView = scrollIntoView
|
||||
scrollIntoView.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
const OPTIONS: SelectOption[] = [
|
||||
{ id: 'dark', label: 'Dark' },
|
||||
@@ -87,6 +98,27 @@ describe('PopupSelectView', () => {
|
||||
expect(fireEvent.keyDown(search, { key: 'ArrowRight' })).toBe(true)
|
||||
})
|
||||
|
||||
it('scrolls the highlighted row into view when the highlight moves', async () => {
|
||||
const { search } = await mountOpen()
|
||||
scrollIntoView.mockClear()
|
||||
act(() => { fireEvent.keyDown(search, { key: 'ArrowDown' }) })
|
||||
const options = screen.getAllByRole('option')
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' })
|
||||
expect(scrollIntoView.mock.instances.at(-1)).toBe(options[1])
|
||||
})
|
||||
|
||||
it('caps the card height at the design maximum when the composer sits low enough', async () => {
|
||||
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ bottom: 800 } as DOMRect)
|
||||
await mountOpen()
|
||||
expect(screen.getByLabelText('/theme options').style.maxHeight).toBe('320px')
|
||||
})
|
||||
|
||||
it('clamps the card height to the space above the composer minus the safe margin', async () => {
|
||||
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ bottom: 200 } as DOMRect)
|
||||
await mountOpen()
|
||||
expect(screen.getByLabelText('/theme options').style.maxHeight).toBe('188px')
|
||||
})
|
||||
|
||||
it('Enter selects the highlighted row: onSelect, consume, close, focusComposer', async () => {
|
||||
const seen: Array<{ option: SelectOption; context: string }> = []
|
||||
const { view, search, consume, focusComposer } = await mountOpen({
|
||||
|
||||
@@ -12,7 +12,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { createScope, scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ClientSessionContext, ConsumeTokenRequest, SlashPick, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import type { CommandContribution, CommandUiSpec, SelectOption } from '../src/client/contract.ts'
|
||||
import type { CommandContribution, CommandDecoration, CommandUiSpec, SelectOption } from '../src/client/contract.ts'
|
||||
import type { CommandDescriptor } from '../src/client/directory.ts'
|
||||
import { CommandService } from '../src/client/service.ts'
|
||||
|
||||
@@ -197,6 +197,68 @@ describe('candidates', () => {
|
||||
command.register(themeContribution({ name: 'plan' }))
|
||||
await expect(source.candidates(proj('s1'), req(''))).rejects.toThrow('collides with a host command')
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('decorations (bare-invocation UI on host commands)', () => {
|
||||
const goalDecoration = (over: Partial<CommandDecoration> = {}): CommandDecoration => ({
|
||||
name: 'goal',
|
||||
available: () => true,
|
||||
ui: themeUi(),
|
||||
...over,
|
||||
})
|
||||
|
||||
it('adds no catalog row: the host row stands alone', async () => {
|
||||
const { command, source } = await bench()
|
||||
command.decorate(goalDecoration())
|
||||
const names = (await source.candidates(proj('s1'), req(''))).map(c => c.name)
|
||||
expect(names).toEqual(['plan', 'goal'])
|
||||
})
|
||||
|
||||
it('bare enter opens the popup; an argued line never consults the decoration (host claim)', async () => {
|
||||
const { command, source, mint, warm } = await bench()
|
||||
command.decorate(goalDecoration())
|
||||
const scope = mint('s1')
|
||||
await warm(proj('s1'))
|
||||
expect(await source.matchEnter!(proj('s1'), '/goal', new AbortController().signal)).toBe('handled')
|
||||
expect(command.popupFor(scope.ctx).state.getSnapshot()).toMatchObject({ open: true, command: 'goal' })
|
||||
const argued = await source.matchEnter!(proj('s1'), '/goal ship it', new AbortController().signal)
|
||||
if (argued === undefined || argued === 'handled' || !('claim' in argued)) throw new Error('expected the host claim')
|
||||
expect(argued.claim.token).toBe('/goal ')
|
||||
})
|
||||
|
||||
it('space never consults the decoration (host claim)', async () => {
|
||||
const { command, source, warm } = await bench()
|
||||
command.decorate(goalDecoration())
|
||||
await warm(proj('s1'))
|
||||
const outcome = source.matchSpace!(proj('s1'), '/goal')
|
||||
if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected the host claim')
|
||||
expect(outcome.claim.token).toBe('/goal ')
|
||||
})
|
||||
|
||||
it('a decoration with no host row never fires (bare enter misses; menu pick misses)', async () => {
|
||||
const { command, source, mint, warm } = await bench()
|
||||
command.decorate(goalDecoration({ name: 'phantom' }))
|
||||
const scope = mint('s1')
|
||||
await warm(proj('s1'))
|
||||
expect(await source.matchEnter!(proj('s1'), '/phantom', new AbortController().signal)).toBeUndefined()
|
||||
expect(menuPick(source, 'phantom', proj('s1'))).toBeUndefined()
|
||||
expect(command.popupFor(scope.ctx).state.getSnapshot().open).toBe(false)
|
||||
})
|
||||
|
||||
it('an unavailable decoration falls through to the host bare path (detached execute)', async () => {
|
||||
const { command, source, warm, executeCalls } = await bench()
|
||||
command.decorate(goalDecoration({ name: 'plan', available: () => false }))
|
||||
await warm(proj('s1'))
|
||||
expect(await source.matchEnter!(proj('s1'), '/plan', new AbortController().signal)).toBe('handled')
|
||||
expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan' }])
|
||||
})
|
||||
|
||||
it('duplicate decoration names fail loud', async () => {
|
||||
const { command } = await bench()
|
||||
command.decorate(goalDecoration())
|
||||
expect(() => { command.decorate(goalDecoration()) }).toThrow('duplicate decoration for /goal')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dispatch (menu column)', () => {
|
||||
|
||||
@@ -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: 85cf040a48cf43b6ee6a8978ad7110ecdffb4051
|
||||
README.zh.md: 305258e2861fb17966050e295a5b980067a59a2d
|
||||
README.md: 5e24e4aad5154430fa48c80eb439694005df7c6f
|
||||
README.zh.md: 89a34041e156e137d966bdafbc92d86477df166e
|
||||
|
||||
@@ -2,21 +2,25 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, stats line, per-tool row slot with a bash sample registrant and the todo row), input dock (queue rows plus the todo plan strip), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares).
|
||||
Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, per-tool row slot with a bash sample registrant and the todo row), composer dock (session stats sticky with the input), input dock (queue rows plus the todo plan strip), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares).
|
||||
|
||||
The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store.
|
||||
The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header occupies the top as ordinary column chrome; beneath it a scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host.
|
||||
|
||||
The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: <active id>`), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves.
|
||||
|
||||
Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The sidebar mirrors the blocked state through the manager-tracked `waitingApproval` list bit (lit for uninstantiated sessions too), which outranks the running ring until the question resolves. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels (the `/permission` popup's display transform twin), and a pick submits the `/permission <preset>` command line through the bar's injected `command` callback.
|
||||
|
||||
Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file with the host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering.
|
||||
|
||||
A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed for this intent alone; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)).
|
||||
|
||||
Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders).
|
||||
|
||||
The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`<done>/<total> 已完成 · <active item>` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1` — above the queue rows — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and collapses to a header of title plus `"<done>/<total> tasks · <n> in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.
|
||||
The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`<done>/<total> 已完成 · <active item>` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1` — above the queue rows — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus `"<done>/<total> tasks · <n> in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.
|
||||
|
||||
Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks.
|
||||
|
||||
The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). The resident no-session shell uses `DisabledInputBar` and therefore dispatches no session-scoped control seats.
|
||||
The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `command.hint` locale namespace this package registers and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The resident no-session shell uses `DisabledInputBar` and therefore dispatches no session-scoped control seats.
|
||||
|
||||
`src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath).
|
||||
|
||||
@@ -31,8 +35,8 @@ None; this package neither assembles nor sends a provider request.
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **The stats line has no duration segment** — assistant `usage` carries token accounting only; elapsed-time needs a host data source.
|
||||
- **Details panel is the minimal form** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred.
|
||||
- **Assistant footer extensions (IconActions row, per-message paging) are reserved slots** — drawn in the design, not implemented.
|
||||
- **Details panel is the minimal form and currently has no entry point** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred. Tool rows stopped being details-panel click targets and nothing replaced that gesture, so `ChatViewInjected.openDetails` is implemented but uncalled and the panel (including its terminal card) is unreachable in the assembled application; its rendering stays covered by mounting it with a selection directly.
|
||||
- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized IconActions row (copy / branch / clock) ships; branch remains a chrome stub.
|
||||
- **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export.
|
||||
- **Approval cards are display-only placeholders** — question requests answer through the composer chain (ui-question), while web-side approval answering is the P-II approvals project.
|
||||
- **The approval panel's "Always allow this type" is deferred** — durable grants need a grant-storage design; only allow-once/reject answer today.
|
||||
- **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline.
|
||||
|
||||
@@ -2,21 +2,25 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、统计行、逐工具行 slot 及一个 bash 示例注册方与 todo 行)、输入区 dock(队列行加 todo 计划条)、最小详情面板、按 scope 寻址的 ConversationService。契约:api-contracts v3 §7 加 slot 终端设计(store seat/props share)。
|
||||
会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、逐工具行 slot 及一个 bash 示例注册方与 todo 行)、编辑器 dock(与输入区一同 sticky 的会话统计行)、输入区 dock(队列行加 todo 计划条)、最小详情面板、按 scope 寻址的 ConversationService。契约:api-contracts v3 §7 加 slot 终端设计(store seat/props share)。
|
||||
|
||||
常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。
|
||||
常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段会话标题栏以普通列 chrome 占据顶部;其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。
|
||||
|
||||
视图环本身就是 slot:会话注册声明 `'conversation.view'` 列表 slot(Session scope),并将其列在 `children` 表中;ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: <active id>`);视图标签页从环账本的注册选项(`id`/`order`/`label`)投影而来。聊天视图是该包自身的环配置项;其他插件(ui-trajectory)通过普通的 `ctx.slots.register` 贡献标签页。先前包内的视图注册表(`registerView`/`ViewEntry`/`ConversationViewMap` 及 chrome 附加表)已退役,逐视图 chrome 则被拆入视图组件自身。
|
||||
|
||||
通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击后通过宿主操作系统的默认应用打开文件(`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行)。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。
|
||||
|
||||
声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出只对该意图开放;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。
|
||||
|
||||
工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openFile`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。
|
||||
|
||||
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: -1` 占用 `'conversation.input.dock'` 列表 slot(位于队列行之上),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏,折叠时收成标题加 `"<已完成>/<总数> tasks · <n> in progress"` 的表头(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。
|
||||
审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位(未实例化会话同样点亮)镜像该阻塞状态,其优先级高于运行中圆环,直至问题解决。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,kebab-case 预设名渲染为 Title Case 标签(与 `/permission` popup 的显示变换孪生),选中会经由输入栏注入的 `command` 回调提交 `/permission <preset>` 命令行。
|
||||
|
||||
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: -1` 占用 `'conversation.input.dock'` 列表 slot(位于队列行之上),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · <n> in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。
|
||||
|
||||
逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。
|
||||
|
||||
输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。常驻无会话壳使用 `DisabledInputBar`,因此不会分发任何会话作用域的控件 seat。
|
||||
输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `command.hint` locale 命名空间本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。常驻无会话壳使用 `DisabledInputBar`,因此不会分发任何会话作用域的控件 seat。
|
||||
|
||||
`src/client/` 按未来的包拆分组织:`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明 + 组合后的 slot props,包括工具行契约、`views.ts` 共享原语、`tool-call-model.ts`);`skeleton/`、`chat/` 和 `toolviews/`(示例注册方)领域目录只导入 contract 文件,彼此绝不导入;`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply`/`inject`、两个服务类和 `contract/` 类型家族;实现组件(骨架、聊天行)与 store factory 保持内部状态,只能通过 apply 的 slot 注册到达页面(测试通过 `./src/*` 子路径获取它们)。
|
||||
|
||||
@@ -31,8 +35,8 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **统计行没有耗时区段**:assistant `usage` 只携带 token 计数;耗时需要主机数据源。
|
||||
- **详情面板是最小形态**:以原始形式显示已选择调用的参数/结果;Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。
|
||||
- **assistant footer 扩展(IconActions 行、逐消息分页)是预留 slot**:设计中已有图稿,尚未实现。
|
||||
- **详情面板是最小形态,且当前没有入口**:以原始形式显示已选择调用的参数/结果;Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。工具行已不再是详情面板的点击目标,且没有任何手势接替它,因此 `ChatViewInjected.openDetails` 虽已实现却无人调用,该面板(含其终端卡片)在组装后的应用中不可达;其渲染仍由直接以选中态挂载它来覆盖。
|
||||
- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的 IconActions 行(复制/分支/时钟)已落地;分支仍是 chrome stub。
|
||||
- **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。
|
||||
- **审批卡片只是只读占位符**:问题请求通过编辑器链回答(ui-question),Web 侧审批回答属于 P-II 审批项目。
|
||||
- **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。
|
||||
- **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
"clsx": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@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",
|
||||
@@ -48,15 +49,18 @@
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-plan-mode": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-todo": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-permission": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-todo": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0"
|
||||
|
||||
@@ -3,9 +3,12 @@ import type { Context } from 'cordis'
|
||||
import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { ViewTab } from './contract/views.ts'
|
||||
import type {
|
||||
ChatViewInjected, ComposerBarInjected, ConversationInjected, ConversationSessionInjected, DetailsInjected,
|
||||
ApprovalWait, ChatViewInjected, ComposerBarInjected, ComposerChainProps, ConversationInjected,
|
||||
ConversationSessionInjected, DetailsInjected,
|
||||
} from './contract/slots.ts'
|
||||
import { resolveToolPath } from './contract/tool-call-model.ts'
|
||||
import { createChatStore } from './stores.ts'
|
||||
@@ -14,7 +17,9 @@ import type { IConversation } from './service.ts'
|
||||
import { InputHub } from './input/hub.ts'
|
||||
import { InputBar } from './skeleton/InputBar.tsx'
|
||||
import { ChatView } from './chat/ChatView.tsx'
|
||||
import { StatsLine } from './chat/StatsLine.tsx'
|
||||
import { bashToolviewSample } from './toolviews/bash-sample.tsx'
|
||||
import { ApprovalPanel } from './skeleton/ApprovalPanel.tsx'
|
||||
import { todoToolview } from './toolviews/todo-row.tsx'
|
||||
import { todoDockEntry } from './skeleton/TodoPanel.tsx'
|
||||
import { queueDockEntry } from './queue/QueueDock.tsx'
|
||||
@@ -23,7 +28,7 @@ import { ConversationSession } from './skeleton/ConversationSession.tsx'
|
||||
import { DetailsPanel } from './skeleton/DetailsPanel.tsx'
|
||||
|
||||
/** Services required by the conversation plugin. */
|
||||
export const inject = ['slots', 'layout', 'sessions', 'workspaces']
|
||||
export const inject = ['slots', 'layout', 'sessions', 'workspaces', 'locale']
|
||||
|
||||
/** Resolve the session-scoped conversation face (scope-addressed send/cancel), failing loud. */
|
||||
function scopedConversation(sessions: ISessions, id: SessionId): IConversation {
|
||||
@@ -34,6 +39,11 @@ function scopedConversation(sessions: ISessions, id: SessionId): IConversation {
|
||||
return conversation
|
||||
}
|
||||
|
||||
/** Chain routing: claim the composer while an approval wait is pending (pure — owner props only). */
|
||||
function selectApproval({ interactions }: ComposerChainProps): ApprovalWait | null {
|
||||
return interactions.find((i): i is ApprovalWait => i.kind === 'approval') ?? null
|
||||
}
|
||||
|
||||
/** Mounts the conversation plugin.
|
||||
* @param ctx - Client root context.
|
||||
*/
|
||||
@@ -43,6 +53,33 @@ export function apply(ctx: Context): void {
|
||||
const layout = ctx.layout
|
||||
const slots = ctx.slots
|
||||
|
||||
// Command hint locale: friendly placeholder text for claimed commands. The
|
||||
// claimed /plan hint and the plan-mode textarea placeholder share one
|
||||
// string: both describe the same next action.
|
||||
const HINT_NS = 'command.hint'
|
||||
const PLAN_HINT_ZH = '描述你的任务以生成计划'
|
||||
const PLAN_HINT_EN = 'describe your task to generate plan'
|
||||
ctx.effect(() => {
|
||||
const disposers = [
|
||||
ctx.locale.register(HINT_NS, 'zh', {
|
||||
plan: PLAN_HINT_ZH,
|
||||
goal: '输入目标,智能体将持续执行',
|
||||
'goal.active': '当前目标进行中。可输入 edit 修改 / pause 暂停 / resume 继续 / clear 清除',
|
||||
'placeholder.plan': PLAN_HINT_ZH,
|
||||
'placeholder.default': '给智能体发消息',
|
||||
}),
|
||||
ctx.locale.register(HINT_NS, 'en', {
|
||||
plan: PLAN_HINT_EN,
|
||||
goal: 'describe the objective for a long-running task',
|
||||
'goal.active': 'goal active — edit / pause / resume / clear',
|
||||
'placeholder.plan': PLAN_HINT_EN,
|
||||
'placeholder.default': 'Message the agent',
|
||||
}),
|
||||
]
|
||||
return () => { for (const dispose of disposers) dispose() }
|
||||
}, 'ui-conversation: command hint dictionaries')
|
||||
const translateHint = ctx.locale.bind(HINT_NS)
|
||||
|
||||
// Apply-time construction keeps store identity bound to this fiber.
|
||||
const chatStore = createChatStore()
|
||||
|
||||
@@ -146,11 +183,28 @@ export function apply(ctx: Context): void {
|
||||
// Stop failure surfaces via snapshot.promptError; nothing to restore.
|
||||
})
|
||||
},
|
||||
command: async (line) => {
|
||||
const session = sessions.binding(sessionId)?.session
|
||||
if (session === undefined) return false
|
||||
const result = await session.command(line)
|
||||
return result.ok && result.value.matched
|
||||
},
|
||||
translateHint,
|
||||
hooks: { notices: shell.notices, lexicon: shell.lexicon },
|
||||
}
|
||||
},
|
||||
}, InputBar)
|
||||
|
||||
// The approval takeover: a selector-routed entry of the chain this package
|
||||
// just declared (the ui-question registration pattern; the entry lives here
|
||||
// because approval answering is core conversation UX, not an optional tool).
|
||||
// Zero business face — data and verbs both ride the matched carrier.
|
||||
// priority 1: question takeovers (default 0) win when both kinds are
|
||||
// pending — a question is a conversation the model is waiting on, while an
|
||||
// approval only blocks one tool call; answering the question first cannot
|
||||
// strand the approval (it re-elects the moment the question resolves).
|
||||
slots.register({ name: 'conversation.composer', select: selectApproval, priority: 1 }, ApprovalPanel)
|
||||
|
||||
// The chat view: first entry of the ring this package just declared.
|
||||
// Declaring the keyed toolview hole here is claiming it: ChatView is the
|
||||
// only component authorized to render per-tool rows. Shares the chat
|
||||
@@ -185,6 +239,9 @@ export function apply(ctx: Context): void {
|
||||
},
|
||||
}, ChatView)
|
||||
|
||||
// Session stats stick with the composer (composer.dock = stats-line family).
|
||||
slots.register({ name: 'conversation.composer.dock', id: 'stats', order: 0 }, StatsLine)
|
||||
|
||||
// Class-plugin mount (packages/AGENTS.md service form): the service
|
||||
// registers itself as `conversation` and lives on its own child fiber.
|
||||
// Mounted AFTER the chat entry register above — construction guarantee for
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
/* Assistant flow body: full-width narration (figma 16/28), block gap 16. */
|
||||
/* Assistant flow body: full-width narration (figma 16/28), block gap 16.
|
||||
IconActions sit below the body with an explicit 16px top margin (figma
|
||||
43:32997) — separate from the body's internal gap so the footer spacing
|
||||
stays fixed when the body is a single block. */
|
||||
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
font-size: 16px;
|
||||
line-height: 28px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Interrupted-turn terminal marker: quiet inline tag, no animation. */
|
||||
.stopped {
|
||||
align-self: flex-start;
|
||||
@@ -19,3 +27,18 @@
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
/* Finalized footer offset (figma 43:32997); chrome lives in MessageIconActions. */
|
||||
.actions {
|
||||
margin-top: 16px;
|
||||
/* Optical align with 28px icon hit targets that pad 6px past the glyph. */
|
||||
margin-left: -6px;
|
||||
}
|
||||
|
||||
/* Hover-capable pointers: reveal shared actions on root hover/focus. */
|
||||
@media (hover: hover) {
|
||||
.root:hover .actions,
|
||||
.root:focus-within .actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,14 @@
|
||||
// view groups them into tool rows through its keyed toolview slot (figma
|
||||
// step-summary flow). Shared by finalized nodes and the streaming partial;
|
||||
// the turn-level loading dots live in the chat view's tail, not here.
|
||||
// Finalized nodes append IconActions (copy / branch / clock) once streaming ends.
|
||||
|
||||
import { memo } from 'react'
|
||||
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { IconThinkOutline14, JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import {
|
||||
IconThinkOutline14, JsonBlock, MarkdownText,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { MessageIconActions } from './MessageIconActions.tsx'
|
||||
import { ToolRow } from './ToolRow.tsx'
|
||||
import css from './AssistantMarkdown.module.css'
|
||||
|
||||
@@ -16,6 +20,8 @@ export interface AssistantMarkdownProps {
|
||||
streaming: boolean
|
||||
/** Frozen partial of an aborted turn: rendered with a 已停止 marker. */
|
||||
interrupted?: boolean | undefined
|
||||
/** Unix epoch ms for the finalized IconActions clock; omitted while streaming. */
|
||||
time?: number | undefined
|
||||
}
|
||||
|
||||
function firstLine(text: string): string {
|
||||
@@ -23,6 +29,15 @@ function firstLine(text: string): string {
|
||||
return nl === -1 ? text : text.slice(0, nl)
|
||||
}
|
||||
|
||||
/** Joined text blocks for the copy action (reasoning / tool heads stay out). */
|
||||
function copyText(blocks: readonly AssistantBlock[]): string {
|
||||
const parts: string[] = []
|
||||
for (const block of blocks) {
|
||||
if (block.kind === 'text') parts.push(block.text)
|
||||
}
|
||||
return parts.join('')
|
||||
}
|
||||
|
||||
/** Reasoning block as the Think variant summary row (figma 39:28304). */
|
||||
function ThinkRow({ text, running }: { text: string; running: boolean }) {
|
||||
return (
|
||||
@@ -38,7 +53,9 @@ function ThinkRow({ text, running }: { text: string; running: boolean }) {
|
||||
)
|
||||
}
|
||||
|
||||
export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, streaming, interrupted }: AssistantMarkdownProps) {
|
||||
export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||
blocks, streaming, interrupted, time,
|
||||
}: AssistantMarkdownProps) {
|
||||
const last = blocks.length - 1
|
||||
// Tool-call heads render as tool rows in the chat view's grouping pass, so
|
||||
// a node that is only those heads (or empty) would paint an empty root
|
||||
@@ -47,18 +64,30 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, strea
|
||||
|| interrupted === true
|
||||
|| blocks.some(block => block.kind !== 'tool-call')
|
||||
if (!hasVisible) return null
|
||||
// Footer only after the turn settles with a known event time; streaming omits it.
|
||||
const showActions = !streaming && time !== undefined
|
||||
return (
|
||||
<div className={css.root} data-streaming={streaming || undefined}>
|
||||
{blocks.map((block, i) => {
|
||||
switch (block.kind) {
|
||||
case 'text': return <MarkdownText key={i} text={block.text} streaming={streaming} />
|
||||
case 'reasoning': return <ThinkRow key={i} text={block.text} running={streaming && i === last} />
|
||||
// Grouped into tool rows by ChatView; hasVisible above skips an empty shell.
|
||||
case 'tool-call': return null
|
||||
default: return <JsonBlock key={i} label="未知内容块" payload={block.block} />
|
||||
}
|
||||
})}
|
||||
{interrupted && <span className={css.stopped}>已停止</span>}
|
||||
<div className={css.body}>
|
||||
{blocks.map((block, i) => {
|
||||
switch (block.kind) {
|
||||
case 'text': return <MarkdownText key={i} text={block.text} streaming={streaming} />
|
||||
case 'reasoning': return <ThinkRow key={i} text={block.text} running={streaming && i === last} />
|
||||
// Grouped into tool rows by ChatView; hasVisible above skips an empty shell.
|
||||
case 'tool-call': return null
|
||||
default: return <JsonBlock key={i} label="未知内容块" payload={block.block} />
|
||||
}
|
||||
})}
|
||||
{interrupted && <span className={css.stopped}>已停止</span>}
|
||||
</div>
|
||||
{showActions && (
|
||||
<MessageIconActions
|
||||
text={copyText(blocks)}
|
||||
time={time}
|
||||
clock="end"
|
||||
className={css.actions}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/* Chat flow: one 16px rhythm everywhere — between blocks (prose <-> tool
|
||||
runs) via the column gap and between consecutive tool rows via the group
|
||||
gap. Input padding cap rides the skeleton. */
|
||||
gap. Input padding cap rides the skeleton. Under
|
||||
`[data-conversation-scroll]` the column host owns overflow and this view
|
||||
is ordinary flow (see ConversationRoot active-phase rules). */
|
||||
|
||||
.root {
|
||||
position: relative;
|
||||
@@ -17,6 +19,18 @@
|
||||
padding: 16px 24px;
|
||||
}
|
||||
|
||||
:global([data-conversation-scroll]) .root {
|
||||
flex: 0 0 auto;
|
||||
min-height: auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
:global([data-conversation-scroll]) .scroll {
|
||||
overflow: visible;
|
||||
flex: 0 0 auto;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
/* Message column: 736px fixed width, centered on the same axis as the
|
||||
input box; the scroller itself stays full-bleed. */
|
||||
.column {
|
||||
@@ -113,16 +127,34 @@
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Back-to-bottom: 34px circular icon button at the column's right edge. */
|
||||
.toBottom {
|
||||
position: absolute;
|
||||
right: max(24px, calc((100% - 736px) / 2));
|
||||
/* Back-to-bottom: zero-height sticky slot so the control does not extend
|
||||
scrollHeight; the button translates up into the viewport. Under the
|
||||
conversation host, clearance sits above the sticky composer stack. */
|
||||
.toBottomSlot {
|
||||
position: sticky;
|
||||
bottom: 16px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
/* Above the sticky composer (z-index 7) so the control stays clickable and
|
||||
visible over the input card. */
|
||||
z-index: 8;
|
||||
height: 0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-right: max(0px, calc((100% - 736px) / 2));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
:global([data-conversation-scroll]) .toBottomSlot {
|
||||
/* Clears the sticky composer stack (stats + docks + input card). */
|
||||
bottom: 168px;
|
||||
}
|
||||
|
||||
.toBottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
margin-top: -34px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 100px;
|
||||
@@ -130,6 +162,7 @@
|
||||
background: var(--dsw-alias-button-floating-fill);
|
||||
box-shadow: var(--dsw-shadow-lv2);
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.toBottom:hover {
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
// ChatView: the default conversation view — message flow with user bubbles,
|
||||
// assistant narration, tool summary rows grouped into step runs, pending
|
||||
// cards, paging, bottom-follow, and the session stats line under the flow
|
||||
// (chrome dissolved into the view: the footer is part of what a chat view
|
||||
// IS, not registration metadata). Pure component registered directly; its
|
||||
// registration declares the keyed 'conversation.chat.toolview' hole, so tool
|
||||
// rows render through the props renderSlot share (entryKey = tool name,
|
||||
// GenericToolCard as the render-site fallback).
|
||||
// cards, paging, and bottom-follow. Session stats live on
|
||||
// 'conversation.composer.dock' (sticky with the composer). Pure component
|
||||
// registered directly; its registration declares the keyed
|
||||
// 'conversation.chat.toolview' hole, so tool rows render through the props
|
||||
// renderSlot share (entryKey = tool name, GenericToolCard as the render-site
|
||||
// fallback).
|
||||
//
|
||||
// Scroll: when nested under `[data-conversation-scroll]` (active conversation
|
||||
// column), that host is the scrollport and this view is flow content; when
|
||||
// mounted alone (unit tests), `.scroll` owns overflow. Bottom-follow and
|
||||
// prepend anchoring always target the resolved scrollport.
|
||||
//
|
||||
// Render economics (architecture RFC performance model): the list parent
|
||||
// subscribes to snapshot segments that do NOT change per streaming chunk
|
||||
@@ -17,7 +22,7 @@
|
||||
// memoized rows never churns them.
|
||||
|
||||
import {
|
||||
memo, useLayoutEffect, useMemo, useRef, useState, type ReactNode,
|
||||
memo, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode,
|
||||
} from 'react'
|
||||
import type {
|
||||
CodeSubCall, CommandNode, ConversationNode, ConversationSnapshot, RunningToolCall, ToolResultNode,
|
||||
@@ -30,12 +35,15 @@ import { AssistantMarkdown } from './AssistantMarkdown.tsx'
|
||||
import { GenericCommandCard } from './GenericCommandCard.tsx'
|
||||
import { GenericToolCard } from './GenericToolCard.tsx'
|
||||
import { MessageItem } from './MessageItem.tsx'
|
||||
import { PendingCard } from './PendingCard.tsx'
|
||||
import { StatsLine } from './StatsLine.tsx'
|
||||
import css from './ChatView.module.css'
|
||||
|
||||
const FOLLOW_THRESHOLD = 24
|
||||
|
||||
/** Active column host when present; otherwise the view-local scroller. */
|
||||
function scrollerOf(from: HTMLElement): HTMLElement {
|
||||
return (from.closest('[data-conversation-scroll]')) ?? from
|
||||
}
|
||||
|
||||
type OpenFile = (path: string) => void
|
||||
|
||||
/** The declared toolview hole's render share (stable framework binding, passed through memoized rows). */
|
||||
@@ -229,7 +237,6 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
|
||||
const running = useSession(s => s.running)
|
||||
const runningCalls = useSession(s => s.runningCalls)
|
||||
const codeDispatches = useSession(s => s.codeDispatches)
|
||||
const pending = useSession(s => s.pending)
|
||||
const openState = useSession(s => s.openState)
|
||||
const openErrorMessage = useSession(s => s.openError === null ? null : `${s.openError.message}(${s.openError.code})`)
|
||||
const hasMore = useSession(s => s.hasMore)
|
||||
@@ -246,26 +253,34 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
|
||||
const firstSeqRef = useRef<number | null>(null)
|
||||
const openedRef = useRef(false)
|
||||
const lastKeyRef = useRef<string | null>(null)
|
||||
/** Flow tip signature — follow-scroll only when this moves, never on a
|
||||
* scroll-driven at-bottom chrome re-render (that was snapping inertial
|
||||
* scrolls the rest of the way to the floor). */
|
||||
const followSigRef = useRef<string | null>(null)
|
||||
|
||||
const firstSeq = nodes[0]?.seq ?? null
|
||||
const lastItem = items[items.length - 1]
|
||||
const lastKey = lastItem?.key ?? null
|
||||
const followSig = `${openState}:${firstSeq}:${lastKey}:${nodes.length}:${running ? 1 : 0}:${runningCalls.length}`
|
||||
|
||||
const toBottom = (el: HTMLDivElement): void => {
|
||||
const toBottom = (el: HTMLElement): void => {
|
||||
el.scrollTop = el.scrollHeight
|
||||
atBottomRef.current = true
|
||||
setAtBottom(true)
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = listRef.current
|
||||
const local = listRef.current
|
||||
/* v8 ignore next -- ref-null guard: React attaches the ref before layout effects run. */
|
||||
if (el === null) return
|
||||
if (local === null) return
|
||||
const el = scrollerOf(local)
|
||||
// Open completed: jump to the bottom once.
|
||||
if (openState === 'open' && !openedRef.current) {
|
||||
openedRef.current = true
|
||||
toBottom(el)
|
||||
firstSeqRef.current = firstSeq
|
||||
lastKeyRef.current = lastItem?.key ?? null
|
||||
lastKeyRef.current = lastKey
|
||||
followSigRef.current = followSig
|
||||
return
|
||||
}
|
||||
// Prepend (head seq decreased): compensate by the height delta.
|
||||
@@ -274,42 +289,65 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
|
||||
anchorRef.current = null
|
||||
firstSeqRef.current = firstSeq
|
||||
/* v8 ignore next -- ?? arm: a prepend adds nodes, so the flow list here is never empty. */
|
||||
lastKeyRef.current = lastItem?.key ?? null
|
||||
lastKeyRef.current = lastKey
|
||||
followSigRef.current = followSig
|
||||
return
|
||||
}
|
||||
firstSeqRef.current = firstSeq
|
||||
// Own words must be visible: a new trailing user node force-scrolls
|
||||
// (send lives in the composer, so arrival is detected here, not armed there).
|
||||
const lastKey = lastItem?.key ?? null
|
||||
const appendedUser = lastKey !== lastKeyRef.current
|
||||
&& lastItem !== undefined && lastItem.kind === 'node' && lastItem.node.kind === 'user'
|
||||
const tipMoved = followSigRef.current !== followSig
|
||||
lastKeyRef.current = lastKey
|
||||
if (appendedUser || atBottomRef.current) toBottom(el)
|
||||
followSigRef.current = followSig
|
||||
// Follow new flow content while pinned; do NOT re-pin on every render
|
||||
// merely because atBottomRef is true (scroll threshold → setState → snap).
|
||||
if (appendedUser || (tipMoved && atBottomRef.current)) toBottom(el)
|
||||
})
|
||||
|
||||
const onScroll = (): void => {
|
||||
const el = listRef.current
|
||||
/* v8 ignore next -- ref-null guard: the handler only fires on the mounted element. */
|
||||
if (el === null) return
|
||||
const onScrollRef = useRef(() => {})
|
||||
onScrollRef.current = () => {
|
||||
const local = listRef.current
|
||||
/* v8 ignore next -- ref-null guard: the handler only fires while mounted. */
|
||||
if (local === null) return
|
||||
const el = scrollerOf(local)
|
||||
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1
|
||||
atBottomRef.current = isAtBottom
|
||||
setAtBottom(isAtBottom)
|
||||
}
|
||||
|
||||
// Bind scroll to the resolved scrollport (host or local) once per mount.
|
||||
useEffect(() => {
|
||||
const local = listRef.current
|
||||
/* v8 ignore next -- ref-null guard: effect runs after the list node commits. */
|
||||
if (local === null) return
|
||||
const el = scrollerOf(local)
|
||||
const onScroll = (): void => { onScrollRef.current() }
|
||||
el.addEventListener('scroll', onScroll, { passive: true })
|
||||
return () => { el.removeEventListener('scroll', onScroll) }
|
||||
}, [])
|
||||
|
||||
// Follow streaming growth the parent never re-renders for (stable ref).
|
||||
// The ref starts null and is assigned every render, so the placeholder
|
||||
// initializer a function initial value would need never exists.
|
||||
const followRef = useRef<(() => void) | null>(null)
|
||||
followRef.current = () => {
|
||||
const el = listRef.current
|
||||
if (el !== null && atBottomRef.current) el.scrollTop = el.scrollHeight
|
||||
const local = listRef.current
|
||||
if (local !== null && atBottomRef.current) {
|
||||
const el = scrollerOf(local)
|
||||
el.scrollTop = el.scrollHeight
|
||||
}
|
||||
}
|
||||
const onGrow = useRef(() => followRef.current?.()).current
|
||||
|
||||
const loadOlderAnchored = (): void => {
|
||||
const el = listRef.current
|
||||
const local = listRef.current
|
||||
/* v8 ignore next -- ref-null guard: the paging button renders inside the list tree. */
|
||||
if (el !== null) anchorRef.current = { h: el.scrollHeight, t: el.scrollTop }
|
||||
if (local !== null) {
|
||||
const el = scrollerOf(local)
|
||||
anchorRef.current = { h: el.scrollHeight, t: el.scrollTop }
|
||||
}
|
||||
loadOlder()
|
||||
}
|
||||
|
||||
@@ -332,7 +370,15 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
|
||||
}
|
||||
const node: ConversationNode = item.node
|
||||
if (node.kind === 'assistant') {
|
||||
return <AssistantMarkdown key={item.key} blocks={node.blocks} streaming={false} interrupted={node.interrupted} />
|
||||
return (
|
||||
<AssistantMarkdown
|
||||
key={item.key}
|
||||
blocks={node.blocks}
|
||||
streaming={false}
|
||||
interrupted={node.interrupted}
|
||||
time={node.time}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (node.kind === 'command') {
|
||||
return <CommandRow key={item.key} renderSlot={renderSlot} node={node} />
|
||||
@@ -344,7 +390,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
|
||||
|
||||
return (
|
||||
<div className={css.root}>
|
||||
<div ref={listRef} className={css.scroll} onScroll={onScroll}>
|
||||
<div ref={listRef} className={css.scroll}>
|
||||
<div className={css.column}>
|
||||
{openState === 'loading' && <div className={css.hint}>载入历史…</div>}
|
||||
{openState === 'error' && <div className={css.openError}>历史加载失败:{openErrorMessage}</div>}
|
||||
@@ -375,29 +421,30 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{pending.map(item => item.kind === 'approval'
|
||||
? <PendingCard key={item.key} item={item} />
|
||||
: null)}
|
||||
{/* No pending placeholders: questions (ui-question) and approvals
|
||||
(ApprovalPanel) both take over the composer, so a flow card would
|
||||
double-render the same wait. */}
|
||||
{/* Turn-level loading signal: rides the whole running turn (first-token
|
||||
wait, tool execution, streaming) so it never flickers per step. */}
|
||||
{running && <TurnDots />}
|
||||
</div>
|
||||
{!atBottom && (
|
||||
<div className={css.toBottomSlot}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.toBottom}
|
||||
aria-label="回到底部"
|
||||
onClick={() => {
|
||||
const local = listRef.current
|
||||
/* v8 ignore next -- ref-null guard: the button only renders alongside the mounted list. */
|
||||
if (local !== null) toBottom(scrollerOf(local))
|
||||
}}
|
||||
>
|
||||
<IconChevronDownOutline14 />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<StatsLine useSession={useSession} />
|
||||
{!atBottom && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.toBottom}
|
||||
aria-label="回到底部"
|
||||
onClick={() => {
|
||||
const el = listRef.current
|
||||
/* v8 ignore next -- ref-null guard: the button only renders alongside the mounted list. */
|
||||
if (el !== null) toBottom(el)
|
||||
}}
|
||||
>
|
||||
<IconChevronDownOutline14 />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
IconThinkOutline14,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ToolRowOwnerProps } from '../contract/slots.ts'
|
||||
import { terminalCardModel } from '../contract/terminal-card-model.ts'
|
||||
import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.ts'
|
||||
import { ToolRow } from './ToolRow.tsx'
|
||||
|
||||
@@ -27,6 +28,7 @@ const VARIANT_ICONS: Record<ToolRowVariant, ReactNode> = {
|
||||
|
||||
export function GenericToolCard({ toolName, block, cwd, openFile }: ToolRowOwnerProps) {
|
||||
const model = toolRowModel(toolName, block, cwd)
|
||||
const terminal = terminalCardModel(block, cwd)
|
||||
const singleFile = model.filePath !== undefined
|
||||
return (
|
||||
<ToolRow
|
||||
@@ -34,9 +36,12 @@ export function GenericToolCard({ toolName, block, cwd, openFile }: ToolRowOwner
|
||||
toolName={toolName}
|
||||
icon={VARIANT_ICONS[model.variant]}
|
||||
title={model.title}
|
||||
summary={model.summary}
|
||||
// A terminal presenter's description is the contract's above-card text, so
|
||||
// it outranks the args-derived summary here exactly as it does in BashRow.
|
||||
summary={terminal?.description ?? model.summary}
|
||||
// Single-file tools never expose an args body — the path link is the only action.
|
||||
body={singleFile ? null : model.body}
|
||||
terminal={terminal}
|
||||
state={model.state}
|
||||
filePath={model.filePath}
|
||||
onOpenFile={singleFile ? openFile : undefined}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/* Shared message IconActions row (user + assistant). Parent modules own
|
||||
hover-reveal selectors and layout offsets via the composed className. */
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
/* Clock before icons (user figma 388:20051) / after (assistant 43:32997). */
|
||||
.timeStart {
|
||||
padding-right: 12px;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.timeEnd {
|
||||
padding-left: 12px;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Hover-capable pointers: hide until a parent hover/focus rule reveals. */
|
||||
@media (hover: hover) {
|
||||
.actions {
|
||||
opacity: 0;
|
||||
transition: opacity var(--ds-transition-duration) var(--ds-ease-in-out);
|
||||
}
|
||||
}
|
||||
|
||||
.action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 6px;
|
||||
border: none;
|
||||
border-radius: 28px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.action:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Shared IconActions chrome for user and assistant messages: copy / branch
|
||||
// live (branch still a stub), date-aware clock, optional edit stub.
|
||||
|
||||
import { useCallback } from 'react'
|
||||
import {
|
||||
IconBranchOutline16, IconCopyOutline16, IconEditOutline16, Tooltip,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { formatMessageClock, writeClipboard } from './message-chrome.ts'
|
||||
import { useCalendarDay } from './use-calendar-day.ts'
|
||||
import css from './MessageIconActions.module.css'
|
||||
|
||||
export interface MessageIconActionsProps {
|
||||
/** Plain text the copy action writes. */
|
||||
text: string
|
||||
/** Unix epoch ms for the clock label. */
|
||||
time: number
|
||||
/** Clock before icons (user) or after (assistant). */
|
||||
clock: 'start' | 'end'
|
||||
/** When true, append the stub edit control (user bubble). */
|
||||
edit?: boolean | undefined
|
||||
/** Parent layout / hover-reveal class composed onto the actions row. */
|
||||
className?: string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy / branch (/ clock) IconActions row shared by user and assistant chrome.
|
||||
* @param props - Copy text, event time, clock side, optional edit, className.
|
||||
* @returns The actions row element.
|
||||
*/
|
||||
export function MessageIconActions({
|
||||
text, time, clock, edit, className,
|
||||
}: MessageIconActionsProps) {
|
||||
const day = useCalendarDay()
|
||||
const onCopy = useCallback(() => {
|
||||
void writeClipboard(text)
|
||||
}, [text])
|
||||
const clockEl = (
|
||||
<span className={clock === 'start' ? css.timeStart : css.timeEnd}>
|
||||
{formatMessageClock(time, day)}
|
||||
</span>
|
||||
)
|
||||
return (
|
||||
<div className={className === undefined ? css.actions : `${css.actions} ${className}`}>
|
||||
{clock === 'start' ? clockEl : null}
|
||||
<Tooltip label="复制" side="bottom">
|
||||
<button type="button" className={css.action} aria-label="复制" onClick={onCopy}>
|
||||
<IconCopyOutline16 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="在新对话中分支" side="bottom">
|
||||
<button type="button" className={css.action} aria-label="在新对话中分支">
|
||||
<IconBranchOutline16 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{edit === true && (
|
||||
<Tooltip label="编辑" side="bottom">
|
||||
<button type="button" className={css.action} aria-label="编辑">
|
||||
<IconEditOutline16 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{clock === 'end' ? clockEl : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -20,46 +20,14 @@
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
/* Hover-capable pointers: hide until the row is hovered/focused. Touch /
|
||||
hover:none keeps actions visible (opacity:0 still hit-tests). */
|
||||
/* Hover-capable pointers: reveal shared MessageIconActions on row hover/focus. */
|
||||
@media (hover: hover) {
|
||||
.actions {
|
||||
opacity: 0;
|
||||
transition: opacity var(--ds-transition-duration) var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.userRow:hover .actions,
|
||||
.userRow:focus-within .actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 6px;
|
||||
border: none;
|
||||
border-radius: 28px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.action:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
margin-bottom: 4px;
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
// MessageItem: the four simple node kinds — user bubble (right-aligned, with
|
||||
// copy / branch / edit IconActions), steering (badged bubble), context
|
||||
// clock + copy / branch / edit IconActions), steering (badged bubble), context
|
||||
// injection and unknown-surface JSON rows. Props are frozen node slices off
|
||||
// the snapshot cache; memo holds across streaming because unchanged nodes
|
||||
// keep their references.
|
||||
|
||||
import { memo, useCallback } from 'react'
|
||||
import { memo } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type {
|
||||
ContextMessageNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
IconBranchOutline16, IconCopyOutline16, IconEditOutline16,
|
||||
JsonBlock, MessageText, Tooltip,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { MessageIconActions } from './MessageIconActions.tsx'
|
||||
import css from './MessageItem.module.css'
|
||||
|
||||
export interface MessageItemProps {
|
||||
@@ -30,42 +28,6 @@ function contentText(content: readonly unknown[]): { text: string; rest: unknown
|
||||
return { text: texts.join(''), rest }
|
||||
}
|
||||
|
||||
/** Best-effort clipboard write; rejections stay swallowed (no success chrome). */
|
||||
async function writeClipboard(text: string): Promise<void> {
|
||||
// lib.dom types clipboard non-optional, but insecure contexts omit it —
|
||||
// that runtime gap is exactly what this guard detects.
|
||||
/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
} catch {
|
||||
// Denied permissions / iframe policy.
|
||||
}
|
||||
return
|
||||
}
|
||||
// execCommand('copy') is the only clipboard fallback where the async API
|
||||
// is missing (insecure contexts); deprecated but deliberately retained.
|
||||
/* eslint-disable @typescript-eslint/no-deprecated */
|
||||
const exec = typeof document.execCommand === 'function'
|
||||
? document.execCommand.bind(document)
|
||||
: undefined
|
||||
if (exec === undefined) return
|
||||
const el = document.createElement('textarea')
|
||||
el.value = text
|
||||
el.setAttribute('readonly', '')
|
||||
el.style.position = 'fixed'
|
||||
el.style.left = '-9999px'
|
||||
document.body.appendChild(el)
|
||||
el.select()
|
||||
try {
|
||||
exec('copy')
|
||||
} catch {
|
||||
// Clipboard unavailable; the button stays idle.
|
||||
}
|
||||
/* eslint-enable @typescript-eslint/no-deprecated */
|
||||
el.remove()
|
||||
}
|
||||
|
||||
/**
|
||||
* Display projection of reference forms in a user bubble (free geometry — no
|
||||
* textarea alignment constraint here); everything else stays plain text. The
|
||||
@@ -98,32 +60,6 @@ function projectUserText(text: string): ReactNode {
|
||||
return <>{parts}</>
|
||||
}
|
||||
|
||||
/** User-bubble IconActions (figma 659:38820): copy is live; branch/edit are chrome stubs. */
|
||||
function UserActions({ text }: { text: string }) {
|
||||
const onCopy = useCallback(() => {
|
||||
void writeClipboard(text)
|
||||
}, [text])
|
||||
return (
|
||||
<div className={css.actions}>
|
||||
<Tooltip label="复制" side="bottom">
|
||||
<button type="button" className={css.action} aria-label="复制" onClick={onCopy}>
|
||||
<IconCopyOutline16 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="在新对话中分支" side="bottom">
|
||||
<button type="button" className={css.action} aria-label="在新对话中分支">
|
||||
<IconBranchOutline16 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="编辑" side="bottom">
|
||||
<button type="button" className={css.action} aria-label="编辑">
|
||||
<IconEditOutline16 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const MessageItem = memo(function MessageItem({ node }: MessageItemProps) {
|
||||
switch (node.kind) {
|
||||
case 'user': {
|
||||
@@ -134,7 +70,13 @@ export const MessageItem = memo(function MessageItem({ node }: MessageItemProps)
|
||||
{projectUserText(text)}
|
||||
{rest.map((block, i) => <JsonBlock key={i} label="附加内容块" payload={block} />)}
|
||||
</div>
|
||||
<UserActions text={text} />
|
||||
<MessageIconActions
|
||||
text={text}
|
||||
time={node.time}
|
||||
clock="start"
|
||||
edit
|
||||
className={css.actions}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
/* Amber pending strip (approval waiting = warn semantic, figma state colors). */
|
||||
|
||||
.card {
|
||||
margin: 6px 0;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--dsw-alias-state-warn-secondary);
|
||||
border-radius: 10px;
|
||||
background: var(--dsw-alias-state-warn-tertiary);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: var(--ds-font-family-code);
|
||||
}
|
||||
|
||||
.reason {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
// PendingCard: display-only approval placeholder. Questions render exclusively
|
||||
// through the composer takeover so the same pending wait is never shown twice.
|
||||
|
||||
import { memo } from 'react'
|
||||
import type { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import css from './PendingCard.module.css'
|
||||
|
||||
export interface PendingCardProps {
|
||||
item: PendingWait<'approval'>
|
||||
}
|
||||
|
||||
export const PendingCard = memo(function PendingCard({ item }: PendingCardProps) {
|
||||
return (
|
||||
<div className={css.card}>
|
||||
<div className={css.title}>等待审批:<span className={css.mono}>{item.payload.toolName}</span></div>
|
||||
{item.payload.reason !== undefined && <div className={css.reason}>{item.payload.reason}</div>}
|
||||
<div className={css.hint}>请在原客户端处理(web 端作答后续里程碑提供)</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -1,4 +1,6 @@
|
||||
// Settled-node identity prevents stream-delta updates from rerendering this row.
|
||||
// Mounted on 'conversation.composer.dock' so it sticks with the composer in the
|
||||
// active conversation scrollport (see ConversationRoot data-conversation-scroll).
|
||||
|
||||
import { memo, useMemo } from 'react'
|
||||
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -49,7 +51,7 @@ export function deriveStats(nodes: ConversationSnapshot['nodes']): UsageTotals {
|
||||
}
|
||||
}
|
||||
|
||||
/** Props: the conversation-snapshot selector hook (handed down by ChatView). */
|
||||
/** Props: the conversation-snapshot selector (dock registration or unit mount). */
|
||||
export interface StatsLineProps { useSession: SnapshotSelectorHook<ConversationSnapshot> }
|
||||
|
||||
export const StatsLine = memo(function StatsLine({ useSession }: StatsLineProps) {
|
||||
|
||||
@@ -175,9 +175,23 @@ button.leading {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* The code variant's expanded body is the run_code program, rendered through
|
||||
the shared CodeBlock (shiki-highlighted TypeScript); only indentation is
|
||||
this row's concern. */
|
||||
.codeBody {
|
||||
/* The two block-shaped expanded bodies: the code variant's run_code program
|
||||
through CodeBlock (shiki-highlighted TypeScript) and a terminal card's
|
||||
command output through TerminalBlock. Both are drawn by the shared
|
||||
primitive, so only the row's indentation is this file's concern — the margin
|
||||
also replaces each primitive's own standalone vertical spacing with the
|
||||
flow's row rhythm. */
|
||||
.codeBody,
|
||||
.terminalBody {
|
||||
margin: 4px 0 4px 22px;
|
||||
}
|
||||
|
||||
/* Indented to the body's own column so the description reads as the card's
|
||||
heading rather than as another summary row, and sits tight against the card
|
||||
below it. Its own rule: grouping it with a body would put description
|
||||
typography on a `CodeBlock` wrapper and change that body's spacing. */
|
||||
.terminalDescription {
|
||||
margin: 4px 0 0 22px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
// ToolRow: the single-line tool summary row (figma component set 122:9479) —
|
||||
// 16px leading slot (state dot / tool icon, chevron on hover or expanded) + title +
|
||||
// separator dot + FILL-truncated summary. Expanded body is indented gray text;
|
||||
// no inline output (full results live in the details panel). Expand state is
|
||||
// separator dot + FILL-truncated summary. The collapsed row is always one
|
||||
// line; the expanded body is indented gray text, the run_code program through
|
||||
// CodeBlock, or — for a call whose render intent is a terminal card — the
|
||||
// command's own output through TerminalBlock, capped at
|
||||
// CHAT_TERMINAL_MAX_LINES so the message flow stays scannable. Expand state is
|
||||
// component-local view state. File-tool summaries are path links that open
|
||||
// through the host; the row itself is not a details-panel control.
|
||||
|
||||
import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { CodeBlock, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { CodeBlock, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { CHAT_TERMINAL_MAX_LINES, type TerminalCardModel } from '../contract/terminal-card-model.ts'
|
||||
import type { ToolRowState, ToolRowVariant } from '../contract/tool-call-model.ts'
|
||||
import css from './ToolRow.module.css'
|
||||
|
||||
@@ -20,8 +24,15 @@ export interface ToolRowProps {
|
||||
icon: ReactNode
|
||||
title: string
|
||||
summary: string
|
||||
/** Expanded-body text; null = not expandable (leading slot never toggles). */
|
||||
/** Expanded-body text; null = no text body (`terminal` is the other body source). */
|
||||
body: string | null
|
||||
/**
|
||||
* Terminal-card material for a call whose render intent is a terminal card
|
||||
* (derived by `terminalCardModel`); it replaces the text body when present.
|
||||
* Null or absent leaves the text body, and a row with neither is not
|
||||
* expandable (its leading slot never toggles).
|
||||
*/
|
||||
terminal?: TerminalCardModel | null | undefined
|
||||
state: ToolRowState
|
||||
/** Makes the row itself the expand control instead of only its leading icon. */
|
||||
expandOnRowClick?: boolean | undefined
|
||||
@@ -52,17 +63,25 @@ export function ToolRow({
|
||||
title,
|
||||
summary,
|
||||
body,
|
||||
terminal,
|
||||
state,
|
||||
expandOnRowClick = false,
|
||||
filePath,
|
||||
onOpenFile,
|
||||
}: ToolRowProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const terminalBody = terminal ?? null
|
||||
// A row that names a single file keeps one interaction (open that path);
|
||||
// args expand is off whether or not the open callback is wired yet.
|
||||
// args expand is off whether or not the open callback is wired yet. Terminal
|
||||
// material still expands: only the file variants carry a path, so a terminal
|
||||
// card and a file link never land on the same row.
|
||||
const singleFile = filePath !== undefined
|
||||
const fileLink = singleFile && onOpenFile !== undefined
|
||||
const expandable = body !== null && !singleFile
|
||||
const expandable = (body !== null && !singleFile) || terminalBody !== null
|
||||
// The text arms take the empty string for a null body: a row expandable
|
||||
// only through its terminal material renders the terminal body instead, so
|
||||
// this substitution never shows.
|
||||
const text = body ?? ''
|
||||
const open = expanded && expandable
|
||||
const rowExpands = expandable && expandOnRowClick
|
||||
const toggleExpand = () => {
|
||||
@@ -137,9 +156,17 @@ export function ToolRow({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{open && (variant === 'code'
|
||||
? <CodeBlock code={body} lang="typescript" className={css.codeBody} />
|
||||
: <div className={css.body}>{body}</div>)}
|
||||
{/* The terminal presenter's description belongs ABOVE the card per the
|
||||
render-intent contract, so an expanded terminal row keeps showing it
|
||||
even though the collapsed summary is hidden while open. */}
|
||||
{open && terminalBody?.description !== undefined && (
|
||||
<div className={css.terminalDescription}>{terminalBody.description}</div>
|
||||
)}
|
||||
{open && (terminalBody !== null
|
||||
? <TerminalBlock {...terminalBody.card} maxLines={CHAT_TERMINAL_MAX_LINES} className={css.terminalBody} />
|
||||
: variant === 'code'
|
||||
? <CodeBlock code={text} lang="typescript" className={css.codeBody} />
|
||||
: <div className={css.body}>{text}</div>)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// Shared chrome helpers for user/assistant IconActions rows: clipboard write
|
||||
// and the compact date+clock label from a session-event epoch.
|
||||
|
||||
/**
|
||||
* Best-effort clipboard write; rejections stay swallowed (no success chrome).
|
||||
* @param text - Plain text to place on the clipboard.
|
||||
*/
|
||||
export async function writeClipboard(text: string): Promise<void> {
|
||||
// lib.dom types clipboard non-optional, but insecure contexts omit it —
|
||||
// that runtime gap is exactly what this guard detects.
|
||||
/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
} catch {
|
||||
// Denied permissions / iframe policy.
|
||||
}
|
||||
return
|
||||
}
|
||||
// execCommand('copy') is the only clipboard fallback where the async API
|
||||
// is missing (insecure contexts); deprecated but deliberately retained.
|
||||
/* eslint-disable @typescript-eslint/no-deprecated */
|
||||
const exec = typeof document.execCommand === 'function'
|
||||
? document.execCommand.bind(document)
|
||||
: undefined
|
||||
if (exec === undefined) return
|
||||
const el = document.createElement('textarea')
|
||||
el.value = text
|
||||
el.setAttribute('readonly', '')
|
||||
el.style.position = 'fixed'
|
||||
el.style.left = '-9999px'
|
||||
document.body.appendChild(el)
|
||||
el.select()
|
||||
try {
|
||||
exec('copy')
|
||||
} catch {
|
||||
// Clipboard unavailable; the button stays idle.
|
||||
}
|
||||
/* eslint-enable @typescript-eslint/no-deprecated */
|
||||
el.remove()
|
||||
}
|
||||
|
||||
function pad2(n: number): string {
|
||||
return String(n).padStart(2, '0')
|
||||
}
|
||||
|
||||
/**
|
||||
* Local calendar-day epoch (ms at local midnight) for an instant.
|
||||
* @param ms - Unix epoch ms.
|
||||
* @returns Midnight of that local calendar day.
|
||||
*/
|
||||
export function startOfLocalDay(ms: number): number {
|
||||
const d = new Date(ms)
|
||||
d.setHours(0, 0, 0, 0)
|
||||
return d.getTime()
|
||||
}
|
||||
|
||||
/**
|
||||
* Delay until the next local midnight after `ms` (at least 1ms).
|
||||
* @param ms - Unix epoch ms.
|
||||
* @returns Milliseconds until the following local midnight.
|
||||
*/
|
||||
export function msUntilNextLocalMidnight(ms: number): number {
|
||||
const next = new Date(ms)
|
||||
next.setHours(24, 0, 0, 0)
|
||||
return Math.max(next.getTime() - ms, 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact local timestamp for message IconActions.
|
||||
* Same calendar day → `HH:mm`; earlier this year → `M月D日 HH:mm`;
|
||||
* other years → `YYYY年M月D日 HH:mm`.
|
||||
* @param time - Unix epoch ms from the source session event.
|
||||
* @param now - Reference instant for the day/year cut (defaults to wall clock).
|
||||
* @returns Date-aware clock string (24-hour, zero-padded time).
|
||||
*/
|
||||
export function formatMessageClock(time: number, now: number = Date.now()): string {
|
||||
const d = new Date(time)
|
||||
const n = new Date(now)
|
||||
const clock = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`
|
||||
if (
|
||||
d.getFullYear() === n.getFullYear()
|
||||
&& d.getMonth() === n.getMonth()
|
||||
&& d.getDate() === n.getDate()
|
||||
) {
|
||||
return clock
|
||||
}
|
||||
const md = `${d.getMonth() + 1}月${d.getDate()}日`
|
||||
if (d.getFullYear() === n.getFullYear()) return `${md} ${clock}`
|
||||
return `${d.getFullYear()}年${md} ${clock}`
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Component-local calendar-day tick: memoized message rows keep stable props
|
||||
// across midnight, so the IconActions clock needs a local day seat that
|
||||
// re-fires at the next local midnight without reaching for framework hooks.
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { msUntilNextLocalMidnight, startOfLocalDay } from './message-chrome.ts'
|
||||
|
||||
/**
|
||||
* Local calendar-day epoch that advances at each local midnight.
|
||||
* @returns Midnight ms for the current local day; updates after the boundary.
|
||||
*/
|
||||
export function useCalendarDay(): number {
|
||||
const [day, setDay] = useState(() => startOfLocalDay(Date.now()))
|
||||
useEffect(() => {
|
||||
let timer: ReturnType<typeof setTimeout>
|
||||
const arm = (): void => {
|
||||
const now = Date.now()
|
||||
setDay(startOfLocalDay(now))
|
||||
timer = setTimeout(arm, msUntilNextLocalMidnight(now))
|
||||
}
|
||||
timer = setTimeout(arm, msUntilNextLocalMidnight(Date.now()))
|
||||
return () => { clearTimeout(timer) }
|
||||
}, [])
|
||||
return day
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import type { ReactNode, RefObject } from 'react'
|
||||
import type {
|
||||
InjectFace, MaybeSnapshotSelectorHook, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { CommandNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { CommandNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import type { ComposerKeyboard, InputActions, InputNotice, InputState } from '../input/contract.ts'
|
||||
import type { createChatStore } from '../stores.ts'
|
||||
@@ -117,6 +117,18 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
|
||||
/** Owner share of the strict session content seat. */
|
||||
export interface ConversationSessionOwnerProps {
|
||||
/**
|
||||
* Wrap the view ring in the transcript scrollport that also hosts the
|
||||
* sticky composer seat (whole `'conversation.composer'` chain output).
|
||||
* Supplied for every real session (hero/settling/active) so the composer
|
||||
* keeps one tree seat across the blank → active flip; the header stays
|
||||
* outside that wrapper as ordinary column chrome (`flex: none`), while
|
||||
* active CSS sticks the seat to the bottom of the same scrollport so wheel
|
||||
* over the footer scrolls the flow.
|
||||
* @param view - the session view-ring content (null while blank chrome is hidden).
|
||||
* @returns the scrollport containing `view` and the sticky composer seat.
|
||||
*/
|
||||
wrapActiveBody?: (view: ReactNode) => ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -251,6 +263,14 @@ export interface ComposerBarInjected {
|
||||
keyboard: ComposerKeyboard
|
||||
/** Cancel the in-flight turn. */
|
||||
stop: () => void
|
||||
/**
|
||||
* Submit one slash-command line against this session's agent (the chrome
|
||||
* controls' write path — the permission chip submits `/permission <preset>`).
|
||||
* Resolves admission: false = rejected/unmatched/transport failure.
|
||||
*/
|
||||
command: (line: string) => Promise<boolean>
|
||||
/** Locale-aware hint translator for claimed command placeholders. */
|
||||
translateHint: (key: string) => string
|
||||
/** Registrant hooks compartment: the renderer binds these to useNotices/useLexicon. */
|
||||
hooks: {
|
||||
/** Latest surfaced notice (null after none; seq keys re-render of repeats). */
|
||||
@@ -307,6 +327,68 @@ export type ConversationSessionSlotProps =
|
||||
& PropsStore<ChatStore>
|
||||
& ConversationSessionInjected
|
||||
|
||||
/** The pending approval carrier the owner dispatches into the composer chain. */
|
||||
export type ApprovalWait = PendingWait<'approval'>
|
||||
|
||||
/**
|
||||
* Approval domain face over the carrier (the ui-question PendingQuestion
|
||||
* pattern): render identity and question material forwarded transparently;
|
||||
* answer owns the wire encoding — the ApprovalResponsePayload value shape
|
||||
* with the audit correlation the host reconciles — and turns a rejected
|
||||
* carrier receipt into a thrown error. Minted per carrier via useMemo.
|
||||
*/
|
||||
export class PendingApproval {
|
||||
/**
|
||||
* @param wait - the runtime carrier for one pending approval question.
|
||||
*/
|
||||
constructor(private readonly wait: ApprovalWait) {}
|
||||
|
||||
/** Opaque render identity (React key / one-shot latch remount axis), forwarded from the carrier. */
|
||||
get key(): string {
|
||||
return this.wait.key
|
||||
}
|
||||
|
||||
/** The tool the question is about (headline fallback), forwarded from the carrier payload. */
|
||||
get toolName(): string {
|
||||
return this.wait.payload.toolName
|
||||
}
|
||||
|
||||
/** The asker's human-readable WHY (headline when present), forwarded from the carrier payload. */
|
||||
get reason(): string | undefined {
|
||||
return this.wait.payload.reason
|
||||
}
|
||||
|
||||
/** The paired tool call's id when the ask names one (command-line lookup key), forwarded from the carrier payload. */
|
||||
get callId(): string | undefined {
|
||||
return this.wait.payload.callId
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver the user's decision; a rejected carrier receipt throws. Panel
|
||||
* removal stays frame-driven: the broadcast `approval/resolved` settles the
|
||||
* wait and drops it from the pending list.
|
||||
* @param outcome - the only two client-answerable outcomes.
|
||||
*/
|
||||
async answer(outcome: 'allowed-once' | 'rejected'): Promise<void> {
|
||||
const receipt = await this.wait.respond({
|
||||
ok: true,
|
||||
value: { sessionId: this.wait.sessionId, approvalId: this.wait.payload.approvalId, outcome },
|
||||
})
|
||||
if (!receipt.accepted) {
|
||||
throw new Error(`approval response rejected: ${receipt.reason}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full approval-composer props: the framework runtime share (chain currency +
|
||||
* session/global standard kit) plus the chain `matched` share — the entry's
|
||||
* selector result, already narrowed to the approval carrier. No injected
|
||||
* share: the carrier plus the domain face above carry the whole behavior
|
||||
* surface; the paired command line derives from useSession in-component.
|
||||
*/
|
||||
export type ApprovalComposerProps = PropsRuntime<'conversation.composer'> & { matched: ApprovalWait }
|
||||
|
||||
/**
|
||||
* Injected share of the chat view entry: the two callbacks whose targets live
|
||||
* outside the view (layout orchestration; the session object layer).
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Pure derivation of the terminal-card props from a frozen call slice: the
|
||||
* `card:'terminal'` render intent the bash tool declares arrives on the
|
||||
* snapshot as `callView`/`resultView`, and this is the one place that turns
|
||||
* that pair into what {@link TerminalBlock} draws. Both conversation render
|
||||
* sites (the chat tool row's expanded body and the details panel's Output
|
||||
* section) call this, so the command, cwd, output and exit status they show
|
||||
* are derived once.
|
||||
* @module
|
||||
*/
|
||||
import type { TerminalBlockProps } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { resolveToolPath, type ToolCallBlock } from './tool-call-model.ts'
|
||||
|
||||
/**
|
||||
* Output lines the chat row's expanded terminal body shows before collapsing
|
||||
* the middle — half the primitive's own default, which the details panel
|
||||
* keeps. A chat row is a summary surface inside the message flow: the flow
|
||||
* must stay scannable across many calls, while the details panel is the
|
||||
* single-call reading surface. A design constant of this UI's row geometry,
|
||||
* not a deployment choice, so it is fixed here rather than a plugin Config
|
||||
* field.
|
||||
*/
|
||||
export const CHAT_TERMINAL_MAX_LINES = 8
|
||||
|
||||
/**
|
||||
* The {@link TerminalBlock} props this derivation owns. Picked off the
|
||||
* primitive's props so the two stay in step; `home` is absent because the web
|
||||
* client has no home path for the session host (a cwd renders as its last
|
||||
* path segment), and `maxLines`/`className` belong to each render site.
|
||||
*/
|
||||
export interface TerminalCardModel {
|
||||
/**
|
||||
* The props {@link TerminalBlock} draws. Held as a nested object so a render
|
||||
* site spreads exactly the primitive's own surface and can never leak a
|
||||
* neighbouring field into it.
|
||||
*/
|
||||
card: Pick<TerminalBlockProps, 'command' | 'cwd' | 'output' | 'exitCode' | 'signal' | 'running'>
|
||||
/**
|
||||
* The call view's model-authored description, which the contract defines as
|
||||
* rendering ABOVE the card (the card itself has no description slot). Absent
|
||||
* when the presenter supplied none, or when the window dropped the call side;
|
||||
* a row then keeps its args-derived summary.
|
||||
*/
|
||||
description: string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a terminal view's working directory the way the render-intent
|
||||
* contract assigns to the UI bridge: an absolute path is used as-is, a relative
|
||||
* one joins under the session workspace, and an omitted one IS the session
|
||||
* workspace. A pure presenter cannot see the session cwd, which is why this
|
||||
* resolution belongs here rather than in the tool. Without a session cwd there
|
||||
* is nothing to resolve against, so a relative path stays as authored and an
|
||||
* omitted one stays absent (the prompt row then draws a bare `$`).
|
||||
* @param viewCwd - the cwd the terminal call view carries, if any.
|
||||
* @param sessionCwd - the session workspace root, if the caller knows it.
|
||||
* @returns the working directory for the prompt label, or undefined.
|
||||
*/
|
||||
function resolveTerminalCwd(viewCwd: string | undefined, sessionCwd: string | undefined): string | undefined {
|
||||
if (viewCwd === undefined || viewCwd === '') return sessionCwd
|
||||
if (sessionCwd === undefined || sessionCwd === '') return normalizeSegments(viewCwd)
|
||||
return normalizeSegments(resolveToolPath(sessionCwd, viewCwd))
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse `.` and `..` segments so the prompt label names the directory the
|
||||
* command actually ran in. The bash executor resolves the workdir before
|
||||
* running, so a joined `/w/app/..` must display as `w`, not as `..`. Separators
|
||||
* are preserved as authored (a Windows path keeps its backslashes) because this
|
||||
* value is only ever displayed; a `..` that would climb past the root is
|
||||
* dropped, which is what a filesystem does with it. A UNC path's `server` and
|
||||
* `share` are part of its root, not poppable segments: Windows cannot climb
|
||||
* above a share, so `\\\\server\\share` with a `..` stays there.
|
||||
* @param path - a joined or absolute path, possibly carrying `.`/`..` segments.
|
||||
* @returns the same path with those segments resolved.
|
||||
*/
|
||||
function normalizeSegments(path: string): string {
|
||||
if (!/(?:^|[/\\])\.\.?(?:[/\\]|$)/.test(path)) return path
|
||||
// A UNC path is `\\\\server\\share\\...`: the server and share form the root,
|
||||
// so they are split off here and neither is a segment `..` may pop. Its
|
||||
// separator is fixed to a backslash, since a joined relative part may have
|
||||
// introduced a forward slash that UNC syntax does not use.
|
||||
const unc = /^[/\\]{2}([^/\\]+)[/\\]+([^/\\]+)/.exec(path)
|
||||
if (unc !== null) {
|
||||
// Both groups are mandatory in the pattern, so destructuring types them as
|
||||
// strings without an assertion.
|
||||
const [matched, server, share] = unc
|
||||
const root = `\\\\${String(server)}\\${String(share)}`
|
||||
// Rooted: what follows the share hangs off it, so a `..` at the top is
|
||||
// dropped rather than kept — Windows cannot climb above a share.
|
||||
const rest = collapse(path.slice(matched.length), true)
|
||||
return rest === '' ? root : `${root}\\${rest}`
|
||||
}
|
||||
const backslashed = path.includes('\\') && !path.includes('/')
|
||||
const separator = backslashed ? '\\' : '/'
|
||||
const rooted = /^[/\\]/.test(path)
|
||||
const drive = /^[A-Za-z]:/.exec(path)?.[0] ?? ''
|
||||
const body = collapse(path.slice(drive.length), rooted || drive !== '', separator)
|
||||
const leading = rooted ? separator : ''
|
||||
return drive === '' ? `${leading}${body}` : `${drive}${rooted ? leading : separator}${body}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse the `.`/`..` segments of a path body against a known root state.
|
||||
* @param body - the path after any drive letter or UNC root.
|
||||
* @param rooted - the body hangs off a root, so a `..` at its top is dropped
|
||||
* the way a filesystem drops one; without a root the `..` is kept, since it
|
||||
* stays meaningful against a cwd this function cannot see.
|
||||
* @param separator - separator to rejoin with (default `/`).
|
||||
* @returns the collapsed body, without leading or trailing separators.
|
||||
*/
|
||||
function collapse(body: string, rooted: boolean, separator = '/'): string {
|
||||
const kept: string[] = []
|
||||
for (const segment of body.split(/[/\\]/)) {
|
||||
if (segment === '' || segment === '.') continue
|
||||
if (segment === '..') {
|
||||
if (kept.length > 0 && kept[kept.length - 1] !== '..') kept.pop()
|
||||
else if (!rooted) kept.push(segment)
|
||||
continue
|
||||
}
|
||||
kept.push(segment)
|
||||
}
|
||||
return kept.join(separator)
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the terminal-card props for a tool call, or null when this call is
|
||||
* not a terminal card and belongs on the generic path.
|
||||
*
|
||||
* The call side supplies the command and its working directory; the result
|
||||
* side supplies the captured output and exit status. Three cases produce
|
||||
* null, all of them the documented generic-card default:
|
||||
*
|
||||
* - Neither side declares `card:'terminal'` — including a `card` value this
|
||||
* UI version does not know, which arrives over the wire and therefore
|
||||
* cannot be trusted to be one of the compiled variants.
|
||||
* - A settled call whose result view is not a terminal card: the result
|
||||
* presentation decides how the settled call renders, and the bash tool
|
||||
* returns a generic fenced card for an execution error or a background
|
||||
* start, whose text and error styling the generic path preserves.
|
||||
*
|
||||
* Window truncation can drop the call head from a settled result (see
|
||||
* `ToolResultNode.call`/`callView` in dsh-client-runtime), leaving a terminal
|
||||
* result with no call side. That still renders: the command falls back to the
|
||||
* result view's replacement title, then to an empty command (the prompt line
|
||||
* draws bare), and the prompt shows no cwd.
|
||||
* @param block - RunningToolCall or ToolResultNode off the snapshot caches.
|
||||
* @param sessionCwd - the session workspace root, which resolves an omitted or
|
||||
* relative view cwd (see {@link resolveTerminalCwd}); absent leaves both unresolved.
|
||||
* @returns the terminal-card props, or null for the generic path.
|
||||
*/
|
||||
export function terminalCardModel(block: ToolCallBlock, sessionCwd?: string): TerminalCardModel | null {
|
||||
const call = block.callView?.card === 'terminal' ? block.callView : null
|
||||
if (!('kind' in block)) {
|
||||
// Running: the call view exists, the result view does not yet.
|
||||
return call === null ? null : {
|
||||
description: call.description,
|
||||
card: {
|
||||
command: call.title,
|
||||
cwd: resolveTerminalCwd(call.cwd, sessionCwd),
|
||||
output: undefined,
|
||||
exitCode: undefined,
|
||||
signal: undefined,
|
||||
running: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
const result = block.resultView?.card === 'terminal' ? block.resultView : null
|
||||
if (result === null) return null
|
||||
return {
|
||||
description: call?.description,
|
||||
card: {
|
||||
// The result's title REPLACES the pending one when the tool supplies it
|
||||
// (the presentation contract's replacement-title rule); the call title is
|
||||
// what a result without one keeps.
|
||||
command: result.title ?? call?.title ?? '',
|
||||
// Only a PRESENT call view can mean "omitted the cwd, so use the
|
||||
// workspace". When the window dropped the call head there is no cwd
|
||||
// anywhere — the result view carries none — and the original call may
|
||||
// well have used an explicit workdir, so the prompt draws a bare `$`
|
||||
// rather than naming a directory this card cannot know.
|
||||
cwd: call === null ? undefined : resolveTerminalCwd(call.cwd, sessionCwd),
|
||||
output: result.output,
|
||||
exitCode: result.exitCode,
|
||||
signal: result.signal,
|
||||
running: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* Pure row-model derivation for tool summary rows: variant classification,
|
||||
* one-line summary and expanded-body text from the frozen call slice. No
|
||||
* inline output ever — full results live in the details panel.
|
||||
* one-line summary and expanded-body text from the frozen call slice. This
|
||||
* derivation reads the call ARGUMENTS only; a call whose render intent is a
|
||||
* terminal card gets its expanded body from the views instead, through
|
||||
* `terminalCardModel` in terminal-card-model.ts.
|
||||
*/
|
||||
// The block union's defining home is runtime (fold-product types); this
|
||||
// contract only forwards it (type-definition authority stays with the layer
|
||||
|
||||
@@ -297,14 +297,23 @@ export class InputMachine {
|
||||
return []
|
||||
}
|
||||
|
||||
/** Shared chip-insertion transaction: replace [span) with one placeholder occurrence (insert-ref and paste-upgrade both land here). */
|
||||
private replaceSpanWithChip(reference: ReferenceInsert, span: TokenSpan): void {
|
||||
/**
|
||||
* Shared chip-insertion transaction: replace [span) with one placeholder
|
||||
* occurrence (insert-ref and paste-upgrade both land here). A separating
|
||||
* space follows the chip unless one is already next.
|
||||
* @returns the inserted length (placeholder plus optional gap).
|
||||
*/
|
||||
private replaceSpanWithChip(reference: ReferenceInsert, span: TokenSpan): number {
|
||||
this.pushTxn()
|
||||
this.typingRun = undefined
|
||||
this.reconcile({ start: span.start, end: span.end, insertedLength: 1 })
|
||||
const tail = this.draft.slice(span.end)
|
||||
const gap = tail.length === 0 || tail[0] !== ' ' ? ' ' : ''
|
||||
const inserted = PLACEHOLDER + gap
|
||||
this.reconcile({ start: span.start, end: span.end, insertedLength: inserted.length })
|
||||
this.withMinted([this.mint(reference, span.start)])
|
||||
this.adopt(this.draft.slice(0, span.start) + PLACEHOLDER + this.draft.slice(span.end))
|
||||
this.adopt(this.draft.slice(0, span.start) + inserted + tail)
|
||||
this.watchClaim()
|
||||
return inserted.length
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -442,10 +451,10 @@ export class InputMachine {
|
||||
if (attempt === undefined || attempt.attemptId !== attemptId) return []
|
||||
if (this.phase !== 'plain' && this.phase !== 'claimed') return []
|
||||
if (!this.casOk(span) || span.start === span.end) return []
|
||||
this.replaceSpanWithChip(reference, span)
|
||||
const insertedLength = this.replaceSpanWithChip(reference, span)
|
||||
this.paste = {
|
||||
...attempt,
|
||||
insertedRange: { start: attempt.insertedRange.start, end: attempt.insertedRange.end + 1 - (span.end - span.start) },
|
||||
insertedRange: { start: attempt.insertedRange.start, end: attempt.insertedRange.end + insertedLength - (span.end - span.start) },
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/* Composer-takeover approval panel (draft approval.png): the same floating
|
||||
capsule footprint as the InputBar card, with an amber header band, the
|
||||
justification headline, a muted command line, and right-aligned actions.
|
||||
Warn semantics ride the alias state tokens; no hardcoded colors. */
|
||||
|
||||
/* Mirrors InputBar .root so the takeover is a content swap, not a layout jump. */
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 8px 32px 12px;
|
||||
}
|
||||
|
||||
.card {
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
max-width: 776px;
|
||||
border: 1px solid var(--dsw-alias-state-warn-secondary);
|
||||
border-radius: 20px;
|
||||
background: var(--dsw-specific-input-major);
|
||||
box-shadow: var(--dsw-shadow-lv2);
|
||||
}
|
||||
|
||||
/* Tinted full-width header band. */
|
||||
.strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
background: var(--dsw-alias-state-warn-tertiary);
|
||||
color: var(--dsw-alias-state-warn-primary);
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--dsw-alias-state-warn-primary);
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 12px 16px 14px;
|
||||
}
|
||||
|
||||
/* The model's justification is the panel's message, not a footnote. */
|
||||
.headline {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.command {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-family: var(--ds-font-family-code);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.actionRow {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.allow,
|
||||
.reject {
|
||||
padding: 6px 16px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.allow:disabled,
|
||||
.reject:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Primary action: filled ink (draft's rightmost emphasis, minus the dropped
|
||||
always-allow button). */
|
||||
.allow {
|
||||
border: none;
|
||||
background: var(--dsw-alias-label-primary);
|
||||
color: var(--dsw-alias-label-primary-foreground);
|
||||
}
|
||||
|
||||
/* Secondary: quiet outline. */
|
||||
.reject {
|
||||
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.reject:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover-danger);
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
border-color: transparent;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// ApprovalPanel: the composer-takeover approval prompt (designer draft
|
||||
// approval.png), registered as a selector-routed entry of the
|
||||
// conversation-declared composer chain. While an approval question is
|
||||
// pending, this panel occupies the composer slot in place of the InputBar:
|
||||
// an amber "Waiting for approval" strip on the card top, the model's
|
||||
// justification as the headline, the paired command in muted code text, and
|
||||
// a right-aligned refuse/allow action row. One-shot: the buttons disable
|
||||
// after a click and the panel leaves (the InputBar returns) on the broadcast
|
||||
// resolved frame. The draft's "Always allow this type" is deferred with
|
||||
// grant storage.
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { RunningToolCall } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { PendingApproval, type ApprovalComposerProps } from '../contract/slots.ts'
|
||||
import css from './ApprovalPanel.module.css'
|
||||
|
||||
/** Extract the shell command from an approval's paired running call (bash-family args carry `command`); undefined hides the line. */
|
||||
export function commandOf(call: RunningToolCall | undefined): string | undefined {
|
||||
if (call === undefined) return undefined
|
||||
try {
|
||||
const args = JSON.parse(call.argsRaw) as Record<string, unknown>
|
||||
return typeof args.command === 'string' ? args.command : undefined
|
||||
} catch {
|
||||
// Unparseable model args: the panel still renders, just without the command line.
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Composer takeover boundary: mints the domain face on the carrier's stable
|
||||
* identity and remounts the flow per request key, so the one-shot answered
|
||||
* latch never leaks to the next pending approval.
|
||||
* @param props - the selector-matched pending approval carrier plus the framework standard kit.
|
||||
* @returns The approval prompt for this request.
|
||||
*/
|
||||
export function ApprovalPanel(props: ApprovalComposerProps) {
|
||||
const approval = useMemo(() => new PendingApproval(props.matched), [props.matched])
|
||||
const command = props.useSession(s => commandOf(
|
||||
approval.callId === undefined ? undefined : s.runningCalls.find(call => call.callId === approval.callId)))
|
||||
return <ApprovalFlow key={approval.key} pending={approval} {...command === undefined ? {} : { command }} />
|
||||
}
|
||||
|
||||
function ApprovalFlow({ pending, command }: { pending: PendingApproval; command?: string }) {
|
||||
// Local one-shot latch: the panel leaves only when the resolved frame
|
||||
// lands; until then the buttons must not re-fire. An answer failure
|
||||
// (rejected receipt / transport) re-arms them for retry.
|
||||
const [answered, setAnswered] = useState(false)
|
||||
const answer = (outcome: 'allowed-once' | 'rejected'): void => {
|
||||
setAnswered(true)
|
||||
void pending.answer(outcome).catch(() => { setAnswered(false) })
|
||||
}
|
||||
return (
|
||||
<div className={css.root} data-approval-key={pending.key}>
|
||||
<div className={css.card}>
|
||||
<div className={css.strip}><span className={css.dot} />等待审批</div>
|
||||
<div className={css.body}>
|
||||
<div className={css.headline}>{pending.reason ?? `工具 ${pending.toolName} 请求越权执行`}</div>
|
||||
{command !== undefined && <div className={css.command}>{command}</div>}
|
||||
<div className={css.actionRow}>
|
||||
<button type="button" className={css.reject} disabled={answered} onClick={() => { answer('rejected') }}>
|
||||
拒绝
|
||||
</button>
|
||||
<button type="button" className={css.allow} disabled={answered} onClick={() => { answer('allowed-once') }}>
|
||||
允许一次
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -17,6 +17,12 @@
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
/* Blank hero/settling: keep the header node mounted (stable Session tree for
|
||||
the wrapActiveBody composer) without taking column space. */
|
||||
.headerHidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.crumbRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -127,6 +133,46 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Common seat for the composer chain (fallback + elected overlay siblings). */
|
||||
.composerSeat {
|
||||
display: flex;
|
||||
flex: none;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Active phase: header is ordinary column chrome above the scrollport (not
|
||||
sticky). The scroll body holds the transcript and the sticky composer seat
|
||||
so wheel over the footer moves the flow. */
|
||||
.root[data-phase='active'] {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.root[data-phase='active'] .header {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.scrollBody {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.root[data-phase='active'] .viewArea {
|
||||
flex: 1 0 auto;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.root[data-phase='active'] .composerSeat {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
/* Above markdown CodeBlock sticky banners (z-index 6) so the footer never
|
||||
paints under a sticking code header while scrolling. */
|
||||
z-index: 7;
|
||||
background: var(--dsw-alias-bg-base);
|
||||
}
|
||||
|
||||
/* Hero phase: the composer stack (hero chrome + workspace row + card) is
|
||||
flex-centered in the column; composer phase docks it at the bottom. Flex,
|
||||
NOT absolute+transform: a transform would make this box the containing
|
||||
@@ -165,12 +211,15 @@
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.root[data-phase='hero'] {
|
||||
/* Hero: the composer sits inside the session scroll body; center there so
|
||||
the tree seat matches active (sticky footer) without a Root remount. */
|
||||
.root[data-phase='hero'] .scrollBody {
|
||||
justify-content: center;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Settling (session replaying, hero/docked unknown): keep the composer
|
||||
/* Settling (session replaying, hero/docked unknown): keep the composer seat
|
||||
mounted but invisible so no wrong layout flashes before the phase lands. */
|
||||
.root[data-phase='settling'] .composerStack {
|
||||
.root[data-phase='settling'] .composerSeat {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// chain stay mounted across no-session/session transitions. Only the inert
|
||||
// input body swaps for the strict session InputBar.
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationSlotProps, InputZone } from '../contract/slots.ts'
|
||||
@@ -113,24 +113,53 @@ export function ConversationRoot({
|
||||
{hero && <HeroGlow className={css.heroGlow} />}
|
||||
{hero && <HeroShell />}
|
||||
{hero && heroWorkspaceRow}
|
||||
{!hero && zone !== undefined && renderSlot('conversation.input.dock', zone)}
|
||||
{/* Stats band above the input-dock strips so the prior ChatView footer
|
||||
order (stats → todo/queue → card) is preserved under the sticky stack. */}
|
||||
{!hero && zone !== undefined && renderSlot('conversation.composer.dock', zone)}
|
||||
{!hero && zone !== undefined && renderSlot('conversation.input.dock', zone)}
|
||||
{inputBar}
|
||||
</div>
|
||||
)
|
||||
|
||||
const phase = settling ? 'settling' : hero ? 'hero' : 'active'
|
||||
const composer = renderSlotChain(
|
||||
'conversation.composer',
|
||||
{ interactions: pending },
|
||||
{ fallback: composerBar, overlay: true },
|
||||
)
|
||||
|
||||
// Sticky wraps the whole chain output (fallback + elected overlay), not
|
||||
// only `.composerStack`: overlay:true renders those as siblings, and sticky
|
||||
// on the fallback alone would leave Question/Approval panels at the content
|
||||
// end off-screen when the user is not pinned to the floor.
|
||||
const composerSeat = (
|
||||
<div className={css.composerSeat} data-composer-seat="">
|
||||
{composer}
|
||||
</div>
|
||||
)
|
||||
|
||||
// Header stays column chrome above this scrollport; the sticky composer
|
||||
// seat lives inside it with the transcript. Always wrap while a session
|
||||
// exists (hero/settling/active) so the composer keeps one tree seat across
|
||||
// the blank → active flip — relocating it only in active remounted the textarea.
|
||||
const wrapActiveBody = (view: ReactNode): ReactNode => (
|
||||
<div className={css.scrollBody} data-conversation-scroll="">
|
||||
{view}
|
||||
{composerSeat}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={css.root} data-phase={settling ? 'settling' : hero ? 'hero' : 'active'}>
|
||||
<div className={css.root} data-phase={phase}>
|
||||
{/* Mounted for every real session, hero included: ConversationSession
|
||||
renders no chrome while blank but owns the draft-persistence mirror
|
||||
bind — unmounting it in the hero would lose pre-first-send text on
|
||||
a refresh or scope rebuild. */}
|
||||
{sessionId !== undefined && renderSlot('conversation.session', {})}
|
||||
{renderSlotChain(
|
||||
'conversation.composer',
|
||||
{ interactions: pending },
|
||||
{ fallback: composerBar, overlay: true },
|
||||
keeps a chrome-hidden shell while blank and owns the draft-
|
||||
persistence mirror bind — unmounting it in the hero would lose
|
||||
pre-first-send text on a refresh or scope rebuild. */}
|
||||
{sessionId !== undefined && renderSlot(
|
||||
'conversation.session',
|
||||
{ wrapActiveBody },
|
||||
)}
|
||||
{sessionId === undefined ? composerSeat : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Strict per-session conversation content: header, view ring, and chat store bindings. */
|
||||
|
||||
import { useEffect, useSyncExternalStore } from 'react'
|
||||
import { useEffect, useSyncExternalStore, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -24,7 +24,7 @@ function deriveAncestry(list: SessionListState, id: SessionId): readonly Session
|
||||
|
||||
export function ConversationSession({
|
||||
sessionId, useSession, useSessions, useInput, inputActions, useStore, actions,
|
||||
renderSlot, views, bindDraftMirror, open,
|
||||
renderSlot, views, bindDraftMirror, open, wrapActiveBody,
|
||||
}: ConversationSessionProps) {
|
||||
useSyncExternalStore(views.subscribe, views.version)
|
||||
const tabs = views.list()
|
||||
@@ -44,52 +44,67 @@ export function ConversationSession({
|
||||
// the machine mirror, not this seed effect.
|
||||
}, [inputActions])
|
||||
|
||||
if (blank && composerPhase === 'blank') return null
|
||||
// Blank hero/settling: keep the same header + body tree shape so a
|
||||
// wrapActiveBody-hosted composer keeps its DOM identity across the first
|
||||
// send (hero → active). Chrome is hidden; the draft-persistence mirror
|
||||
// still runs because this component stays mounted.
|
||||
const hideChrome = blank && composerPhase === 'blank'
|
||||
|
||||
const view: ReactNode = hideChrome ? null : (
|
||||
<div className={css.viewArea}>
|
||||
{active !== undefined && renderSlot('conversation.view', {}, { only: active.id })}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className={css.header}>
|
||||
<div className={css.crumbRow}>
|
||||
<nav className={css.crumbs} aria-label="Session hierarchy">
|
||||
{ancestry.map((summary, index) => {
|
||||
const last = index === ancestry.length - 1
|
||||
return (
|
||||
<span key={summary.id} className={css.crumbSeg}>
|
||||
{index > 0 && <span className={css.crumbSep}>/</span>}
|
||||
<header
|
||||
className={clsx(css.header, hideChrome && css.headerHidden)}
|
||||
aria-hidden={hideChrome || undefined}
|
||||
>
|
||||
{!hideChrome && (
|
||||
<>
|
||||
<div className={css.crumbRow}>
|
||||
<nav className={css.crumbs} aria-label="Session hierarchy">
|
||||
{ancestry.map((summary, index) => {
|
||||
const last = index === ancestry.length - 1
|
||||
return (
|
||||
<span key={summary.id} className={css.crumbSeg}>
|
||||
{index > 0 && <span className={css.crumbSep}>/</span>}
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.crumb, last && css.crumbCurrent)}
|
||||
disabled={last}
|
||||
onClick={() => { open(summary.id) }}
|
||||
>
|
||||
{summary.displayTitle}
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
{ancestry.length === 0 && <span className={css.crumbCurrent}>{sessionId}</span>}
|
||||
</nav>
|
||||
</div>
|
||||
{tabs.length > 1 && (
|
||||
<div className={css.tabs} role="tablist">
|
||||
{tabs.map(viewTab => (
|
||||
<button
|
||||
key={viewTab.id}
|
||||
type="button"
|
||||
className={clsx(css.crumb, last && css.crumbCurrent)}
|
||||
disabled={last}
|
||||
onClick={() => { open(summary.id) }}
|
||||
role="tab"
|
||||
aria-selected={viewTab.id === active?.id}
|
||||
className={clsx(css.tab, viewTab.id === active?.id && css.tabActive)}
|
||||
onClick={() => { actions.setView(viewTab.id) }}
|
||||
>
|
||||
{summary.displayTitle}
|
||||
{viewTab.label}
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
{ancestry.length === 0 && <span className={css.crumbCurrent}>{sessionId}</span>}
|
||||
</nav>
|
||||
</div>
|
||||
{tabs.length > 1 && (
|
||||
<div className={css.tabs} role="tablist">
|
||||
{tabs.map(view => (
|
||||
<button
|
||||
key={view.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={view.id === active?.id}
|
||||
className={clsx(css.tab, view.id === active?.id && css.tabActive)}
|
||||
onClick={() => { actions.setView(view.id) }}
|
||||
>
|
||||
{view.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</header>
|
||||
<div className={css.viewArea}>
|
||||
{active !== undefined && renderSlot('conversation.view', {}, { only: active.id })}
|
||||
</div>
|
||||
{wrapActiveBody !== undefined ? wrapActiveBody(view) : view}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -92,3 +92,17 @@
|
||||
.code[data-error] {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
/* Above the card, which is where the render-intent contract puts a terminal
|
||||
call's description; the panel has no summary row to carry it. */
|
||||
.terminalDescription {
|
||||
margin: 0 0 6px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
/* The terminal card sits directly under its section label, so it drops the
|
||||
primitive's standalone vertical margin; the section owns the spacing. */
|
||||
.terminal {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -1,47 +1,59 @@
|
||||
// DetailsPanel, P-I minimal form: close button + the selected call's args and
|
||||
// result rendered raw. The three-段 Switch / Prev-Next stepping / See-in-
|
||||
// trajectory are deferred (ledger). Reads the selection from the shared chat
|
||||
// result — args as JSON, the result raw except for a terminal-card call, whose
|
||||
// Output section is the command's terminal card. The three-段 Switch /
|
||||
// Prev-Next stepping / See-in-trajectory are deferred (ledger). Reads the
|
||||
// selection from the shared chat
|
||||
// store (conversation writes, this panel reads — the cross-registration
|
||||
// share the store seat exists for) and derives the call material from the
|
||||
// session snapshot — no data of its own.
|
||||
|
||||
import { CodeBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { CodeBlock, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationSnapshot, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { DetailsSlotProps } from '../contract/slots.ts'
|
||||
import { terminalCardModel } from '../contract/terminal-card-model.ts'
|
||||
import type { ToolCallBlock } from '../contract/tool-call-model.ts'
|
||||
import css from './DetailsPanel.module.css'
|
||||
|
||||
/** Full props composed by reference from the contract (automatic shares & injected share). */
|
||||
export type DetailsPanelProps = DetailsSlotProps
|
||||
|
||||
/** Selected call material: resolved result node, or the in-flight running call's args. */
|
||||
/**
|
||||
* Selected call material: the call's display name and args plus the frozen
|
||||
* block slice it came from. `block` is a snapshot-cached reference, so the
|
||||
* wrapper stays shallow-equal across unrelated snapshot frames; the settled /
|
||||
* running split is read off it with the `'kind' in block` discrimination
|
||||
* instead of duplicated as flags.
|
||||
*/
|
||||
interface CallMaterial {
|
||||
name: string
|
||||
argsRaw: string | null
|
||||
result: ToolResultNode | null
|
||||
running: boolean
|
||||
block: ToolCallBlock
|
||||
}
|
||||
|
||||
/** Material of a settled result node (native call or run_code sub-dispatch). */
|
||||
function settledMaterial(node: ToolResultNode, callId: string): CallMaterial {
|
||||
return { name: node.call?.name ?? callId, argsRaw: node.call?.argsRaw ?? null, block: node }
|
||||
}
|
||||
|
||||
/** Material of an in-flight call (native call or run_code sub-dispatch). */
|
||||
function runningMaterial(call: RunningToolCall): CallMaterial {
|
||||
return { name: call.name, argsRaw: call.argsRaw, block: call }
|
||||
}
|
||||
|
||||
function materialFor(s: ConversationSnapshot, callId: string): CallMaterial | null {
|
||||
for (const node of s.nodes) {
|
||||
if (node.kind === 'tool-result' && node.callId === callId) {
|
||||
return { name: node.call?.name ?? callId, argsRaw: node.call?.argsRaw ?? null, result: node, running: false }
|
||||
}
|
||||
if (node.kind === 'tool-result' && node.callId === callId) return settledMaterial(node, callId)
|
||||
}
|
||||
const open = s.runningCalls.find(c => c.callId === callId)
|
||||
if (open !== undefined) {
|
||||
return { name: open.name, argsRaw: open.argsRaw, result: null, running: true }
|
||||
}
|
||||
if (open !== undefined) return runningMaterial(open)
|
||||
// run_code sub-dispatches: the native call-block shapes, so a selected
|
||||
// sub-row resolves through the same material as a native call — the
|
||||
// settled ToolResultNode form, or the RunningToolCall form mid-flight.
|
||||
for (const subs of s.codeDispatches.values()) {
|
||||
for (const sub of subs) {
|
||||
if (sub.callId !== callId) continue
|
||||
if ('kind' in sub) {
|
||||
return { name: sub.call?.name ?? callId, argsRaw: sub.call?.argsRaw ?? null, result: sub, running: false }
|
||||
}
|
||||
return { name: sub.name, argsRaw: sub.argsRaw, result: null, running: true }
|
||||
return 'kind' in sub ? settledMaterial(sub, callId) : runningMaterial(sub)
|
||||
}
|
||||
}
|
||||
return null
|
||||
@@ -56,8 +68,11 @@ function pretty(raw: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function DetailsPanel({ useSession, useStore, closeDetails }: DetailsPanelProps) {
|
||||
export function DetailsPanel({ useSession, useSessions, sessionId, useStore, closeDetails }: DetailsPanelProps) {
|
||||
const selection = useStore(s => s.selection)
|
||||
// Session workspace root: an omitted or relative terminal cwd resolves
|
||||
// against it, which the pure presenter cannot see.
|
||||
const sessionCwd = useSessions(list => list.byId[sessionId]?.cwd)
|
||||
const callId = selection?.callId
|
||||
// materialFor builds a fresh wrapper; shallowEqual short-circuits on its
|
||||
// stable members (result node reference rides the snapshot's structural sharing).
|
||||
@@ -95,15 +110,11 @@ export function DetailsPanel({ useSession, useStore, closeDetails }: DetailsPane
|
||||
)}
|
||||
<section className={css.section}>
|
||||
<div className={css.sectionLabel}>Output</div>
|
||||
{/* materialFor invariant: result===null ⇔ running (a settled
|
||||
material always carries its result node). */}
|
||||
{material.result === null
|
||||
? <div className={css.empty}>运行中…</div>
|
||||
: (
|
||||
<pre className={css.code} data-error={material.result.isError || undefined}>
|
||||
{renderResult(material.result)}
|
||||
</pre>
|
||||
)}
|
||||
{/* Keyed by the selected call: the body owns per-call view
|
||||
state (the terminal card's expand and copy), which React
|
||||
would otherwise carry into the next selection because the
|
||||
panel does not unmount between calls. */}
|
||||
<OutputBody key={callId} material={material} cwd={sessionCwd} />
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
@@ -112,6 +123,41 @@ export function DetailsPanel({ useSession, useStore, closeDetails }: DetailsPane
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The Output section's body for the selected call. A terminal-card call — a
|
||||
* shell command's call/result views — renders through the shared TerminalBlock
|
||||
* at the primitive's own full height allowance, so column-aligned output keeps
|
||||
* its alignment and scrolls sideways instead of folding. Every other call, and
|
||||
* a running call with no terminal card yet, keeps the flattened text form.
|
||||
* @param props.material - the selected call's material from {@link materialFor}.
|
||||
* @param props.cwd - the session workspace root, resolving the terminal view's cwd.
|
||||
* @returns the Output section's body element.
|
||||
*/
|
||||
function OutputBody({ material, cwd }: { material: CallMaterial; cwd: string | undefined }) {
|
||||
const terminal = terminalCardModel(material.block, cwd)
|
||||
if (terminal !== null) {
|
||||
// The contract renders the presenter's description above the card, and the
|
||||
// panel has no summary row to carry it, so it is drawn here.
|
||||
return (
|
||||
<>
|
||||
{terminal.description !== undefined && (
|
||||
<div className={css.terminalDescription}>{terminal.description}</div>
|
||||
)}
|
||||
<TerminalBlock {...terminal.card} className={css.terminal} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
// A settled call always carries the result node the flattened form needs;
|
||||
// the running shape has no result to flatten.
|
||||
if (!('kind' in material.block)) return <div className={css.empty}>运行中…</div>
|
||||
const result = material.block
|
||||
return (
|
||||
<pre className={css.code} data-error={result.isError || undefined}>
|
||||
{renderResult(result)}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
/** Flatten result content blocks to display text (text blocks verbatim, others as JSON). */
|
||||
function renderResult(node: ToolResultNode): string {
|
||||
const parts: string[] = []
|
||||
|
||||
@@ -115,9 +115,9 @@ export function HeroShell({ children }: HeroShellProps) {
|
||||
Let's start building
|
||||
</div>
|
||||
<div className={css.body}>
|
||||
{/* The resident composer (rendered by ConversationRoot at its stable
|
||||
tree position; the workspace row rides its accessory hole) is
|
||||
CSS-positioned into this gap during the hero phase — see
|
||||
{/* The resident composer (ConversationRoot wrapActiveBody seat; the
|
||||
workspace row rides the stack above the card) is CSS-centered in
|
||||
the session scroll body during hero — see
|
||||
ConversationRoot.module.css [data-phase='hero']. */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -125,20 +125,18 @@
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
color: transparent;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hlToken {
|
||||
border-radius: 4px;
|
||||
/* GOAL 稿 amber token emphasis (state warn pair; glyphs stay the textarea's). */
|
||||
background: var(--dsw-alias-state-warn-tertiary);
|
||||
color: transparent;
|
||||
background-color: transparent;
|
||||
color: var(--dsw-alias-state-warn-label);
|
||||
}
|
||||
|
||||
.hlSegment {
|
||||
border-radius: 4px;
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
background-color: transparent;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
@@ -170,7 +168,7 @@
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
color: transparent;
|
||||
/* Business blue, not brand-primary: that token resolves to ink in this sheet. */
|
||||
caret-color: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
@@ -348,25 +346,13 @@
|
||||
draft's own glyphs — advance untouched, so the two layers cannot drift.
|
||||
Chip family colors; clone keeps rounded ends on soft-wrap fragments. */
|
||||
.textRef {
|
||||
color: transparent;
|
||||
background-color: transparent;
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
box-decoration-break: clone;
|
||||
-webkit-box-decoration-break: clone;
|
||||
position: relative;
|
||||
}
|
||||
.textRef:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
border-radius: 6px;
|
||||
background: rgba(97, 135, 216, 0.22);
|
||||
transform: translate(-2px, -1px);
|
||||
padding: 2px 4px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Reference chip: rendered in the backdrop at the placeholder offset. Hard
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user