diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml index 38a665a0fd..85a8dd684b 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md -2026-06-18-shared-persistence-write-coordinator.md: 93b6cd1bd058499e71948d3909de8e4c076b445e -2026-06-18-shared-persistence-write-coordinator.zh.md: 9e1bc736d4dba5e59b763710976425fa0ae76c30 +2026-06-18-shared-persistence-write-coordinator.md: 286bbb7d5cd3720109db0d0abc0bb72ddbfcbdcd +2026-06-18-shared-persistence-write-coordinator.zh.md: d24398c4bc9445739b4cdeb9f2ed176577060932 diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md index 93b6cd1bd0..286bbb7d5c 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md +++ b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md @@ -16,6 +16,10 @@ Composition, not inheritance. The coordinator is a concrete class the backend ho The coordinator holds one lifecycle entry for each exact live `Session`: initialization plus a package-private write controller that owns pending events, a fixed batching deadline, the active write, failure retention, and the shared flush barrier. Each `session/event` enters that bounded write path, and `session/flush` bypasses the wait to observe quiescence. The [flush-controller simplification](../simplification/2026-07-23-collapse-persistence-flush-state.md) owns controller consolidation; the [bounded batching decision](2026-08-08-bounded-session-persistence-write-batching.md) owns scheduling cadence. +Creation borrows the exact `Session.events` snapshot as its persistence seed. `Session` has already detached, validated, and deeply frozen every event, and the snapshot array remains stable when later appends replace the cached view. The coordinator and its backend hooks only read this typed in-process value, so cloning the complete log again would duplicate the ownership work described by the [agent-scope runtime decision](2026-07-12-agent-scope-runtime-design.md#session-append-materialize-validate-commit-notify). Public persistence `append()` still snapshots caller-owned input at its API boundary. + +Prepared-session suffixes and events admitted to the write-behind queue retain their existing copies. Those paths establish asynchronous queue ownership one suffix or event at a time and have no measured whole-log clone cost; removing their copies remains a separate ownership audit rather than part of creation-seed borrowing. + The coordinator retires a session from `session/disposed`: it waits for the controller's initialization and current flush, serializes a final drain, and removes the controller and owned per-id state only after success. A failure leaves the controller discoverable for backend teardown to retry. Settled per-id chain tails remove themselves only when they are still current, so a completion cannot erase a newer operation for the same id. Backend teardown unregisters write-path listeners, flushes every remaining controller, awaits per-id operations, and then closes the backend. ### The hook interface (`PersistenceBackend`) diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md index 9e1bc736d4..d24398c4bc 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md @@ -16,6 +16,10 @@ Status: implemented 协调器为每个存活的 `Session` 实例持有一个生命周期条目:初始化,加上一个包私有写入控制器,后者负责待处理事件、固定批处理截止时间、活跃写入、失败保留和共享 flush 屏障。每个 `session/event` 都进入这条有界写入路径,`session/flush` 则绕过等待以观察完全停稳。控制器归并由 [flush 控制器简化](../simplification/2026-07-23-collapse-persistence-flush-state.md)定义;调度节奏由[有界批处理决策](2026-08-08-bounded-session-persistence-write-batching.md)定义。 +创建流程将 `Session.events` 的原始快照借作持久化种子。`Session` 已经分离、验证并深度冻结每个事件,后续追加会替换缓存视图,因此该快照数组保持稳定。协调器及其后端钩子只读取这个有类型的进程内值;再次克隆完整日志会重复 [agent scope 运行时决策](2026-07-12-agent-scope-runtime-design.md#session-append-materialize-validate-commit-notify)规定的所有权工作。持久化服务的公开 `append()` 仍在 API 边界为调用方拥有的输入创建快照。 + +已准备 Session 的后缀,以及进入 write-behind 队列的事件,仍保留现有复制。这些路径会逐个后缀或事件建立异步队列所有权,且没有已测得的完整日志克隆成本;移除这些复制属于单独的所有权审计,不属于创建种子的借用决策。 + 协调器通过 `session/disposed` 退役会话:它等待控制器完成初始化和当前 flush,串行执行最后一次排空,且仅在成功后才移除控制器与其拥有的每 id 状态。失败时保持控制器可被找到,以供后端 teardown(拆除)重试。每个 id 的已结算链尾仅在其仍是当前链尾时才移除自身,因此旧操作完成后不会抹除同一 id 的新操作。后端 teardown 会注销写入路径监听器、flush 每个剩余的控制器、等待所有按 id 串行化的操作,最后关闭后端。 ### 钩子接口(`PersistenceBackend`) diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml index 340f07b15c..ef95863be1 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md -2026-07-24-web-config-tree-boot-and-transport-layering.md: f397ca6a25cffa0ba709d0a8e84717a1c228b951 -2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: e1cde78bad16d618913e52894eff1271dac74be1 +2026-07-24-web-config-tree-boot-and-transport-layering.md: eb30ba84ef293a169931ef6519a9d6d2ea98af7f +2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 7a175b44fa8806b4b73eca538f32f16aa778fdee diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md index f397ca6a25..eb30ba84ef 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md @@ -18,7 +18,7 @@ English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md) **Config sources have one declaration place each.** Bundle yml values are engineering defaults, Settings sections are writable user preferences, CLI flags address their owning launcher rows, and env values enter through yml `!!js` expressions. Patches replace a row's config wholesale. The resolved frontend `distIndex` uses that patch channel as an assembly fact. The transport-independent provider/model default belongs to `ctx.agentDefaultModel`; the [direct headless entry point](2026-08-09-headless-direct-core-entry-point.md) and the Web gateway consume the same state. -**The transport splits five ways.** `dsh-host-apiproxy` is the gateway plugin (`api-gateway` row): it default-exports `ApiProxyService`, configures only `{nativeOpen?}`, consumes the base layer's entry-point-neutral `ctx.agentDefaultModel`, provides `ctx.apiProxy`, remains transport-agnostic, and registers no routes. `dsh-host-webserver` is a plain route-registration plugin: `WebServer` provides `ctx.webServer` (`register(route) → disposer` with duplicate-pattern throw, `tapIndex` transforms applied in registration order, `port`), listens on activation, answers per-request failures with 400 and logging, and knows no harness concepts. The connection node half owns the `/api` binding from `ctx.apiProxy` through `toFetchHandler`. The modules node half (`ClientModuleRegistry`, providing `ctx.clientModules`) owns incremental package scanning, the bundle route, the index tap, and `onRebuilt`/`onGraphChanged` notification. The hmr node half owns dev reload through `fs.watchFile` membership and the `/plugins/events` SSE route. +**The transport splits five ways.** `dsh-host-apiproxy` is the gateway plugin (`api-gateway` row): it default-exports `ApiProxyService`, configures only `{nativeOpen?}`, consumes the base layer's entry-point-neutral `ctx.agentDefaultModel`, provides `ctx.apiProxy`, remains transport-agnostic, and registers no routes. `dsh-host-webserver` is a plain route-registration plugin: `WebServer` provides `ctx.webServer` (`register(route) → disposer` with duplicate-pattern throw, `renderIndex` rendering — structured `webserver/index-inject` rows, then raw `tapIndex` transforms in registration order — and `port`), listens on activation, answers per-request failures with 400 and logging, and knows no harness concepts. The connection node half owns the `/api` binding from `ctx.apiProxy` through `toFetchHandler`. The modules node half (`ClientModuleRegistry`, providing `ctx.clientModules`) owns incremental package scanning, the bundle route, the boot injection rows, and `onRebuilt`/`onGraphChanged` notification. The hmr node half owns dev reload through `fs.watchFile` membership and the `/plugins/events` SSE route. **Package export discipline.** The modules package exposes exactly `.` (node half) and `./client` (the complete browser half: `ClientModuleSystem`, `parseBootManifest`, the adoption plugin face) — no bespoke subpaths; wire types re-export through the root for host-side consumers. The adoption handshake: the kernel writes the constructed instance to `window.__DSH_MODULES__` before cordis exists; the `./client` apply reads the slot (missing = loud throw) and provides `ctx.modules`. diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md index e1cde78bad..7a175b44fa 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md @@ -18,7 +18,7 @@ Status: implemented **每个配置源有唯一声明位置。** 组合包 yml 值是工程默认,Settings 分节是可写的用户偏好,CLI(命令行界面)flags 面向其归属的启动器配置行,env 值则通过 yml `!!js` 表达式进入。patch 会整体替换一行的 config。解析后的前端 `distIndex` 通过同一条 patch 通道作为组装事实传递。与传输无关的提供方/模型默认值归 `ctx.agentDefaultModel` 所有;[直接 headless 入口](2026-08-09-headless-direct-core-entry-point.md)与 Web 网关消费同一份状态。 -**传输五分。** `dsh-host-apiproxy` 是网关插件(`api-gateway` 行):默认导出 `ApiProxyService`,只配置 `{nativeOpen?}`,消费 base 层不偏向特定入口的 `ctx.agentDefaultModel`,provide `ctx.apiProxy`,保持传输无关且不注册路由。`dsh-host-webserver` 是朴素的路由注册插件:`WebServer` provide `ctx.webServer`(`register(route) → disposer`、重复 pattern 即抛、`tapIndex` 按注册序应用、`port`),激活即 listen,单请求失败时答 400 并记日志,且不认识任何 harness 概念。connection node 半拥有从 `ctx.apiProxy` 经 `toFetchHandler` 绑定到 `/api` 的逻辑。modules node 半(`ClientModuleRegistry`,provide `ctx.clientModules`)拥有单包增量扫描、bundle 路由、index tap 与 `onRebuilt`/`onGraphChanged` 通知。HMR(热模块替换) node 半通过 `fs.watchFile` membership 与 `/plugins/events` SSE 路由拥有开发期重载。 +**传输五分。** `dsh-host-apiproxy` 是网关插件(`api-gateway` 行):默认导出 `ApiProxyService`,只配置 `{nativeOpen?}`,消费 base 层不偏向特定入口的 `ctx.agentDefaultModel`,provide `ctx.apiProxy`,保持传输无关且不注册路由。`dsh-host-webserver` 是朴素的路由注册插件:`WebServer` provide `ctx.webServer`(`register(route) → disposer`、重复 pattern 即抛、`renderIndex` 渲染——先结构化 `webserver/index-inject` 行、后原始 `tapIndex` 按注册序应用——与 `port`),激活即 listen,单请求失败时答 400 并记日志,且不认识任何 harness 概念。connection node 半拥有从 `ctx.apiProxy` 经 `toFetchHandler` 绑定到 `/api` 的逻辑。modules node 半(`ClientModuleRegistry`,provide `ctx.clientModules`)拥有单包增量扫描、bundle 路由、启动注入行与 `onRebuilt`/`onGraphChanged` 通知。HMR(热模块替换) node 半通过 `fs.watchFile` membership 与 `/plugins/events` SSE 路由拥有开发期重载。 **包出口纪律。** modules 包只暴露 `.`(node 半)与 `./client`(完整浏览器半:`ClientModuleSystem`、`parseBootManifest`、收编插件面)——不设专用子路径;wire 类型经根出口 re-export 给 host 侧消费方。收编握手:内核在 cordis 之前把建好的实例写入 `window.__DSH_MODULES__`;`./client` 的 apply 读取该槽位(缺少时显式抛错)并 provide `ctx.modules`。 diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml index 5db1a9baf8..20edc37d09 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md -2026-08-04-configuration-source-ownership.md: c90979ff3ade89c11af7fb9a73d536aa2c1daa12 -2026-08-04-configuration-source-ownership.zh.md: 31a813eddf4aefaf4bce1fd9999660abc8032c29 +2026-08-04-configuration-source-ownership.md: 2cd09ae2daca2b15657caa18ff210fa178c2999b +2026-08-04-configuration-source-ownership.zh.md: 76625cade4b1034c2327344945f80e3a4fec5bdc diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md index c90979ff3a..2cd09ae2da 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md @@ -42,7 +42,7 @@ The launching environment wins because `DEEPSEEK_API_KEY=… dsh`, a CI secret, **The project the harness is launched in is trusted, by default and without a prompt.** A checkout may carry its own endpoint, its own ordinary variables, and its own key; the key ranks below the managed store, so a key stored through the Models page is never displaced by one a checkout happens to contain. `LaunchEnvironmentSnapshot.getFrom(name, sources)` still searches only the layers a caller names, and omitting one is a refusal rather than a demotion — the mechanism exists for the decisions where a layer must be unreachable, not because the project is one of them today. -**Trust does not extend to changing the harness itself.** `loadLayeredEnv` rejects, at load and before anything is materialized, any `.env` that sets a variable governing how a process launches (`PATH`, `SHELL`, `NODE_OPTIONS`, `LD_PRELOAD`), what code a runtime executes before the program it was asked to run (`BASH_ENV`, `PERL5OPT`, `PYTHONSTARTUP`, `RUBYOPT`, `JAVA_TOOL_OPTIONS`, the Git hook commands), where model-visible instructions load from (the whole `DSH_*` namespace, `HOME`, `XDG_*`), or how the network is reached and trusted (proxy and CA variables). Matching is case-insensitive, so `https_proxy` is not a bypass. +**Trust does not extend to changing the harness itself.** `loadLayeredEnv` rejects, at load and before anything is materialized, any `.env` that sets a variable governing how a process launches (`PATH`, `SHELL`, `NODE_OPTIONS`, `LD_PRELOAD`), which ambient program handles an operation (`EDITOR`, `PAGER`, `BROWSER`), what code a runtime executes before the program it was asked to run (`BASH_ENV`, `PERL5OPT`, `PYTHONSTARTUP`, `RUBYOPT`, `JAVA_TOOL_OPTIONS`, the Git hook commands), where model-visible instructions load from (the whole `DSH_*` namespace, `HOME`, `XDG_*`), or how the network is reached and trusted (proxy and CA variables). Matching is case-insensitive, so `https_proxy` is not a bypass. The line is that these take effect with no user action, before any turn, outside the permission policy and the sandbox. `DSH_PERMISSION_MODE` would switch off the approvals that make trusting a project meaningful at all, and `BASH_ENV` runs a file of the project's choosing on every single `bash -c` the bash tool issues — the project's code running under the agent's policy is the deal; the project rewriting that policy is not. Enumerating these is a losing game one variable at a time, which is why the whole `DSH_*` namespace is denied rather than an audited subset, and why the list is organised by what a variable *does* rather than by which runtime owns it. There is no opt-out: an escape hatch would have to be readable from somewhere, and anything a discovered file could set is the hole itself. @@ -53,7 +53,7 @@ The line is that these take effect with no user action, before any turn, outside ## Consequences - The web credential form now takes effect against an older key in the user's `.env`; only a key exported in the launching shell still makes it read-only, and the diagnostic says so. -- A `.env` holding `DSH_*`, `PATH`, or a proxy variable fails the launch instead of being applied. Developers keeping switches in a repository `.env` move them to their shell — a deliberate, loud break. +- A `.env` holding `DSH_*`, `PATH`, `BROWSER`, or a proxy variable fails the launch instead of being applied. Developers keeping switches in a repository `.env` move them to their shell — a deliberate, loud break. - Composition is no longer overridable by a stale shell endpoint. It is still overridable by a user's stored `settings.yaml`, which is the settings seam's layering and not something this note changes; the product CLI offers no flag above it, so a deployment that must win against stored settings owns its own bin or loader tree. - Not solved: the layers are still materialized into `process.env`, so ordinary project variables continue to reach child processes under the subprocess scrub. Bootstrap variables cannot come from a file at all; the environment package records the remaining subprocess reach as a limitation. - Exa and Perplexity still capture their key at load time rather than through the credential seam. They no longer read raw `process.env` — they resolve through the trusted layers — but converting them to per-request credential resolution is separate work. diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md index 31a813eddf..76625cade4 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md @@ -43,7 +43,7 @@ inherited process environment (read-only, wins) **harness 被启动于其中的项目默认可信,且不做询问。** 一个 checkout 可以携带自己的 endpoint、自己的普通变量和自己的密钥;密钥排在受管存储之下,因此通过 Models 页存下的密钥绝不会被 checkout 中恰好带有的那一个顶掉。`LaunchEnvironmentSnapshot.getFrom(name, sources)` 仍然只搜索调用方点名的层,省略某层仍是拒绝而不是降级——该机制是为「某一层必须不可达」的那些决策准备的,而项目层今天不在其列。 -**信任不延伸到改变 harness 本身。** `loadLayeredEnv` 会在加载时、且在物化任何内容之前,拒绝任何设置了下列变量的 `.env`:决定进程如何启动的(`PATH`、`SHELL`、`NODE_OPTIONS`、`LD_PRELOAD`)、决定运行时在执行被要求运行的程序之前先执行哪些代码的(`BASH_ENV`、`PERL5OPT`、`PYTHONSTARTUP`、`RUBYOPT`、`JAVA_TOOL_OPTIONS`、Git 的钩子命令)、决定模型可见指令从哪里加载的(整个 `DSH_*` 命名空间、`HOME`、`XDG_*`),以及决定网络如何访问以及如何建立信任的(proxy 与 CA 变量)。匹配不区分大小写,因此 `https_proxy` 不是绕过手段。 +**信任不延伸到改变 harness 本身。** `loadLayeredEnv` 会在加载时、且在物化任何内容之前,拒绝任何设置了下列变量的 `.env`:决定进程如何启动的(`PATH`、`SHELL`、`NODE_OPTIONS`、`LD_PRELOAD`)、决定由哪个环境程序处理一项操作的(`EDITOR`、`PAGER`、`BROWSER`)、决定运行时在执行被要求运行的程序之前先执行哪些代码的(`BASH_ENV`、`PERL5OPT`、`PYTHONSTARTUP`、`RUBYOPT`、`JAVA_TOOL_OPTIONS`、Git 的钩子命令)、决定模型可见指令从哪里加载的(整个 `DSH_*` 命名空间、`HOME`、`XDG_*`),以及决定网络如何访问以及如何建立信任的(proxy 与 CA 变量)。匹配不区分大小写,因此 `https_proxy` 不是绕过手段。 这条界线在于:它们无需任何用户动作、在任何轮次开始之前、且在权限策略与沙箱之外就生效。`DSH_PERMISSION_MODE` 会关掉让「信任项目」根本成立的那道审批,而 `BASH_ENV` 会在 bash 工具每次发出 `bash -c` 时执行项目指定的文件——项目的代码在 agent(智能体)的策略下运行是约定,项目改写那份策略不是。一个变量一个变量地枚举是必输的游戏,所以整个 `DSH_*` 命名空间被拒绝而不是只拒绝一份经审查的子集,也所以这份清单是按变量*做什么*而不是按哪个运行时拥有它来组织的。不设逃生门:逃生门本身总得从某处读取,而任何被发现的文件能设置的东西,就是那个漏洞本身。 @@ -54,7 +54,7 @@ inherited process environment (read-only, wins) ## Consequences - Web 凭据表单现在能压过用户 `.env` 里更旧的密钥;只有在启动 shell 里 export 的密钥才会让它变成只读,诊断信息也会这么说。 -- 含 `DSH_*`、`PATH` 或 proxy 变量的 `.env` 会导致启动失败而不是被应用。把开关放在仓库 `.env` 里的开发者需要改放到 shell——这是一次刻意且响亮的破坏。 +- 含 `DSH_*`、`PATH`、`BROWSER` 或 proxy 变量的 `.env` 会导致启动失败而不是被应用。把开关放在仓库 `.env` 里的开发者需要改放到 shell——这是一次刻意且响亮的破坏。 - composition 不再会被陈旧的 shell endpoint 覆盖。但它仍然会被用户已存的 `settings.yaml` 覆盖,这是 settings seam 的分层方式,本 Note 不改变它;产品 CLI 没有高于它的标志,因此需要压过已存 settings 的部署方要自带 bin 或 loader 配置树。 - 未解决的:各层仍然会被物化进 `process.env`,因此普通项目变量继续按子进程清洗规则抵达子进程。bootstrap 变量完全不能来自文件;环境包将其余变量仍可抵达子进程这一点记录为一项限制。 - Exa 与 Perplexity 仍在加载时捕获密钥,而不是经凭据 seam。它们不再读裸 `process.env`——改为经受信层解析——但把它们改造成按请求解析凭据是另一件事。 diff --git a/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.i18n.yaml new file mode 100644 index 0000000000..50cdedbe5b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md +2026-08-10-cancelled-stream-prefix-finalize.md: 0cae25b786922fba8204d68ca9c0a669e43d76a0 +2026-08-10-cancelled-stream-prefix-finalize.zh.md: e961ea6a51f74dcc244e4ad8970eae4cbe4c9a6c diff --git a/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md new file mode 100644 index 0000000000..0cae25b786 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md @@ -0,0 +1,39 @@ +# Agent Note: Cancelled streams finalize their delivered prefix + +Status: implemented + +English | [中文](2026-08-10-cancelled-stream-prefix-finalize.zh.md) + +## Problem + +A cancelled stream can leave `assistant/chunk` events that clients continue rendering while `deriveMessages()` excludes them because no `assistant/message` records the delivered prefix. A follow-up such as "expand on your second point" then lacks text the user read, and a fork at the cancelled turn inherits the same gap. + +The model history must contain assistant content that remains visible to the user after cancellation. + +## Decision + +`ReactLoopAgent.step()` catches cancellation while consuming a model stream, when its `BlockAssembler`, logged chunk seqs, and provider route identify the delivered prefix. It appends that prefix as the step's `assistant/message` with `interrupted: true`, `surfaceOp: 'append'`, and `sourceEventSeqs` containing exactly the logged chunks. The append precedes `step/end` and the aborted `turn/end`. + +`BlockAssembler.interruptedBlocks()` returns closed and open `text` and `reasoning` blocks with non-whitespace content in stream order. It omits tool calls because interruption precedes dispatch and no real result exists; it also omits empty blocks and open unknown block types. An empty result appends no assistant message. Provider `error` and `aborted` finishes leave the stream-consumption scope before `agent/request-error`, so provider failures and cancellation during recovery commit no content from the failed request. + +Chat and Trajectory Conversation Definitions read `interrupted` from the durable message. Chat renders the Stopped marker, while Trajectory keeps the provider request in the error lifecycle after `step/end` and retains the durable result seq and provenance. Cancellation during tool execution follows the tool scheduler contract because the assistant message has already committed: started calls produce real results, and undispatched calls receive `ABORTED_BEFORE_DISPATCH` results. + +## Alternatives considered + +**Always discard the prefix.** This avoids a new durable marker but makes every cancel-then-follow-up and fork omit assistant content that remains visible to the user. + +**Assemble the prefix from chunks during projection.** `deriveMessages()` and client Conversation Definitions would each need interruption assembly rules, and the log would have no authoritative assistant message for the prefix. This also expands model history beyond the three `SurfaceEventType` events. + +**Retain complete tool calls with synthetic aborted results.** These calls never dispatched, so synthetic results would claim an execution outcome that did not occur and add content the user did not receive as a tool result. + +**Append a model-visible interruption message such as `[interrupted by user]`.** This can tell the model that the prefix is incomplete, but it requires a separate source type, projection rule, UI treatment, and localized wording. The durable aborted `turn/end` preserves the fact needed for that later decision. + +## Consequences + +Post-cancel follow-ups and forks include the delivered prefix. The ACP bridge drains ordered assistant output before settling the prompt, so the final `agent_message_chunk` update precedes the cancelled stop reason. + +Terminal provider errors still discard their streamed prefix. That asymmetry remains because an error turn ends without the user's cancellation decision and requires its own retention policy. + +## Testing + +`packages/core/agent-loop/tests/cancel.spec.ts` covers content, cited seqs, event order, next-request parity, reasoning-only output, tool-call omission, recovery cancellation, and the empty-prefix case. `packages/llm/llm/tests/assembler.spec.ts` covers `interruptedBlocks()`. `packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts` and `packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts` cover both client projections. The keyless `cancel` ACP snapshot and `goal-round-driver` goal snapshot cover assembled applications. diff --git a/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.zh.md b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.zh.md new file mode 100644 index 0000000000..e961ea6a51 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.zh.md @@ -0,0 +1,39 @@ +# Agent Note: 被取消的流定稿其已送达前缀 + +Status: implemented + +[English](2026-08-10-cancelled-stream-prefix-finalize.md) | 中文 + +## Problem + +被取消的流可能留下客户端继续渲染的 `assistant/chunk` 事件,但如果没有 `assistant/message` 记录已送达前缀,`deriveMessages()` 就会排除这部分内容。后续的「第二点展开讲讲」之类追问会缺少用户已读到的文本,在该轮次上创建的分支也会继承这个缺口。 + +模型历史必须包含取消后仍对用户可见的 assistant 内容。 + +## Decision + +`ReactLoopAgent.step()` 在消费模型流期间捕捉取消,此时 `BlockAssembler`、已记录的分片 seq 和提供方路由可以确定已送达前缀。循环把该前缀追加为 step 的 `assistant/message`,并设置 `interrupted: true`、`surfaceOp: 'append'` 以及恰好包含已记录分片的 `sourceEventSeqs`。该追加先于 `step/end` 和记录 aborted 的 `turn/end`。 + +`BlockAssembler.interruptedBlocks()` 按流顺序返回内容非空白的已闭合和未闭合 `text` 与 `reasoning` 块。打断先于分派,没有真实工具结果,因此它会省略工具调用,也会省略空块和未闭合的未知块类型。返回结果为空时不追加 assistant 消息。提供方的 `error` 和 `aborted` finish 会在 `agent/request-error` 前离开流消费范围,因此提供方故障和恢复期间的取消都不会提交失败请求的内容。 + +Chat 和 Trajectory Conversation Definition 从持久消息读取 `interrupted`。Chat 渲染 Stopped 标记,Trajectory 则在 `step/end` 后把提供方请求保持在 error 生命周期,并保留持久结果 seq 和提供方信息。工具执行期间的取消遵循工具调度器约定,因为 assistant 消息已提交:已启动的调用生成真实结果,未分派的调用获得 `ABORTED_BEFORE_DISPATCH` 结果。 + +## Alternatives considered + +**始终丢弃前缀。** 这能避免新增持久标记,但每次取消后的追问和分支都会缺少仍对用户可见的 assistant 内容。 + +**在投影时从分片组装前缀。** `deriveMessages()` 和客户端 Conversation Definition 都需要实现打断组装规则,日志中也没有该前缀的权威 assistant 消息。这还会让模型历史超出三类 `SurfaceEventType` 事件。 + +**保留完整工具调用并合成 aborted 结果。** 这些调用从未分派,合成结果会声称一个并未发生的执行结果,还会增加用户未收到的工具结果内容。 + +**追加 `[interrupted by user]` 之类模型可见的打断消息。** 这可以告诉模型前缀并不完整,但需要独立的来源类型、投影规则、UI 处理和本地化文案。持久的 aborted `turn/end` 保留了该后续决策所需的事实。 + +## Consequences + +取消后的追问和分支会包含已送达前缀。ACP 桥会在结算 prompt 前排空按序传送的 assistant 输出,因此最后一条 `agent_message_chunk` 更新先于 cancelled stop reason。 + +终局提供方错误仍会丢弃已流出前缀。该不对称保留,因为 error 轮次的结束不来自用户的取消决定,需要独立的保留策略。 + +## Testing + +`packages/core/agent-loop/tests/cancel.spec.ts` 覆盖内容、引用的 seq、事件顺序、下一请求的一致性、仅 reasoning 的输出、工具调用省略、恢复期间的取消和空前缀情形。`packages/llm/llm/tests/assembler.spec.ts` 覆盖 `interruptedBlocks()`。`packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts` 和 `packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts` 覆盖两种客户端投影。keyless 的 `cancel` ACP 快照和 `goal-round-driver` goal 快照覆盖完整应用。 diff --git a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml new file mode 100644 index 0000000000..80a1959b5a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md +2026-08-11-pwsh-persistent-pty.md: 8353b3ab3cdbf20add22a55acb03312c94283602 +2026-08-11-pwsh-persistent-pty.zh.md: 95048a02416dfcf5f0ef2837d99a561008f6496f diff --git a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md new file mode 100644 index 0000000000..8353b3ab3c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md @@ -0,0 +1,65 @@ +# Agent Note: Persistent pwsh over the terminal seam on Windows + +Status: implemented + +English | [中文](2026-08-11-pwsh-persistent-pty.zh.md) + +## Problem + +The harness had no persistent shell on Windows. The persistent `bash` stack was POSIX-only by construction: `@deepseek-ai/dsh-subprocess-local` threw at terminal allocation (`createProcessInspector()` rejected win32), `@deepseek-ai/dsh-terminal-bash` was bash-shaped (`/bin/bash` defaults, `PS1`/`PROMPT_COMMAND` environment markers), `@deepseek-ai/dsh-tool-bash-persistent` wrapped commands in bash syntax, and every pty test skipped on win32. The one-shot `pwsh` tool (`@deepseek-ai/dsh-tool-pwsh` over `@deepseek-ai/dsh-pwsh-local`) already ran on Windows, but each call started a fresh `pwsh -Command` process: cwd, `$env:` variables, functions, and interactive children ended with the call, and its README recorded "No persistent shell or PTY" as deferred work. + +The gap excluded Windows workflows whose state lives in a terminal: stepping a debugger, exploring in a Python or Node REPL, or returning to a shell after interrupting its foreground command — the same class of work the persistent bash pty serves on POSIX. + +Two foundations already existed. the terminal service itself (`ctx.terminals` registry, owner scoping, send/read/signal/kill contract) is platform-neutral. The Loader's `disabled: !!js` interpolation (PR #2234) gates shell rows per platform and pins the invariant that exactly one shell stack mounts per host; a persistent pwsh stack composes through the same rows. + +## Decision + +A model-facing persistent `pwsh` tool ships on Windows with the same contract as `tool-bash-persistent`: one owner-scoped persistent shell per Agent, marker-detected command completion, exact native exit codes, bounded output, and timeout/cancel/`exit` semantics that reset the shell and tell the model. Three pieces deliver it: a Windows substrate in `subprocess-local`, a shell-dialect option in `terminal-bash`, and the new `tool-pwsh-persistent` package with the minimal-preset composition rows. + +### Windows substrate in `@deepseek-ai/dsh-subprocess-local` + +`createProcessInspector()` returns a `WindowsProcessInspector` on win32 instead of throwing. The koffi-backed inspector enumerates the process table through Toolhelp32, combines GetProcessTimes creation identities with zero-time process-handle waits (pid-reuse fencing plus terminated-object detection), reports the **shell pid as a pseudo foreground group** (Windows has no POSIX groups; the stable value lets the prompt-marker readiness fast path settle in one poll interval), reports no stdin-wait evidence (readiness degrades exactly like macOS), and signals through `taskkill /T` escalation (`/F` only for SIGKILL). koffi (`^3.1.0`, the version `sandbox-windows-acl` already pins) loads lazily on win32 only. + +`LocalTerminalHandle` branches for win32 because node-pty's `kill(signal)` throws ("Signals not supported on windows") and its bare kill delegates to a console-list agent that fails without a parent console. Teardown escalates through taskkill fenced on the shell's start identity, and — because an externally taskkilled shell may never fire node-pty's exit notification — the handle settles `done` from the inspector-verified absence (`settleExitIfGone`). `signalForeground` maps SIGINT to a `\x03` Ctrl-C input write (the console-wide delivery conhost turns into a CTRL_C event; verified to interrupt a running command), routes SIGTERM/SIGKILL to taskkill, and rejects SIGTSTP/SIGHUP as unavailable on Windows. The public `PtySignal` set and seam types are unchanged; the mapping lives in the backend. + +### Shell dialect in `@deepseek-ai/dsh-terminal-bash` + +One backend, two dialects: `shellDialect: 'bash' | 'pwsh'` (default `'bash'`, existing deployments byte-identical). The effective `shellPath`/`shellArgs` resolve per dialect (bash `/bin/bash --noprofile --norc -i`; pwsh through the shared `dsh-pwsh-local` resolver with `-NoLogo -NoProfile`, keeping the interactive host for child REPLs). The child environment drops the bash-only `PS1`/`PROMPT_COMMAND` markers and adds `NO_COLOR` for pwsh. pwsh cannot install its prompt from the environment, so the backend writes the prompt function through the session at startup and waits until the controlled prompt is actually visible, looping over follow-up sends because the pwsh banner-to-prompt gap can outlast the silence bound; a `session_exit` or `timeout` wait rejects the spawn. Both dialects emit the same BEL-terminated OSC `133;D;` marker, so the sanitizer, `PROMPT_MARKER_PREFIX`, `CONTROLLED_PROMPT`, and the exact-tail readiness logic are reused untouched — the marker stays a readiness signal with an unconsumed payload, exactly as in the bash path, and no model-notification channel was added (aligned with the current implementation; the deferred BEL event channel stays deferred). + +### `@deepseek-ai/dsh-tool-pwsh-persistent` + +A new package mirroring `tool-bash-persistent`: same `Config` (`backendType` default `shell`, `timeoutMs`, `maxOutputChars`, `description`), same owner-scoped shell registry and serialized per-owner queue, same timeout/abort/exit/reset paths. The tool name is `pwsh`; it never co-mounts with the one-shot `tool-pwsh` because the preset rows are mutually exclusive per platform. + +Commands run through a wrapper that resets `$LASTEXITCODE` (assignable, verified), invokes the body via `Invoke-Expression` in a backtick-escaped double-quoted string (`quoteForPwsh`: backtick, quote, `$`, CRLF, and ESC escapes, so no raw control characters ride the input line and the wrapper survives ConstrainedLanguage), and reports the exact native exit code, `1` for a terminating PowerShell error, or `0` for success. PSReadLine echoes the submitted wrapper back into the stream — there is no `stty -echo` equivalent — so the extraction strips the wrapper source from captured output; the echo can never fabricate completion because the status regex needs digits immediately after the END nonce and the echo continues with quote characters. The prompt function installs the tool's own prompt (`__DSH_PERSISTENT_PWSH_PROMPT__ `) over the backend bootstrap value, the same two-layer structure as bash. + +### Composition + +The minimal preset gates its persistent shell stack by platform with the #2234 `disabled: !!js` interpolation: the bash rows (`terminal-bash` + `tool-bash-persistent`) mount on POSIX, and the pwsh rows (`terminal-bash` with `shellDialect: pwsh` + `tool-pwsh-persistent`) mount on win32 — exactly one persistent shell per host. `windows-shell.spec` pins the per-platform roster; the real Loader composition exercises the whole stack over a real ConPTY pwsh. + +### Testing + +The Windows test surface follows master's exemption structure: terminal-bash and subprocess-local tests stay excluded on win32 (`windowsUnsupportedTests`) and their sources stay coverage-exempt there (`windowsUnsupportedCoveragePackages`), so the platform-gated fixtures and node-translated commands remain the win32 dev-lane evidence, while the koffi-backed inspector joins the windows-only coverage exclusions on Linux. `tool-pwsh-persistent` is not exempt: its suite runs and its sources are coverage-required on the windows-native lane, mirroring `tool-bash-persistent`'s stub-mode matrix plus an echo-stripping mode; the real-pwsh suites prove persistent cwd/env, secret scrubbing, multiline and here-string commands, large-output clipping, and exit/reset over real ConPTY sessions. The ACP keyless snapshot boots the persistent tool through a real Loader composition and pins its model-visible schema and result. + +## Alternatives considered + +- **A separate `pty-pwsh-local` backend package.** Rejected: the local session, sanitizer, readiness tiers, and sandbox fence are shared machinery; duplicating the 500-line session for argv/env/startup differences trades one config field for a package of copy-paste, unlike the bash group's thin parallel executors. +- **tasklist or wmic polling for the process tree.** Rejected: `inspectForeground` runs on every readiness poll (~50 ms), so a spawned probe per tick is untenable, and wmic is removed from current Windows releases. koffi + Toolhelp32 is in-process and cheap. +- **A native helper or `GenerateConsoleCtrlEvent` for SIGINT.** Rejected: writing `\x03` to ConPTY input interrupts running commands (verified) with zero new code. The semantic difference — at a prompt, `\x03` cancels the pending line instead of signalling a process — is documented rather than engineered around. +- **Base64 body encoding for the wrapper.** Rejected: decoding needs `[Convert]`/`[System.Text.Encoding]` calls whose ConstrainedLanguage status is unproven, while backtick-escaped double-quoted strings use only language-level constructs and were verified end-to-end. +- **Tolerating the echo without stripping the wrapper.** Rejected: in complete and prompt-settled paths the echo is naturally excluded, but timeout and lost-START fallbacks would leak the wrapper source (including marker nonces) into model-visible text. +- **Resurrecting a BEL model-notification channel.** Rejected: the current implementation consumes no marker payload and delivers no BEL events; the design aligns with the current implementation and keeps the deferred item deferred. +- **Windows PowerShell 5.1 as a first-class target.** Rejected: pwsh 7 (including the Store install) is the target; `resolvePwshPath` keeps 5.1 as the last-resort executable fallback without promising full persistent-shell behavior on it. + +## Consequences + +**Windows became a first-class persistent-shell host.** The persistent pwsh stack runs and is coverage-gated on the windows-native lane; the one-shot/persistent shell split mirrors POSIX, and the preset spec pins exactly one shell stack per host on both platforms. + +**Windows coverage keeps master's exemption structure.** subprocess-local and terminal-bash sources stay coverage-exempt and their suites test-excluded on win32 exactly as on master; the Windows code paths are exercised through the win32 dev lane and the real-pwsh tool suites, and the new surface's coverage obligation on the windows-native lane sits on `tool-pwsh-persistent`. + +**Windows readiness is weaker than Linux.** The pseudo-pgid marker fast path covers shell prompts, but a child without a prompt settles on the silence tier (~3 s), exactly like macOS; there is no exact stdin-wait tier. + +**Windows teardown and signalling differ from POSIX.** taskkill without `/F` does not terminate console processes (the TERM tier is a grace wait before `/F`), SIGINT is console-wide Ctrl-C, SIGTSTP/SIGHUP are unavailable, and externally taskkilled shells may not fire node-pty's exit notification — the handle settles from verified absence instead. + +**Input echo is an accepted platform fact.** PSReadLine echoes submitted input; the marker-anchored extraction and wrapper-source strip remove it in complete results, with bounded residual in partial-output fallbacks. + +**Risks carried.** Under the Windows ACL sandbox's read-only mode, ConstrainedLanguage may deny the bootstrap's `[Console]::` encoding pin and prompt marker; commands then settle through the printable prompt and silence tier, while non-ASCII output may follow the host code page. A model redefinition of the `prompt` function likewise degrades readiness to the silence tier. Raw ESC characters in model commands are unsupported (PSReadLine consumes them). koffi is now a dependency of the process substrate, carrying the same install/prebuild review the sandbox package already has. diff --git a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md new file mode 100644 index 0000000000..95048a0241 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md @@ -0,0 +1,65 @@ +# Agent Note: Windows 上基于 terminal seam 的持久化 pwsh + +Status: implemented + +[English](2026-08-11-pwsh-persistent-pty.md) | 中文 + +## 问题 + +harness 在 Windows 上没有持久 shell。持久 `bash` 栈按构造就是 POSIX-only:`@deepseek-ai/dsh-subprocess-local` 在终端分配时直接抛错(`createProcessInspector()` 拒绝 win32),`@deepseek-ai/dsh-terminal-bash` 是 bash 形态(`/bin/bash` 默认值、`PS1`/`PROMPT_COMMAND` 环境标记),`@deepseek-ai/dsh-tool-bash-persistent` 用 bash 语法包装命令,pty 测试全部在 win32 上 skip。一次性 `pwsh` 工具(`@deepseek-ai/dsh-tool-pwsh` + `@deepseek-ai/dsh-pwsh-local`)已经能在 Windows 运行,但每次调用都是全新的 `pwsh -Command` 进程:cwd、`$env:` 变量、函数和交互式子进程都随调用结束,其 README 把 "No persistent shell or PTY" 记为 deferred work。 + +这个缺口排除了状态驻留在终端里的 Windows 工作流:单步调试、在 Python 或 Node REPL 中探索、中断前台命令后回到原 shell —— 正是持久 bash pty 在 POSIX 上服务的同一类工作。 + +两个基础已经存在。PTY 服务本身(`ctx.terminals` 注册表、owner 作用域、send/read/signal/kill 契约)是平台无关的。Loader 的 `disabled: !!js` 插值(PR #2234)按平台门控 shell 行,并钉死了"每宿主恰好挂载一个 shell 栈"的不变量;持久 pwsh 栈通过同一行机制组合。 + +## 决定 + +模型侧持久 `pwsh` 工具在 Windows 上交付,契约与 `tool-bash-persistent` 逐项对齐:每个 Agent 一个 owner 作用域的持久 shell、标记检测的命令完成、精确的原生退出码、有界输出,以及超时/取消/`exit` 时重置 shell 并告知模型的语义。三块交付:`subprocess-local` 的 Windows 基座、`terminal-bash` 的 shell 方言选项、新的 `tool-pwsh-persistent` 包加 minimal 预设组合行。 + +### `@deepseek-ai/dsh-subprocess-local` 的 Windows 基座 + +`createProcessInspector()` 在 win32 返回 `WindowsProcessInspector` 而不是抛错。基于 koffi 的检查器通过 Toolhelp32 枚举进程表,把 GetProcessTimes 创建身份与进程句柄零时等待结合起来(同时防止 PID 复用并识别已终止的进程对象),把 **shell pid 作为伪前台进程组**(Windows 没有 POSIX 进程组;这个稳定值让 prompt-marker 就绪快路径在一个轮询间隔内结算),不报告 stdin-wait 证据(就绪与 macOS 同档),信号走 `taskkill /T` 升级(仅 SIGKILL 加 `/F`)。koffi(`^3.1.0`,`sandbox-windows-acl` 已固定的版本)仅在 win32 惰性加载。 + +`LocalTerminalHandle` 为 win32 分支,因为 node-pty 的 `kill(signal)` 会抛错("Signals not supported on windows"),其无参 kill 委托的 console-list agent 在没有父控制台时失败。拆卸经 taskkill 升级并以 shell 的启动身份作栅栏;由于被外部 taskkill 的 shell 可能永远不会触发 node-pty 的退出通知,句柄从 inspector 验证的消失状态结算 `done`(`settleExitIfGone`)。`signalForeground` 把 SIGINT 映射为 `\x03` Ctrl-C 输入写入(conhost 转为控制台级 CTRL_C 事件的投递方式;实测可中断运行中的命令),SIGTERM/SIGKILL 路由到 taskkill,SIGTSTP/SIGHUP 以 Windows 不可用为由拒绝。公共 `PtySignal` 集合与 seam 类型不变;映射全部留在 backend。 + +### `@deepseek-ai/dsh-terminal-bash` 的 shell 方言 + +一个 backend、两种方言:`shellDialect: 'bash' | 'pwsh'`(默认 `'bash'`,存量部署逐字节不变)。有效 `shellPath`/`shellArgs` 按方言解析(bash `/bin/bash --noprofile --norc -i`;pwsh 经共享的 `dsh-pwsh-local` 解析器取 `-NoLogo -NoProfile`,保留交互宿主供子 REPL)。子环境去掉 bash 专属 `PS1`/`PROMPT_COMMAND` 标记并为 pwsh 加 `NO_COLOR`。pwsh 无法从环境安装提示符,因此 backend 在启动时通过会话写入 prompt 函数,并等待受控提示符真正可见——因为 pwsh 从横幅到提示符的间隙可能超过静默上限,所以会在后续 send 上循环等待;`session_exit` 或 `timeout` 结算拒绝 spawn。两种方言发出相同的 BEL 终结 OSC `133;D;` 标记,因此 sanitizer、`PROMPT_MARKER_PREFIX`、`CONTROLLED_PROMPT` 与精确尾部就绪逻辑原样复用——标记仍只是就绪信号、载荷不被消费,与 bash 路径完全一致,且没有新增模型通知通道(与当前实现对齐;延后的 BEL 事件通道保持延后)。 + +### `@deepseek-ai/dsh-tool-pwsh-persistent` + +新包镜像 `tool-bash-persistent`:同样的 `Config`(`backendType` 默认 `shell`、`timeoutMs`、`maxOutputChars`、`description`)、同样的 owner 作用域 shell 注册表与每 owner 串行队列、同样的超时/中止/退出/重置路径。工具名是 `pwsh`;它与一次性 `tool-pwsh` 永不共挂,因为预设行按平台互斥。 + +命令经包装器执行:先重置 `$LASTEXITCODE`(可赋值,已实测),通过 `Invoke-Expression` 在反引号转义的双引号字符串中执行 body(`quoteForPwsh`:反引号、引号、`$`、CRLF 与 ESC 转义,输入行上不携带裸控制字符,包装器可在 ConstrainedLanguage 下存活),报告精确原生退出码、PowerShell 终止性错误的 `1` 或成功的 `0`。PSReadLine 会把提交的包装器回显进流——没有 `stty -echo` 的对应物——因此提取会从捕获输出中剥离包装器原文;回显无法伪造完成,因为状态正则要求 END nonce 后紧跟数字,而回显继续是引号字符。prompt 函数安装工具自有提示符(`__DSH_PERSISTENT_PWSH_PROMPT__ `)覆盖 backend 引导值,与 bash 的双层结构相同。 + +### 组合 + +minimal 预设用 #2234 的 `disabled: !!js` 插值按平台门控持久 shell 栈:bash 行(`terminal-bash` + `tool-bash-persistent`)在 POSIX 挂载,pwsh 行(`shellDialect: pwsh` 的 `terminal-bash` + `tool-pwsh-persistent`)在 win32 挂载——每宿主恰好一个持久 shell。`windows-shell.spec` 钉死按平台的花名册;真实 Loader 组合在真实 ConPTY pwsh 上跑通整条栈。 + +### 测试 + +Windows 测试面沿用 master 的豁免结构:terminal-bash 与 subprocess-local 的测试在 win32 上继续排除(`windowsUnsupportedTests`),其源码在 win32 上继续覆盖豁免(`windowsUnsupportedCoveragePackages`),平台门控 fixture 与 node 翻译命令因此仍是 win32 开发车道的证据;koffi-backed inspector 在 Linux 侧加入 windows-only 覆盖豁免。`tool-pwsh-persistent` 不在豁免之列:其套件在 windows-native 车道上运行、源码受覆盖约束,镜像 `tool-bash-persistent` 的 stub 模式矩阵并加回显剥离模式;真实 pwsh 套件在真实 ConPTY 会话上证明持久 cwd/env、密钥清洗、多行与 here-string 命令、大输出裁剪与退出/重置。ACP keyless snapshot 通过真实 Loader 组合启动持久工具,并固定模型可见的 schema 与结果。 + +## 备选方案 + +- **独立的 `pty-pwsh-local` backend 包。** 拒绝:本地 session、sanitizer、就绪档位和沙箱栅栏是共享机制;为一个 config 字段复制 500 行 session 换来的是一包复制粘贴,与 bash 组并置薄 executor 的情形不同。 +- **tasklist 或 wmic 轮询进程树。** 拒绝:`inspectForeground` 每次就绪轮询(约 50ms)都跑,每 tick 生成一次探测进程不可行;wmic 已从现行 Windows 移除。koffi + Toolhelp32 是进程内、廉价的。 +- **为 SIGINT 加原生 helper 或 `GenerateConsoleCtrlEvent`。** 拒绝:向 ConPTY 输入写 `\x03` 即可中断运行中的命令(已实测),零新增代码。语义差异——在提示符处 `\x03` 取消当前行而不是给进程发信号——文档化而不是绕开。 +- **包装器 body 用 base64 编码。** 拒绝:解码需要 `[Convert]`/`[System.Text.Encoding]` 调用,其在 ConstrainedLanguage 下的可用性未证实;反引号转义的双引号字符串只用语言级构造,且已端到端实测。 +- **容忍回显而不剥离包装器。** 拒绝:完整路径和提示符就绪路径下回显天然被排除,但超时和 START 丢失的回退会把包装器源码(含 marker nonce)泄漏进模型可见文本。 +- **复活 BEL 模型通知通道。** 拒绝:当前实现不消费任何 marker 载荷、不投递任何 BEL 事件;设计对齐当前实现,deferred 项保持 deferred。 +- **把 Windows PowerShell 5.1 当一等目标。** 拒绝:pwsh 7(含 Store 安装)是目标;`resolvePwshPath` 保留 5.1 作为最后的可执行回退,但不承诺持久 shell 在其上的完整行为。 + +## 后果 + +**Windows 成为一等公民的持久 shell 宿主。** 持久 pwsh 栈在 windows-native 车道上运行并受覆盖门禁约束;一次性/持久 shell 的划分与 POSIX 镜像,预设 spec 在两种平台上都钉死每宿主恰好一个 shell 栈。 + +**Windows 覆盖沿用 master 的豁免结构。** subprocess-local 与 terminal-bash 源码在 win32 上保持覆盖豁免、其套件保持测试排除,与 master 完全一致;Windows 代码路径经 win32 开发车道与真实 pwsh 工具套件验证,新表面的覆盖义务在 windows-native 车道上落在 `tool-pwsh-persistent`。 + +**Windows 就绪弱于 Linux。** 伪 pgid marker 快路径覆盖 shell 提示符,但没有提示符的子进程按静默档结算(约 3s),与 macOS 完全一致;没有精确的 stdin-wait 档。 + +**Windows 的拆卸与信号不同于 POSIX。** 不带 `/F` 的 taskkill 无法终止控制台进程(TERM 档是 `/F` 升级前的宽限等待)、SIGINT 是控制台级 Ctrl-C、SIGTSTP/SIGHUP 不可用,且被外部 taskkill 的 shell 可能不触发 node-pty 的退出通知——句柄改从验证的消失状态结算。 + +**输入回显是接受的平台事实。** PSReadLine 回显提交的输入;marker 锚定提取与包装器原文剥离在完整结果中移除它,部分输出回退中残留有界。 + +**携带的风险。** Windows ACL 沙箱只读模式下,ConstrainedLanguage 可能拒绝引导代码通过 `[Console]::` 固定编码并写入 prompt marker;此时命令通过可打印提示符和静默档结算,非 ASCII 输出可能沿用宿主代码页。模型重定义 `prompt` 函数同样会使就绪降级到静默档。模型命令中的裸 ESC 字符不受支持(PSReadLine 会吞掉)。koffi 成为进程基座的依赖,承担与沙箱包相同的安装/prebuild 评审。 diff --git a/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.i18n.yaml new file mode 100644 index 0000000000..b97fb2d1e8 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-08-18-client-build-environment.md +2026-08-18-client-build-environment.md: 45ed6c8bc68e0f08157fb56a91ae4f6165e6e431 +2026-08-18-client-build-environment.zh.md: bb9633721401f66b443a65253dcbc0241f45d328 diff --git a/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.md b/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.md new file mode 100644 index 0000000000..45ed6c8bc6 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.md @@ -0,0 +1,37 @@ +# Agent Note: Build-time public environment variables for client business code + +Status: implemented + +English | [中文](2026-08-18-client-build-environment.zh.md) + +## Problem + +Browser business packages need deployment builds to select static behavior, but the Web client has two artifact paths that do not contain one another: Vite builds the static shell, while the shared tsdown preset builds dynamically loaded plugins. Replacing an environment expression in only one path would give the same business expression different results depending on its package type. + +Browsers have no Node `process`, and embedding the build process's complete environment object would expose values unrelated to the frontend. Runtime configuration also does not accurately represent a build variant because this choice must remain fixed after an artifact is published. + +## Decision + +`DSH_CLIENT_*` is the build-time namespace for values that may be exposed to browser business code. Business code may use a static property read such as `process.env.DSH_CLIENT_NAME` to select behavior. Values come only from the build process environment, not from Vite `.env*` files. Set values are inlined as strings, and unset values evaluate to `undefined`. + +The Vite config and the shared tsdown preset for dynamic client bundles use one define generator. The generator creates exact substitutions only for `DSH_CLIENT_*` and reduces all remaining `process.env` reads to an empty object. The browser receives no global `process`, dynamic-key lookup, or environment enumeration capability. + +The `DSH_CLIENT_*` prefix itself declares that a value is public. Credentials, paths, and other Host- or CI-only values must not use it. + +The root build wrapper supplies one exact public environment to both bundlers. It derives `DSH_CLIENT_COMMIT_HASH` as the seven-character prefix of the source Git HEAD for every complete build; an explicit value supports build environments without repository metadata. `pnpm run build` otherwise inherits the caller's `DSH_CLIENT_*` values, while `pnpm run build:official` selects the repository's official artifact profile without shell-specific environment syntax and sets `DSH_CLIENT_BUILD_PROFILE=official` for deployment-specific business registrations. A successful complete build writes the exact public environment and a digest covering the Vite output and every dynamic client bundle. Partial build commands do not replace that record. + +## Alternatives considered + +**Replace values only in Vite.** A dynamic plugin's `lib/client.js` is loaded as an independent script and never enters Vite's module graph, so the expression would remain in a browser that has no `process`. + +**Expose every `DSH_*` value.** Host, test, and CI variables already use that prefix and may contain credentials or local paths. The narrower `DSH_CLIENT_*` prefix makes exposure intent auditable. + +**Provide a complete `process.env` object in the browser.** This would permit build-environment enumeration and turn a Node compatibility shim into a runtime API. Exact static substitutions are sufficient for build choices. + +**Standardize on `import.meta.env`.** Dynamic plugins are emitted as independent CommonJS factories and cannot retain `import.meta`. Business code would still need two interfaces depending on the artifact path. + +## Consequences + +The Vite static shell and shared tsdown dynamic bundles receive the same string for a given `DSH_CLIENT_*` build-process variable. An unset static property read evaluates to `undefined`; non-`DSH_CLIENT_*` values cannot enter browser artifacts through this mechanism, and business code cannot enumerate the build process environment. Every complete build carries its short source revision as public display metadata. CI build gates select the official profile without exposing its public values to source tests or unrelated workflow steps. npm packing and built Web tests verify the recorded environment and current artifact digest, so a default build followed by an official pack request, a partial rebuild, or modified output fails before consumption. + +Every `DSH_CLIENT_*` value referenced by business code becomes public artifact content, so a misnamed value can disclose information. Build choices are fixed when the artifact is generated; a setting that must change after deployment requires a validated, transported, and documented runtime configuration mechanism. diff --git a/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.zh.md b/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.zh.md new file mode 100644 index 0000000000..bb96337214 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.zh.md @@ -0,0 +1,37 @@ +# Agent Note: Client 业务代码使用构建期公开环境变量 + +Status: implemented + +[English](2026-08-18-client-build-environment.md) | 中文 + +## Problem + +浏览器业务包需要按部署构建选择静态行为,但 Web client 有两条互不包含的产物路径:Vite 构建静态壳,共享 tsdown preset 构建运行时加载的动态插件。只在一条路径替换环境变量会使相同业务表达式因所在包类型不同而产生不同结果。 + +浏览器没有 Node `process`,而把构建进程的完整环境对象放入产物会泄露与前端无关的值。运行时配置也不能准确表达构建变体,因为产物发布后不应再改变这类选择。 + +## Decision + +`DSH_CLIENT_*` 是可公开给浏览器业务代码的构建期命名空间。业务代码可用静态点访问 `process.env.DSH_CLIENT_NAME` 选择行为;值只取自构建进程环境,不读取 Vite `.env*` 文件。设置的值在构建时内联为字符串,未设置的值为 `undefined`。 + +Vite 配置与动态 client bundle 的共享 tsdown preset 使用同一 define 生成器。生成器只为 `DSH_CLIENT_*` 创建精确替换,并把其余 `process.env` 读取收敛到空对象;浏览器不获得全局 `process`、动态键读取或环境枚举能力。 + +`DSH_CLIENT_*` 的名称本身表示公开性。凭据、路径和其他仅供 Host 或 CI 使用的值不得使用该前缀。 + +根构建包装脚本向两个 bundler 提供同一份精确的公开环境。每次完整构建都会把源码 Git HEAD 的七位前缀派生为 `DSH_CLIENT_COMMIT_HASH`;没有仓库元数据的构建环境可显式提供该值。除此之外,`pnpm run build` 继承调用方的 `DSH_CLIENT_*` 值,`pnpm run build:official` 则不依赖特定 shell 的环境变量语法,直接选择仓库的官方产物 profile,并设置 `DSH_CLIENT_BUILD_PROFILE=official` 供部署专属业务注册使用。完整构建成功后会写入精确的公开环境,以及覆盖 Vite 输出和所有动态 client bundle 的摘要;局部构建命令不会替换该记录。 + +## Alternatives considered + +**只在 Vite 中替换。** 动态插件的 `lib/client.js` 作为独立脚本由浏览器加载,不进入 Vite 模块图,表达式会残留到无 `process` 的浏览器。 + +**公开全部 `DSH_*`。** 仓库中的 Host、测试和 CI 变量使用该前缀,其中可能包含凭据或本地路径;更窄的 `DSH_CLIENT_*` 让公开意图可审计。 + +**在浏览器提供完整 `process.env` 对象。** 这会允许枚举构建环境并把 Node 兼容垫片变成运行时 API;静态精确替换足以承载构建选择。 + +**统一改用 `import.meta.env`。** 动态插件输出为独立 CJS factory,不能保留 `import.meta`;业务代码仍会因产物路径不同而使用两套接口。 + +## Consequences + +Vite 静态壳和共享 tsdown 动态 bundle 对同一 `DSH_CLIENT_*` 构建进程变量产生相同字符串值。未设置的静态点访问得到 `undefined`,非 `DSH_CLIENT_*` 值不会通过该机制进入浏览器产物,业务代码也无法枚举构建进程环境。每次完整构建都携带可公开展示的短源码 revision。CI 构建门禁选择官方 profile,而不把其中的公开值暴露给源码测试或无关 workflow 步骤。npm 打包与 built Web 测试会校验记录中的环境及当前产物摘要,因此默认构建后请求官方打包、局部重建或修改输出都会在消费产物前失败。 + +任何被业务代码引用的 `DSH_CLIENT_*` 值都会成为公开产物内容,命名错误可能泄露信息。构建选择在产物生成时固定;需要部署后变化的设置必须使用拥有校验、传输和文档的运行时配置机制。 diff --git a/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.i18n.yaml index 1b56228555..67b493b3cf 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.md -2026-08-18-experimental-agent-teams-packages.md: cec73be48a5cde25fae570587d2ecd17a4a87d01 -2026-08-18-experimental-agent-teams-packages.zh.md: 71b702daa8694fddec6e2a0972704f19955cb509 +2026-08-18-experimental-agent-teams-packages.md: cfaf7b23d03a1adecf8acc18a110717a62aa9ed1 +2026-08-18-experimental-agent-teams-packages.zh.md: 4491f4f638b7c5db26416aa88f2a6453c2a72ff6 diff --git a/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.md b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.md index cec73be48a..cfaf7b23d0 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.md +++ b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.md @@ -12,9 +12,9 @@ An experimental directory without a current package previously imposed placement ## Decision -`packages/experimental/team` and `packages/experimental/tool-team` are private workspace packages. Their npm names remain `@deepseek-ai/dsh-team` and `@deepseek-ai/dsh-tool-team`, so promotion changes paths and release metadata without renaming imports. +`packages/experimental/agent-team` and `packages/experimental/tool-agent-team` are private workspace packages. The [experimental package naming decision](2026-08-19-experimental-package-name-prefix.md) owns their npm names and promotion rename; this note owns their placement, release exclusion, and dependency isolation. -The dsh release family and local baseline publisher exclude every manifest below `packages/experimental/`. Workspace constraints require each experimental package to set `private: true` and omit `publishConfig`. The same top-level check rejects `dependencies`, `optionalDependencies`, and `peerDependencies` from release packages, release apps, or the Python runtime to an experimental package. Experimental packages may depend on release packages and each other; tests may use them through `devDependencies`, and examples may load them explicitly. +The dsh pack and publish set and the local baseline publisher exclude every manifest below `packages/experimental/`. `release:dsh` still advances their manifest versions with the shared dsh version without creating release tags. Workspace constraints require each experimental package to set `private: true` and omit `publishConfig`. The same top-level check rejects `dependencies`, `optionalDependencies`, and `peerDependencies` from release packages, release apps, or the Python runtime to an experimental package. Experimental packages may depend on release packages and each other; tests may use them through `devDependencies`, and examples may load them explicitly. The generic caller-reserved continuable child identity and selective direct-child drain remain in the stable Subagent service. They own Subagent identity and Activation lifecycle without importing or naming Agent Teams; the experimental Team service consumes them in the permitted direction. @@ -28,10 +28,8 @@ Experimental status changes publication and compatibility expectations only. The **Move the Subagent prerequisites into the experimental directory.** Child identity allocation and Activation teardown belong to the Subagent owner and contain no Team-specific contract. Moving or duplicating them would invert the dependency or split one lifecycle across packages. -**Publish experimental packages under different npm names.** Experimental suffixes would make promotion rename every import and configuration row without strengthening release exclusion; private manifests and release-family filtering enforce the actual requirement. - ## Consequences Agent Teams can use the full repository graph and quality checks without entering official tarballs or becoming a supported runtime dependency. A release package cannot expose Team until the Team packages are promoted, so CLI and Web experiments use explicit example or experimental compositions instead of the shipped base bundles. -The product-role grouping is less direct while the packages incubate, and promotion creates path churn. Keeping npm names stable limits that churn to repository paths, metadata, documentation, and composition. +The product-role grouping is less direct while the packages incubate. Promotion creates path and npm-name churn as specified by the experimental package naming decision. diff --git a/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.zh.md b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.zh.md index 71b702daa8..4491f4f638 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.zh.md @@ -12,9 +12,9 @@ Agent Teams 的服务与工具约定仍在变化,但它需要使用真实 Sess ## 决策 -`packages/experimental/team` 与 `packages/experimental/tool-team` 是私有 workspace 包。其 npm 名仍为 `@deepseek-ai/dsh-team` 与 `@deepseek-ai/dsh-tool-team`,因此 promotion 只需修改路径与发布元数据,无需重命名 import。 +`packages/experimental/agent-team` 与 `packages/experimental/tool-agent-team` 是私有 workspace 包。[实验性包命名决策](2026-08-19-experimental-package-name-prefix.md)负责其 npm 名和 promotion 重命名;本记录负责其目录归属、发布排除与依赖隔离。 -dsh 发布系列与本地 baseline 发布器均排除 `packages/experimental/` 下的所有 manifest。workspace 约束要求每个实验性包设置 `private: true` 并省略 `publishConfig`。同一个顶层检查会拒绝发布包、发布 app 或 Python runtime 通过 `dependencies`、`optionalDependencies` 或 `peerDependencies` 依赖实验性包。实验性包可以依赖发布包和其他实验性包;测试可以通过 `devDependencies` 使用它们,示例可以显式加载它们。 +dsh pack 与 publish 集合以及本地 baseline 发布器均排除 `packages/experimental/` 下的所有 manifest。`release:dsh` 仍会让这些 manifest 跟随 dsh 共享版本递增,但不会创建发布 tag。workspace 约束要求每个实验性包设置 `private: true` 并省略 `publishConfig`。同一个顶层检查会拒绝发布包、发布 app 或 Python runtime 通过 `dependencies`、`optionalDependencies` 或 `peerDependencies` 依赖实验性包。实验性包可以依赖发布包和其他实验性包;测试可以通过 `devDependencies` 使用它们,示例可以显式加载它们。 通用的调用方预留 continuable child 身份和精确 direct-child drain 仍属于稳定 Subagent 服务。它们负责 Subagent 身份与 Activation 生命周期,不 import 或命名 Agent Teams;实验性 Team 服务沿允许的方向消费这些能力。 @@ -28,10 +28,8 @@ dsh 发布系列与本地 baseline 发布器均排除 `packages/experimental/` **把 Subagent 前置能力移入 experimental 目录。** child 身份分配与 Activation teardown 属于 Subagent owner,且不包含 Team 专用约定。移动或复制这些能力会反转依赖方向,或把同一个生命周期拆到多个包中。 -**使用不同的 npm 名发布实验性包。** experimental 后缀会让 promotion 重命名所有 import 与配置项,却不能加强发布排除;私有 manifest 与发布系列过滤已经强制执行实际要求。 - ## 后果 Agent Teams 可以使用完整仓库依赖图与质量检查,而不进入正式 tarball,也不会成为受支持的运行时依赖。在 Team 包 promotion 前,发布包不能暴露 Team,因此 CLI 和 Web 实验使用显式示例或实验性组合,而不是已发布的基础组合包。 -孵化期间的产品职责分组不够直接,promotion 也会产生路径改动。保持 npm 名稳定后,改动范围仅包括仓库路径、元数据、文档和组合。 +孵化期间的产品职责分组不够直接。promotion 会按照实验性包命名决策产生路径和 npm 名改动。 diff --git a/.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.i18n.yaml new file mode 100644 index 0000000000..e1aaf99127 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.md +2026-08-18-sqlite-physical-chunk-row-compression.md: e46adf26ab4ce0a495f3509977ab0835631c16a9 +2026-08-18-sqlite-physical-chunk-row-compression.zh.md: 8e3978c1319545fd09dc668fbd46434a03eb8def diff --git a/.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.md b/.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.md new file mode 100644 index 0000000000..e46adf26ab --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.md @@ -0,0 +1,81 @@ +# Agent Note: SQLite physical chunk-row compression + +Status: implemented + +English | [中文](2026-08-18-sqlite-physical-chunk-row-compression.zh.md) + +## Problem + +The scalar [`session-persistence-sqlite`](../../../../packages/session/session-persistence-sqlite/README.md) layout stores one physical row per logical `SessionEvent`. Provider streams produce token-sized `assistant/chunk` events with repeated turn, step, block, type, and envelope fields, so transaction batching reduces commits without reducing row count or repeated JSON payload. The logical stream cannot be coalesced because chunk boundaries, sequence numbers, timestamps, replay, partial output, UI fidelity, and `sourceEventSeqs` remain observable. + +A physical row that represents several events affects append contiguity, crash repair, suffix seeks, schema ownership, revisions, and stale writers. Durable decoding must also be fixed by the schema version; a configurable codec set could make one schema version unreadable under a different Cordis composition. + +## Decision + +`@deepseek-ai/dsh-session-persistence-sqlite` uses the packed schema-17 implementation. It is the only SQLite persistence package and provider; the predecessor scalar layout and the temporary versioned sibling are not retained. SQLite remains an opt-in switch, while shipped default compositions continue to use JSONL. Both backends implement the same `SessionPersistence` service through `PersistenceCoordinator`, so physical packing changes neither live event delivery nor the logical session API. + +Schema 17 keeps ordinary ROWID tables and the composite `events(session_id, seq)` primary-key index. Scalar rows represent one logical event. Packed rows use the storage tags `text-chunks`, `reasoning-chunks`, and `tool-call-chunks`; the SQL `seq` and `time` columns hold the first logical member, and `data` holds the packed payload. Packed rows set `ignorable=0` as a physical discriminator and leave `source_event_seqs` and `surface_op` as `NULL`; scalar rows use `ignorable=1` only for logical ignorable events and `NULL` otherwise. A future ignorable logical event may therefore reuse a storage-tag name without being decoded as a packed row. The tags are storage vocabulary, not `SessionEventMap` members. + +SQLite owns chunk encoding and validation inside the schema-17 package. Exact-field whitelisting means unknown fields, surface metadata, incompatible chunk identity, sequence gaps, and unsafe timestamps remain scalar rather than losing information. One packed row represents at most 1,024 events and 1 MiB of uncompressed UTF-8 `data`; the encoder partitions longer runs, and the decoder rejects rows outside those format limits. + +The `data` column accepts `TEXT` or `BLOB`. Serialized values below 4 KiB remain text. At or above the threshold, the writer uses Zstandard level 3 and retains the frame only when it is smaller than the text; the reader decompresses the blob before strict UTF-8 decoding and JSON parsing. The fixed moderate level and threshold limit frame overhead and synchronous CPU work while capturing the repeated payloads that dominate retained bytes. + +`source_event_seqs` remains the complete ordered list of earlier events cited by a surface node, including every streamed chunk behind an assembled assistant message. Schema 17 stores the first sequence as an unsigned varint and every subsequent signed difference as a ZigZag varint. This preserves arbitrary order and every sequence while exploiting the overwhelmingly consecutive lists produced by streaming. An empty list is an empty non-null blob, distinct from absent provenance. + +### Transactional append packing + +Each append acquires `BEGIN IMMEDIATE`, rechecks schema ownership, selects the bounded physical span that may cover the last stored sequence, and derives the next logical sequence from that decoded tail. A mismatch rejects a stale writer before mutation. The codec packs only the new durable batch. Its inserts, lazy session materialization, and one revision increment commit or roll back together. + +Normal append never deletes or replaces an earlier event row. Fixed write-behind windows normally collect high-frequency deltas into useful runs, while sparse or explicitly flushed batches may remain scalar. This makes physical event writes proportional to newly durable batches and prevents a stable retained-row count from hiding repeated replacement of a growing JSON value. + +### Reads and repair + +Full reads decode each physical row as one all-or-nothing logical span and validate contiguous logical sequences. A reverse pass identifies the last valid `turn/end` without retaining a second decoded copy of the full physical scan; the forward pass decodes one row at a time into the required logical result. A malformed row or gap before that committed boundary is corruption; a malformed final physical row becomes the opaque repair marker at that row's base sequence. Recovery re-reads and validates that marker while holding the write lock, then deletes the whole physical row and any later rows before binding synthetic closers as scalar events. A stale repair cannot delete a newer writer's valid suffix. + +`readFrom(id, fromSeq)` examines packed predecessors only within the maximum schema-17 row span, then reads from the earliest candidate that may contain `fromSeq`. The decoder filters reconstructed members below `fromSeq`, so a suffix may begin inside a packed row without parsing an unrelated earlier scalar row. Reading from that candidate also exposes an overlapping scalar row to contiguity validation instead of letting it hide the packed member. Packed data exceeding the uncompressed format byte limit rejects before JSON parsing. + +### Schema ownership + +A pristine database initializes at schema 17. Older physical schemas, foreign application identities, non-pristine unversioned databases, and incompatible schema objects reject; the pre-release package supplies no migration. Every connection disables trusted schemas and memory-mapped I/O before inspecting durable schema, then reads both settings back. After selecting and verifying the journal mode, the provider pins `synchronous=FULL` and verifies it so SQLite build defaults cannot weaken committed-append durability. Package code loads every statement and fixed pragma from closed-name `.sql` resources and binds runtime values as parameters. + +### Physical-write regression + +The repository regression guard writes 1,000 streamed deltas in 40-event durable batches. After every committed batch it compares every retained physical field, requires cumulative inserts to equal the final row count, and rejects changed or removed rows. It also checks the exact 31-row bound, the largest persisted record against the schema byte limit, and an idle interval with no WAL extent change. These checks prove bounded row structure and catch coarse write amplification; they do not establish device traffic because WAL frames can be overwritten in place and checkpoints also write the main database. Incident-class validation separately samples process physical bytes around active and idle periods and stresses synchronized multi-process access. Lock tests hold `BEGIN IMMEDIATE` in another process and verify bounded waiting and successful continuation. + +## Alternatives considered + +**Coalesce logical chunk events.** Rejected because it changes sequence references, replay, partial output, and live delivery. Physical records provide the storage reduction while restoring the authoritative log exactly. + +**Run a periodic or post-commit compactor.** Rejected because it adds another writer lifecycle, races append and repair, changes revisions without a logical append, and adds disposal work. + +**Merge each new batch into the prior packed tail.** Rejected because a stable database and row count can hide repeated delete-and-insert churn. Paced-stream measurement found higher process and WAL writes than the predecessor scalar layout even when the retained database was smaller. Batch-local packing gives up timing-independent row convergence to bound physical writes. + +**Use `synchronous=NORMAL` with WAL.** Rejected because it permits a recent committed transaction to roll back after an operating-system crash or power loss. `append()` resolves only after its batch is durable, so the provider explicitly retains SQLite's `FULL` durability level across builds. + +**Remove ROWID from `events`.** Rejected because the composite text/integer primary key then becomes the table B-tree key and is repeated through internal pages. On the 105-session comparison corpus, selective Zstandard with ordinary ROWID used 107.02 MB; the otherwise equivalent `WITHOUT ROWID` database used 126.75 MB. + +**Set a larger SQLite page size.** Rejected because the retained-size change was negligible: 4 KiB pages used 107.08 MB and 32 KiB pages used 106.89 MB in the layout reconstruction. The larger page also increases WAL-frame and cache granularity. The provider therefore issues no `page_size` pragma. + +**Compress every payload.** Rejected because small independent Zstandard frames add headers and synchronous CPU work while losing the cross-record dictionary opportunity of a whole-file stream. On the 105-session comparison corpus, a threshold sweep produced 75.01 MB at 4 KiB, versus 93.87 MB at 16 KiB and 60.92 MB at 1 KiB. The writer fixes level 3 rather than inheriting a library default, matching the moderate level used by [Codex cold-rollout compression](https://github.com/openai/codex/blob/main/codex-rs/rollout/src/compression.rs) while retaining independent row access. + +The final frozen comparison used 105 sessions, 2,507,860 logical events, 512-event durable batches, three independent builds per backend, and three read passes per build. SQLite used 75.01 MB, wrote in 8.58 s, read complete sessions at 3.95/21.58 ms p50/p95, read 50-event tails at 0.253/0.378 ms, and forked every session in 13.10 s. Zstandard JSONL used 30.65 MB and measured 28.21 s, 4.49/23.36 ms, 10.58/80.90 ms, and 14.48 s. The predecessor scalar SQLite layout used 709.57 MB and measured 10.64 s, 9.02/69.16 ms, 0.189/0.293 ms, and 19.30 s. The packed layout is 89.4% smaller than the predecessor, writes 19.4% faster, improves complete-read p50/p95 by 56.2%/68.8%, and reduces 2,507,860 physical event rows to 65,810. Scalar tail-50 and list micro-latency are lower, but the packed provider remains materially faster than JSONL on those paths and wins the dominant size, write, full-read, and fork costs. The 4 KiB threshold is the accepted balance rather than a strict dominance claim. + +**Store packed payloads under the logical `assistant/chunk` type.** Rejected because payload heuristics make malformed rows ambiguous and couple physical decoding to future logical payload fields. Explicit tags fail loudly. + +**Store `SessionHeader` fields in an extensible metadata blob.** Rejected for schema 17 because `agentPreset` is a typed core resume invariant shared by JSONL and SQLite, not provider extension metadata. Persisting validated core fields directly keeps both backends aligned; an untyped catch-all would add another compatibility mechanism without a current producer. Revisit this only with a core-owned, namespaced `SessionHeader` extension protocol implemented by every backend. + +**Expose compression rules through configuration or a live registry.** Rejected because same-version databases must be readable independently of runtime topology. The codec is modular source code, but the durable rule set is fixed by schema version. + +**Migrate older schemas in place.** Rejected under the pre-release policy. Changing strict column types requires rebuilding the event table, which turns the first append into an unbounded historical rewrite and temporarily duplicates storage. A new database keeps activation explicit and failure predictable. + +**Store forked history as a parent reference.** Deferred because it changes independent-session persistence rather than physical row encoding. Codex uses referenced history and excludes referenced or pointer-bearing rollouts from cold compression, but this provider would first need explicit parent retention, deletion, repair, export, and cross-backend semantics. Copying remains the bounded local choice until the session service owns those rules. + +**Keep the packed implementation as a versioned sibling.** Rejected because the pre-release repository has no compatibility promise for the scalar format, while two SQLite package names duplicate configuration, documentation, tests, and ownership. Historical benchmark artifacts retain the comparison without exposing a rollback provider. + +## Consequences + +The canonical SQLite provider preserves every logical persistence, replay, revision, crash-recovery, and model-facing behavior. High-frequency batches use fewer rows and fewer measured process disk-written bytes than the predecessor in paced-stream validation; idle samples add no measured writes. Packing ratio depends on durable batch boundaries, but previously committed rows are immutable outside explicit crash repair. + +The cost is no migration from older pre-release SQLite schemas and timing-dependent physical row count. SQLite and Zstandard remain synchronous: each connection uses the configured `busyTimeoutMs` for a competing lock and blocks its JavaScript thread during that wait, while large row encoding and decoding also run on that thread. A cold open yields after an immediate `SQLITE_BUSY` journal-mode transition and starts no further attempt after an open-relative retry cutoff; an in-progress synchronous call may finish later. External SQL tooling must use the provider decoder rather than assuming every physical `events.type` is a logical event type or every payload column is text. + +The [JSONL packed-row decision](2026-07-26-packed-chunk-rows-by-default.md), [bounded persistence batching](2026-08-08-bounded-session-persistence-write-batching.md), and original [session-persistence decision](2026-06-14-session-persistence.md) remain active: they respectively own the JSONL format, write scheduling, and backend-neutral service semantics. diff --git a/.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.zh.md b/.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.zh.md new file mode 100644 index 0000000000..8e3978c131 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.zh.md @@ -0,0 +1,81 @@ +# Agent Note: SQLite 物理分片行压缩 + +Status: implemented + +[English](2026-08-18-sqlite-physical-chunk-row-compression.md) | 中文 + +## 问题 + +标量 [`session-persistence-sqlite`](../../../../packages/session/session-persistence-sqlite/README.md) 后端为每个逻辑 `SessionEvent` 存储一个物理行。提供方流会生成 token 大小的 `assistant/chunk` 事件,并重复轮次、步骤、块、类型和 envelope 字段,因此事务批处理可以减少提交次数,却不能减少行数或重复 JSON payload。逻辑流不能合并,因为分片边界、序列号、时间戳、回放、部分输出、UI 保真度和 `sourceEventSeqs` 仍然可观察。 + +一个表示多个事件的物理行会影响追加连续性、崩溃修复、后缀定位、schema 所有权、revision 和陈旧写入方。持久解码规则还必须由包版本固定;可配置 codec 集可能导致同一 schema 版本在不同 Cordis 组合下无法读取。 + +## 决策 + +`@deepseek-ai/dsh-session-persistence-sqlite` 使用打包后的 schema 17 实现。它是唯一的 SQLite 持久化包和提供方;仓库不保留此前的标量布局与临时版本化同级包。SQLite 仍是可选开关,随产品交付的默认组合继续使用 JSONL。两个后端都通过 `PersistenceCoordinator` 实现同一 `SessionPersistence` 服务,因此物理打包既不改变实时事件投递,也不改变逻辑会话 API。 + +Schema 17 保留普通 ROWID 表以及复合主键索引 `events(session_id, seq)`。标量行表示一个逻辑事件。打包行使用存储标签 `text-chunks`、`reasoning-chunks` 与 `tool-call-chunks`;SQL 的 `seq` 和 `time` 列保存第一个逻辑成员,`data` 保存打包 payload。打包行把 `ignorable=0` 用作物理判别值,并让 `source_event_seqs` 与 `surface_op` 保持 `NULL`;标量行仅在逻辑事件可忽略时使用 `ignorable=1`,否则使用 `NULL`。因此,未来的可忽略逻辑事件即使复用了某个存储标签名称,也不会被解码为打包行。这些标签属于存储词汇,而不是 `SessionEventMap` 成员。 + +SQLite 在 schema 17 包内拥有分片编码和验证。字段完全匹配的白名单意味着未知字段、surface 元数据、不兼容的分片身份、序列缺口和不安全时间戳仍保持标量表示,不会丢失信息。一个打包行最多表示 1,024 个事件和 1 MiB 未压缩 UTF-8 `data`;编码器会分割更长的连续段,解码器则拒绝超出这些格式上限的行。 + +`data` 列接受 `TEXT` 或 `BLOB`。序列化值小于 4 KiB 时保持为文本。达到或超过该阈值时,写入方使用 Zstandard level 3,并且只在 frame 小于原文本时保留该 frame;读取方会先解压,再进行严格 UTF-8 解码和 JSON 解析。固定的适中级别与阈值限制 frame 开销与同步 CPU 工作,同时覆盖占据大部分保留字节的重复 payload。 + +`source_event_seqs` 是 surface 节点引用的早期事件的完整有序列表,包括组装后的 assistant 消息背后的每个流式分片。Schema 17 把第一个序列存为无符号 varint,把后续每个有符号差值存为 ZigZag varint。这样既能保留任意顺序和每个序列,又能利用流式处理所产生的绝大多数连续列表。空列表表示为空的非 `NULL` blob,与不存在来源区分开来。 + +### 事务化追加打包 + +每次追加会获取 `BEGIN IMMEDIATE`、重新检查 schema 所有权、选择可能覆盖最后存储序列的有界物理范围,并根据解码后的尾部推导下一逻辑序列。若不匹配,系统会在变更前拒绝陈旧写入方。Codec 只打包新的持久批次;其插入、会话惰性物化和一次 revision 递增会一起提交或回滚。 + +普通追加绝不删除或替换既有事件行。固定写后缓冲窗口通常会把高频 delta 收集成有效连续段,而稀疏或显式 flush 的批次可能保持标量形式。这样,物理事件写入量与新增持久批次成正比,稳定的保留行数无法再掩盖对不断增长 JSON 值的反复替换。 + +### 读取与修复 + +完整读取把每个物理行解码为全有或全无的逻辑范围,并验证逻辑序列连续。反向扫描会定位最后一个有效 `turn/end`,但不会保留完整物理扫描的第二份解码副本;正向扫描则逐行解码并写入必需的逻辑结果。在该已提交边界之前出现的畸形行或缺口属于损坏;畸形最终物理行则以该行的起始序列作为不透明修复标记。恢复会在持有写锁时重新读取并验证该 marker,再删除整个物理行及其后所有行,然后把合成 closers 绑定为标量事件。陈旧修复无法删除较新写入方的有效后缀。 + +`readFrom(id, fromSeq)` 只检查 schema 17 最大行跨度内的打包前驱,再从可能包含 `fromSeq` 的最早候选项开始读取。解码器会过滤重建后序列小于 `fromSeq` 的成员,因此后缀可以从打包行内部开始,而无需解析无关的更早标量行。从该候选项开始读取,还会让连续性验证看到相互重叠的标量行,而不是让它隐藏打包成员。打包数据超出未压缩格式字节上限时,会在解析 JSON 前拒绝。 + +### Schema 所有权 + +全新数据库初始化为 schema 17。旧物理 schema、外部 application identity、非空未版本化数据库以及不兼容 schema 对象都会被拒绝;该预发布提供方不提供迁移。每个连接都会在检查持久 schema 前禁用可信 schema 和内存映射 I/O,然后读回这两项设置。选择并验证 journal mode 后,提供方会把 `synchronous` 固定为 `FULL` 并验证该设置,避免 SQLite 构建默认值削弱已提交追加的持久性。包代码通过封闭名称的 `.sql` 资源加载每条语句和固定 pragma,并把运行时值作为参数绑定。 + +### 物理写入回归 + +仓库回归守卫以 40 个事件为持久批次写入 1,000 个流式 delta。它会在每个批次提交后比较所有保留物理字段,要求累计插入数等于最终行数,并拒绝发生变化或被移除的行。它还会检查精确的 31 行上限、最大持久记录不超过 schema 字节上限,并观察空闲区间内 WAL 范围不再变化。这些检查证明行结构有界并捕获粗粒度写放大;它们不能证明设备写流量,因为 WAL 帧可在原位覆写,检查点还会写入主数据库。事故级验证另行采样活动期和空闲期前后的进程物理写入字节,并对同步多进程访问进行压力测试。锁测试在另一个进程中持有 `BEGIN IMMEDIATE`,验证有界等待及之后成功继续。 + +## 考虑过的替代方案 + +**合并逻辑分片事件。** 不予采用,因为它会改变序列引用、回放、部分输出和实时投递。物理记录可以在准确恢复权威日志的同时获得存储缩减。 + +**运行周期性或提交后压缩器。** 不予采用,因为它会增加另一个写入方生命周期,与追加和修复竞争,在没有逻辑追加的情况下改变 revision,并增加资源释放工作。 + +**把每个新批次合并进已有打包尾部。** 不予采用,因为稳定的数据库与行数可能掩盖反复删除和插入产生的写入流量。节奏化流测量表明,即使保留数据库更小,该方案写入的进程字节与 WAL 字节仍高于此前的标量布局。逐批打包放弃与时序无关的行收敛,以换取有界物理写入。 + +**在 WAL 模式下使用 `synchronous=NORMAL`。** 不予采用,因为操作系统崩溃或断电后,最近提交的事务可能回滚。`append()` 只会在批次持久化后返回,因此提供方会在不同 SQLite 构建中显式保留 `FULL` 持久性级别。 + +**从 `events` 移除 ROWID。** 不予采用,因为复合文本/整数主键随后会成为表 B-tree 的键,并在内部页中重复。在 105 个会话的对比语料上,使用普通 ROWID 的选择性 Zstandard 数据库为 107.02 MB;其余条件相同的 `WITHOUT ROWID` 数据库为 126.75 MB。 + +**设置更大的 SQLite page size。** 不予采用,因为保留体积变化可以忽略:在独立的 page-size 布局重建中,4 KiB page 使用 107.08 MB,32 KiB page 使用 106.89 MB。更大的 page 还会增大 WAL frame 和 cache 粒度。因此提供方不设置 `page_size` pragma。 + +**压缩每个 payload。** 不予采用,因为小型独立 Zstandard frame 会增加 header 和同步 CPU 工作,也无法利用整文件流的跨记录字典。在 105 个会话的对比语料上,阈值扫描结果为:4 KiB 生成 75.01 MB,16 KiB 为 93.87 MB,1 KiB 为 60.92 MB。写入方固定使用 level 3,而不是继承库默认值;这与 [Codex 冷 rollout 压缩](https://github.com/openai/codex/blob/main/codex-rs/rollout/src/compression.rs)所用的适中级别一致,同时保留独立行访问。 + +最终冻结对比包含 105 个会话、2,507,860 个逻辑事件,以 512 个事件为持久批次;每个后端独立构建三次,每次构建执行三轮读取。SQLite 使用 75.01 MB,写入耗时 8.58 秒,完整读取 p50/p95 为 3.95/21.58 毫秒,读取最后 50 个事件为 0.253/0.378 毫秒,对所有会话执行 fork 为 13.10 秒。Zstandard JSONL 使用 30.65 MB,对应指标为 28.21 秒、4.49/23.36 毫秒、10.58/80.90 毫秒和 14.48 秒。此前的标量 SQLite 布局使用 709.57 MB,对应指标为 10.64 秒、9.02/69.16 毫秒、0.189/0.293 毫秒和 19.30 秒。打包布局比此前布局小 89.4%,写入快 19.4%,完整读取 p50/p95 改善 56.2%/68.8%,并把 2,507,860 个物理事件行减少到 65,810 行。标量布局的最后 50 个事件读取与 list 微延迟更低,但打包提供方在这些路径上仍明显快于 JSONL,并改善主要的空间、写入、完整读取和 fork 成本。4 KiB 阈值是接受的平衡点,而不是严格支配所有指标的结论。 + +**把打包 payload 存在逻辑 `assistant/chunk` 类型下。** 不予采用,因为 payload 启发式判断会使畸形行产生歧义,并把物理解码耦合到未来逻辑 payload 字段。显式标签会明确失败。 + +**把 `SessionHeader` 字段存入可扩展元数据 blob。** Schema 17 不采用该方案,因为 `agentPreset` 是 JSONL 与 SQLite 共同使用的强类型核心恢复不变量,而不是提供方扩展元数据。直接持久化已校验的核心字段可使两个后端保持一致;在没有当前生产方的情况下加入无类型兜底字段,只会增加另一套兼容机制。只有核心层定义由所有后端实现、带命名空间的 `SessionHeader` 扩展协议后,才应重新考虑该方案。 + +**通过配置或实时注册表暴露压缩规则。** 不予采用,因为同一版本数据库必须能独立于运行时拓扑被读取。Codec 在源码层保持模块化,但持久规则集由 schema 版本固定。 + +**原地迁移旧 schema。** 预发布策略不采用此方案。改变 strict 列类型需要重建事件表,这会把第一次追加变成无界的历史改写,并暂时复制存储。使用新数据库可让启用行为明确、失败方式可预测。 + +**把 fork 历史存为父级引用。** 延期处理,因为它改变的是独立会话持久化语义,而不是物理行编码。Codex 使用引用历史,并避免对被引用或带指针的 rollout 做冷压缩;但该提供方首先需要明确父级保留、删除、修复、导出和跨后端语义。在会话服务拥有这些规则之前,复制仍是有界的本地选择。 + +**把打包实现保留为版本化同级包。** 不予采用,因为预发布仓库不承诺兼容此前的标量格式,而两个 SQLite 包名会重复配置、文档、测试和所有权。历史 benchmark 产物保留对比,无需暴露回滚提供方。 + +## 后果 + +标准 SQLite 提供方保留每一项逻辑持久化、回放、revision、崩溃恢复和模型可见行为。在节奏流验证中,高频批次使用的行数和测得的进程磁盘写入字节少于此前布局;空闲样本没有新增测得写入。打包率取决于持久批次边界,但除显式崩溃修复外,已经提交的行保持不可变。 + +代价是不迁移旧的预发布 SQLite schema,以及取决于时序的物理行数。SQLite 与 Zstandard 都是同步操作:每个连接以配置的 `busyTimeoutMs` 等待竞争锁,该等待期间会阻塞其 JavaScript 线程,大型行的编码与解码也在该线程上执行。冷打开会在 journal-mode 切换立即返回 `SQLITE_BUSY` 后让出执行,并在从打开时计算的重试截止点后不再发起新尝试;正在执行的同步调用可能更晚才完成。外部 SQL 工具必须使用提供方解码器,而不能假定每个物理 `events.type` 都是逻辑事件类型或每个 payload 列都是文本。 + +[JSONL 打包行决策](2026-07-26-packed-chunk-rows-by-default.md)、[有界持久化批处理](2026-08-08-bounded-session-persistence-write-batching.md)和原始[会话持久化决策](2026-06-14-session-persistence.md)继续保持 active:它们分别负责 JSONL 格式、写入调度以及后端无关的服务语义。 diff --git a/.agents/notes/implemented/architecture/2026-08-19-experimental-package-name-prefix.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-19-experimental-package-name-prefix.i18n.yaml new file mode 100644 index 0000000000..e25d832371 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-19-experimental-package-name-prefix.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-08-19-experimental-package-name-prefix.md +2026-08-19-experimental-package-name-prefix.md: 3420fa20a0dd9519af5684142fc8f3e70f2ed779 +2026-08-19-experimental-package-name-prefix.zh.md: b7e7d073157eab272baed4ffb75f987a685f53d7 diff --git a/.agents/notes/implemented/architecture/2026-08-19-experimental-package-name-prefix.md b/.agents/notes/implemented/architecture/2026-08-19-experimental-package-name-prefix.md new file mode 100644 index 0000000000..3420fa20a0 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-19-experimental-package-name-prefix.md @@ -0,0 +1,29 @@ +# Agent Note: Mark experimental packages in npm names + +Status: implemented + +English | [中文](2026-08-19-experimental-package-name-prefix.zh.md) + +## Problem + +Directory placement, private manifests, and release-family filtering keep experimental packages out of releases, but an npm specifier or Cordis configuration row does not expose that status. A stable-looking package name can be copied into another composition without the reader seeing that its complete public contract remains experimental. + +## Decision + +Every package directly below `packages/experimental/` uses the `@deepseek-ai/dsh-experimental-*` npm prefix. The workspace constraints gate discovers those manifests and rejects a missing prefix alongside the existing `private: true` and omitted-`publishConfig` requirements. + +Agent Teams uses `@deepseek-ai/dsh-experimental-agent-team` from `packages/experimental/agent-team` and `@deepseek-ai/dsh-experimental-tool-agent-team` from `packages/experimental/tool-agent-team`. Package imports, Cordis configuration rows, generated catalogs, and repository metadata use those names without compatibility aliases. + +Promotion moves a package to its product-role group, removes `experimental-` from its npm name, and updates every repository reference atomically. The pre-release compatibility policy permits that rename without an alias package. + +## Alternatives considered + +**Keep stable-looking npm names while using only directory and release metadata for experimental status.** This minimizes promotion churn, but import specifiers and configuration rows hide the package status and cannot carry the repository-only placement rule into review. + +**Use an experimental suffix.** A prefix groups every experimental package under one searchable npm namespace and makes the status visible before the product role; a suffix would scatter that marker after role-specific names. + +## Consequences + +Experimental imports and configuration rows identify their support status without consulting repository layout. The top-level constraints command and its focused unit test prevent a newly added experimental package from omitting the prefix. + +Promotion deliberately renames imports, configuration, generated references, and metadata. No compatibility package preserves the experimental name. diff --git a/.agents/notes/implemented/architecture/2026-08-19-experimental-package-name-prefix.zh.md b/.agents/notes/implemented/architecture/2026-08-19-experimental-package-name-prefix.zh.md new file mode 100644 index 0000000000..b7e7d07315 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-19-experimental-package-name-prefix.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 在 npm 名中标记实验性包 + +Status: implemented + +[English](2026-08-19-experimental-package-name-prefix.md) | 中文 + +## 问题 + +目录归属、私有 manifest 与发布系列过滤可以阻止实验性包进入发布,但 npm specifier 或 Cordis 配置项无法体现该状态。外观稳定的包名可能被复制到其他组合中,而读者看不出其完整公开约定仍处于实验阶段。 + +## 决策 + +`packages/experimental/` 直属的每个包都使用 `@deepseek-ai/dsh-experimental-*` npm 前缀。workspace constraints 门禁会发现这些 manifest,并在现有 `private: true` 与省略 `publishConfig` 要求之外拒绝缺少该前缀的包。 + +Agent Teams 使用位于 `packages/experimental/agent-team` 的 `@deepseek-ai/dsh-experimental-agent-team`,以及位于 `packages/experimental/tool-agent-team` 的 `@deepseek-ai/dsh-experimental-tool-agent-team`。包 import、Cordis 配置项、生成目录和仓库元数据直接使用这些名称,不提供兼容别名。 + +promotion 会把包移至其产品职责组、从 npm 名中移除 `experimental-`,并原子更新全部仓库引用。预发布兼容策略允许该重命名不提供别名包。 + +## 曾考虑的替代方案 + +**保留外观稳定的 npm 名,仅通过目录和发布元数据表达实验状态。** 这种方式可以减少 promotion 改动,但 import specifier 与配置项会隐藏包状态,也无法在评审中携带仅存在于仓库内的目录规则。 + +**使用 experimental 后缀。** 前缀会把所有实验性包归入一个可搜索的 npm 命名空间,并在产品职责之前显示状态;后缀会把该标记分散在各个职责名称之后。 + +## 后果 + +实验性 import 与配置项无需查阅仓库布局即可表明其支持状态。顶层 constraints 命令及其聚焦单元测试会阻止新实验性包遗漏该前缀。 + +promotion 会明确重命名 import、配置、生成引用与元数据。不会有兼容包保留实验名称。 diff --git a/.agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.i18n.yaml new file mode 100644 index 0000000000..5917e80616 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.md +2026-08-19-session-projection-state-and-client-views.md: 14da0525b2cc838ff496d5902dd66ae6ab456af4 +2026-08-19-session-projection-state-and-client-views.zh.md: edd2edaf0bc897bb2084325a5768e549637ed720 diff --git a/.agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.md b/.agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.md new file mode 100644 index 0000000000..14da0525b2 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.md @@ -0,0 +1,28 @@ +# Agent Note: Separate session projection state from client views + +Status: implemented + +English | [中文](2026-08-19-session-projection-state-and-client-views.zh.md) + +## Problem + +The projection registry persisted each unit's internal fold state without a runtime schema, while `SessionProjectionMap` described the client value returned by `view`. This left restored state unvalidated and made the same type table appear to describe two values that may differ. Host consumers also needed the current folded state without serializing every registered client view or exposing internal-only state through the client protocol. + +## Decision + +`SessionProjectionStateMap` is the merge-extensible table for host fold states. Every `ProjectionDefinition` key belongs to this table and supplies a `stateSchema`; cached rows are validated before they seed a fold. `SessionProjectionMap` retains its existing meaning and name as the sole table of client-visible whole values, preserving existing client data structures such as `title: string | null`. + +A unit whose key also appears in `SessionProjectionMap` supplies `wire.viewSchema` and `wire.view`. Every unit's state is checkpointed — client-visible and host-only alike; the `persist` opt-in is gone, so no unit can silently skip the durable cache. Snapshot APIs return only `SessionProjectionMap`, so internal states cannot enter API payloads. Host code reads one current state through `stateOf(session, key)`; the returned reference is borrowed and must not be mutated. + +## Consequences + +Projection state and client values are independently typed and validated without introducing a second client DTO vocabulary. A unit may expose a compact or compatibility-preserving client value while retaining richer host state. Malformed cached state cannot seed `viewCheckpoint`; restore rejects malformed state and the cache's existing full-read fallback rebuilds it from the log. Host consumers can replace private log scans with the same incremental fold used by carriers. + +The original [session-projection proposal](../../proposed/architecture/2026-07-27-session-projection-and-command-log.md) now records this split. The earlier [subagent identity projection](2026-08-06-subagent-list-identity-projection.md) and [projected token usage](2026-07-29-projected-token-usage-and-request-context.md) decisions remain current; their domain folds move to the state table without changing their user-facing values. + +## Alternatives considered + +- **Rename the existing map to a state table and introduce a new client map** — rejected because it changes the established client type name and invites unnecessary client payload migrations. +- **Keep one table for both state and client values** — rejected because a richer fold state and a compatibility-preserving client value then cannot be represented accurately. +- **Opt-in persistence for host-only units** — rejected: a `persist` flag lets a unit silently skip the durable cache, and the savings (one small row per session) never justify the asymmetry or the stateVersion confusion it invites. Every unit's state is checkpointed uniformly. +- **Return copied state from `stateOf`** — rejected because cloning every host read adds work without protecting a boundary; the method documents a readonly borrowed-reference obligation for typed same-process callers. diff --git a/.agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.zh.md b/.agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.zh.md new file mode 100644 index 0000000000..edd2edaf0b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.zh.md @@ -0,0 +1,28 @@ +# Agent Note:拆分会话投影状态与客户端视图 + +状态:已实现 + +[English](2026-08-19-session-projection-state-and-client-views.md) | 中文 + +## 问题 + +投影注册表会持久化各单元的内部折叠状态,却没有运行时 schema;与此同时,`SessionProjectionMap` 描述的是 `view` 返回的客户端值。这使恢复出的状态未经校验,也让同一张类型表看似同时描述两种可能不同的值。host 消费方还需要读取当前折叠状态,但不应为此序列化全部已注册客户端视图,也不应把内部状态暴露到客户端协议。 + +## 决策 + +`SessionProjectionStateMap` 是 host 折叠状态的 merge-extensible 类型表。每个 `ProjectionDefinition` key 都属于此表并提供 `stateSchema`;缓存行只有通过校验后才能为折叠提供初始状态。`SessionProjectionMap` 保留原有名称和语义,继续作为唯一的客户端可见全量值类型表,因此 `title: string | null` 等既有客户端数据结构保持不变。 + +如果一个单元的 key 也存在于 `SessionProjectionMap`,该单元就提供 `wire.viewSchema` 与 `wire.view`。每个单元的状态都会写入检查点——client-visible 与 host-only 一视同仁;`persist` 选择项已移除,任何单元都不能悄悄跳过持久化缓存。快照 API 只返回 `SessionProjectionMap`,因此内部状态不会进入 API 载荷。host 代码通过 `stateOf(session, key)` 读取一份当前状态;返回的是借用引用,不得修改。 + +## 结果 + +投影状态和客户端值分别获得类型与校验,同时不引入第二套客户端 DTO 词汇。单元可以保留更丰富的 host 状态,并暴露紧凑或兼容既有结构的客户端值。畸形缓存状态不能为 `viewCheckpoint` 提供数据;恢复会拒绝畸形状态,并由缓存既有的全量读取回退从日志重建。host 消费方可以用同一套增量折叠替换私有日志扫描。 + +原始 [session-projection 提案](../../proposed/architecture/2026-07-27-session-projection-and-command-log.md)已记录这次拆分。既有的 [subagent 身份投影](2026-08-06-subagent-list-identity-projection.md)与[投影化 token 用量](2026-07-29-projected-token-usage-and-request-context.md)决策仍然有效;其中的领域折叠迁入状态表,不改变面向用户的值。 + +## 考虑过的替代方案 + +- **把既有类型表改名为状态表,再引入新的客户端类型表**——不予采用,因为这会改变已经确立的客户端类型名称,并导致不必要的客户端载荷迁移。 +- **继续用一张类型表同时描述状态与客户端值**——不予采用,因为这样无法准确表达更丰富的折叠状态和保持兼容的客户端值。 +- **host-only 单元按需选择持久化**——不予采用:`persist` 标志会让单元悄悄跳过持久化缓存,而省下的(每会话一行小记录)永远不值得这种不对称或它带来的 stateVersion 困惑。每个单元的状态统一写入检查点。 +- **让 `stateOf` 返回状态副本**——不予采用,因为每次 host 读取都克隆会增加工作,却没有保护任何边界;该方法为同进程类型化调用方明确规定只读借用引用义务。 diff --git a/.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.i18n.yaml new file mode 100644 index 0000000000..90d1f96dff --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.md +2026-08-19-web-index-injection-table.md: 9ed02aa94cd318d107a32802d8723652e6b10ea2 +2026-08-19-web-index-injection-table.zh.md: 8ad036766faa14071b20da12ef907ab012cae23f diff --git a/.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.md b/.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.md new file mode 100644 index 0000000000..9ed02aa94c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.md @@ -0,0 +1,30 @@ +# Agent Note: structured index injection table (webserver/index-inject) + +Status: implemented + +English | [中文](2026-08-19-web-index-injection-table.zh.md) + +## Problem + +The web shell's boot HTML needs three kinds of injection: client-modules' boot protocol (the `__ModuleLoader__` registration queue inline script, the parser-blocking preload `` +})()` const preload = PARSER_PRELOAD_IDS.map(id => graph.entries.find(entry => entry.id === id)) .filter((entry): entry is WebBootEntry => entry !== undefined) - .map(entry => ``) - .join('') - const script = `${queue}${preload}` - const head = html.indexOf('') - if (head !== -1) return `${html.slice(0, head + 6)}${script}${html.slice(head + 6)}` - // Headless fixture pages may lack ; prepending keeps the read-before-shell ordering. - return `${script}${html}` + .map((entry): IndexInjection => ({ kind: 'script-src', placement: 'head', src: entry.url })) + return [ + { kind: 'script', placement: 'head', text: queue }, + ...preload, + { kind: 'global', name: '__DSH_BOOT__', value: graph }, + ] } /** * The web plugin table service: incremental `dsh.client` scan + wire composition - * + bundle route + index tap. Construction runs the activation scan + * + bundle route + index injection rows. Construction runs the activation scan * synchronously — a malformed declaration or missing bundle among the * already-loaded entries aggregates into one loud throw (FAILED fiber; the * boot activation audit reports it). @@ -353,10 +340,9 @@ export class ClientModuleRegistry extends Service { () => ctx.webServer.register({ kind: 'prefix', path: '/plugins', handler: this.serveBundle }), 'client-modules: bundle route', ) - ctx.effect( - () => ctx.webServer.tapIndex(html => injectBootManifest(html, this.composed)), - 'client-modules: boot manifest injection', - ) + ctx.on('webserver/index-inject', (table) => { + table.push(...bootInjections(this.composed)) + }) } /** diff --git a/packages/client/modules/tests/node-half.client.spec.ts b/packages/client/modules/tests/node-half.client.spec.ts index 0dc6ea0e3c..8577d2a57d 100644 --- a/packages/client/modules/tests/node-half.client.spec.ts +++ b/packages/client/modules/tests/node-half.client.spec.ts @@ -8,9 +8,9 @@ import { pathToFileURL } from 'node:url' import { runInNewContext } from 'node:vm' import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it } from 'vitest' -import type { WebServer, WebRoute } from '@deepseek-ai/dsh-host-webserver' +import { renderIndexInjections, type WebServer, type WebRoute } from '@deepseek-ai/dsh-host-webserver' import * as modulesClient from '../src/client/index.ts' -import { ClientModuleRegistry, injectBootManifest, orderByModuleGraph } from '../src/index.ts' +import { ClientModuleRegistry, bootInjections, orderByModuleGraph } from '../src/index.ts' import type { ClientModuleLoaderTarget, WebBootEntry, WebBootGraph } from '../src/client/index.ts' const MODULES_ID = '@deepseek-ai/dsh-client-modules' @@ -81,9 +81,12 @@ function construct(packageNames: string[]): ClientModuleRegistry { return constructWithRoute(packageNames).service } -/** Execute the exact first inline script emitted by the Host HTML transform. */ +/** Execute the exact first inline script emitted by the Host boot rows. */ function injectedFacade(graph: WebBootGraph): { html: string; target: ClientModuleLoaderTarget } { - const html = injectBootManifest('', graph) + const html = renderIndexInjections( + '', + bootInjections(graph), + ) const source = /') const runtimeAt = html.indexOf('') - const graphAt = html.indexOf('window.__DSH_BOOT__ = ') + const graphAt = html.indexOf('globalThis["__DSH_BOOT__"] = ') const entryAt = html.indexOf('') expect([facadeAt, modulesAt, runtimeAt, graphAt, entryAt]).toEqual([...new Set([ facadeAt, modulesAt, runtimeAt, graphAt, entryAt, diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index d39e4c95d1..3e383dff61 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -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: 7d0e90809aaffb9503f2222e1f7426b13d72d7d5 -README.zh.md: d0a43c32b4861d02786aa0c42e1af72047c9032a +README.md: eae49163c14f122a3603bcaa8a61aaac7a9a7270 +README.zh.md: 9e8f82a0bcf0d2021c9b16bd51acf3c72dd04cb2 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 7d0e90809a..eae49163c1 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -56,7 +56,7 @@ The Chat builder keeps one mutable keyed store per Session. Content updates noti ## Trajectory request data -Trajectory Definitions assemble one chronological, purpose-discriminated provider-request stream. Assistant requests always carry their numeric `turn` and `step`; compaction requests carry `step: 0` and a `turn` owner that may be `null`. That null owner means a manual compaction ran standalone between turns, not that it belongs to either adjacent turn. A `session/end-seed` boundary closes an unmatched compaction request as an error at the boundary time with `Compaction was interrupted before completion.`; a later start projects as an independent request instead of overwriting the orphan. +Trajectory Definitions assemble one chronological, purpose-discriminated provider-request stream. Assistant requests always carry their numeric `turn` and `step`; compaction requests carry `step: 0` and a `turn` owner that may be `null`. That null owner means a manual compaction ran standalone between turns, not that it belongs to either adjacent turn. A cancellation-finalized `assistant/message` retains its durable result seq and provider provenance but does not complete the request; `step/end` classifies that request as an error. A `session/end-seed` boundary closes an unmatched compaction request as an error at the boundary time with `Compaction was interrupted before completion.`; a later start projects as an independent request instead of overwriting the orphan. ## Code Mode child-call tree diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index d0a43c32b4..9e8f82a0bc 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -56,7 +56,7 @@ Chat builder 为每个 Session 保留一个 mutable keyed store。内容更新 ## Trajectory 请求数据 -Trajectory Definition 组装出一条按时间顺序排列、以用途为判别字段的提供方请求流。助手请求始终携带数值型 `turn` 与 `step`;压缩请求携带 `step: 0`,其 `turn` 所有者可以是 `null`。这个 null 所有者表示手动压缩独立运行在两个轮次之间,并不表示它属于任一相邻轮次。`session/end-seed` 边界会在边界时刻将未匹配的压缩请求以错误状态结束,错误固定为 `Compaction was interrupted before completion.`;后续 start 会投影为独立请求,而不会覆盖这项遗留的未匹配请求。 +Trajectory Definition 组装出一条按时间顺序排列、以用途为判别字段的提供方请求流。助手请求始终携带数值型 `turn` 与 `step`;压缩请求携带 `step: 0`,其 `turn` 所有者可以是 `null`。这个 null 所有者表示手动压缩独立运行在两个轮次之间,并不表示它属于任一相邻轮次。由取消定稿的 `assistant/message` 会保留持久结果 seq 和提供方信息,但不会将请求标记为完成;`step/end` 会把该请求归类为错误。`session/end-seed` 边界会在边界时刻将未匹配的压缩请求以错误状态结束,错误固定为 `Compaction was interrupted before completion.`;后续 start 会投影为独立请求,而不会覆盖这项遗留的未匹配请求。 ## Code Mode 子调用树 diff --git a/packages/client/runtime/package.json b/packages/client/runtime/package.json index ca21359f2f..051f573010 100644 --- a/packages/client/runtime/package.json +++ b/packages/client/runtime/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-runtime", "description": "Client core services: SlotRegistry, SessionRuntime (scope tree + object layer)", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/client/runtime/src/client/contract/session.ts b/packages/client/runtime/src/client/contract/session.ts index c3ef72e253..e07267d487 100644 --- a/packages/client/runtime/src/client/contract/session.ts +++ b/packages/client/runtime/src/client/contract/session.ts @@ -38,7 +38,11 @@ export interface ISession { * @param mode - 'queue' appends a turn; 'steer' interrupts the running one. * @returns acceptance, or the business error (also mirrored into snapshot.promptError). */ - prompt(content: PromptContentPart[], mode: 'queue' | 'steer'): Promise> + prompt( + content: PromptContentPart[], + mode: 'queue' | 'steer', + signal?: AbortSignal, + ): Promise> /** * Resolve one durable image referenced by this session. * @param attachmentId - opaque id found in the folded session log. diff --git a/packages/client/runtime/src/client/contract/sessions-port.ts b/packages/client/runtime/src/client/contract/sessions-port.ts index 551c3a16ee..8b9bf49229 100644 --- a/packages/client/runtime/src/client/contract/sessions-port.ts +++ b/packages/client/runtime/src/client/contract/sessions-port.ts @@ -32,11 +32,15 @@ export interface SessionsPort { /** Observable list snapshot (read face only; writes stay inside the sessions domain). */ readonly list: ObservableSnapshot /** - * Create a session on the host. - * @param opts - target workspace. - * @returns the new session id. + * Create or explicitly adopt a session on the host. + * @param opts - target workspace and optional confirmed blank-reuse id. + * @returns the created or adopted session id. */ - create(opts: { workspaceId: WorkspaceId }): Promise + create(opts: { + workspaceId: WorkspaceId + sessionId?: SessionId + reuseWorkspaceBlank?: true + }): Promise /** * Select a session as current. * @param id - session id (must exist in the list store). diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index fea9c817d7..90a2b2f5f3 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -85,7 +85,7 @@ export { } from './sessions/conversation.ts' export { emptyAssistantBlock } from './sessions/partial.ts' export { isTokenDelta } from './sessions/assistant-timing.ts' -export { contextForm, contextProvenance } from './sessions/context-provenance.ts' +export { contextForm, contextProvenance, sessionRecallLabels } from './sessions/context-provenance.ts' export { displayFailureMessage } from './sessions/failure-display.ts' export type { ConversationContext, ConversationContextOriginKind, diff --git a/packages/client/runtime/src/client/sessions/context-provenance.ts b/packages/client/runtime/src/client/sessions/context-provenance.ts index 5912ea388a..dbd3b2dd30 100644 --- a/packages/client/runtime/src/client/sessions/context-provenance.ts +++ b/packages/client/runtime/src/client/sessions/context-provenance.ts @@ -58,6 +58,19 @@ function joined(names: string[]): string | null { return names.length > 0 ? names.join(', ') : null } +/** + * The referenced-session labels of one durable `session-reference` recall + * source, in first-seen order; empty for every other source shape, including + * a foreign or older log whose reference entries carry no readable label. + * @param source - the logged `user/message` source, exactly as recorded. + * @returns distinct non-empty reference labels. + */ +export function sessionRecallLabels(source: unknown): string[] { + const record = asRecord(source) + if (record === null || readString(record, 'kind') !== 'session-reference') return [] + return collect(record, 'references', 'label') +} + /** * Project one durable message source onto its transcript role and producer name. * diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 698d8ab88f..44b6f7f42d 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -92,14 +92,14 @@ export interface AssistantTiming { completedTime: number } -/** A finalized (or interruption-frozen) assistant message. */ +/** A finalized assistant message or an interruption-frozen streaming prefix. */ export interface AssistantMessageNode { kind: 'assistant' seq: number /** - * Stable identity of the finalized model output, carried from the - * `assistant/message` event. Absent on interruption-frozen partials: those - * were never finalized, so they address no durable message. + * Stable identity carried from the `assistant/message` event. Absent only on + * synthetic interruption fallbacks assembled from chunks without a durable + * assistant message. */ messageId?: MessageId /** Unix epoch ms from the source session event (or turn/end when frozen from a partial). */ @@ -112,8 +112,9 @@ export interface AssistantMessageNode { 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. */ + /** Prefix of an aborted turn, rendered with a 已停止 marker. A durable + * finalized prefix uses its event seq; a chunk-only fallback uses a fractional + * seq derived from the closing boundary to keep it ordered inside the flow. */ interrupted?: true } diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index 13aa20d1c8..bb8ed7bd6c 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -534,10 +534,18 @@ export class SessionManager { * @returns the create result. */ async create( - opts: { workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId } = {}, + opts: { + workspaceId?: WorkspaceId + cwd?: string + sessionId?: SessionId + reuseWorkspaceBlank?: true + } = {}, ): Promise> { try { - const shared = opts.sessionId === undefined ? {} : { sessionId: opts.sessionId } + const shared = { + ...(opts.sessionId === undefined ? {} : { sessionId: opts.sessionId }), + ...(opts.reuseWorkspaceBlank === undefined ? {} : { reuseWorkspaceBlank: opts.reuseWorkspaceBlank }), + } const payload = opts.workspaceId !== undefined ? { workspaceId: opts.workspaceId, ...shared } : { ...(opts.cwd === undefined ? {} : { cwd: opts.cwd }), ...shared } diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index c66da4e0d3..5e23b3a10a 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -482,7 +482,12 @@ export class SessionRuntime implements ISessions { * @returns the new session id. * @throws {SessionCreateError} with the requested id. */ - async create(opts: { workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId } = {}): Promise { + async create(opts: { + workspaceId?: WorkspaceId + cwd?: string + sessionId?: SessionId + reuseWorkspaceBlank?: true + } = {}): Promise { const result = await this.manager.create(opts) if (!result.ok) throw new SessionCreateError(result.error, opts.sessionId) this.projectList() diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index f38e3c757b..02939cf28f 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -187,7 +187,11 @@ export class Session implements SessionFace { * @param mode - queue appends after the current turn; steer interrupts it. * @returns the prompt result (also mirrored into promptError on failure). */ - async prompt(content: PromptContentPart[], mode: 'queue' | 'steer'): Promise> { + async prompt( + content: PromptContentPart[], + mode: 'queue' | 'steer', + signal?: AbortSignal, + ): Promise> { this.promptError = null this.lastAgentError = null // Synchronous, before the first await: the blank → engaging edge must be @@ -204,7 +208,7 @@ export class Session implements SessionFace { mode, content, clientTimeZone: resolvedClientTimeZone(), - })).result + }, signal)).result } else if (this.address.mode === 'one-shot') { result = { ok: false, @@ -231,7 +235,7 @@ export class Session implements SessionFace { ? [{ type: 'text' as const, text: part.text }] : []), clientTimeZone: resolvedClientTimeZone(), - })).result + }, signal)).result result = routed.ok ? { ok: true, value: { accepted: true } } : routed } } diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index c23e3b3a6c..bfce0c6e28 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -53,7 +53,7 @@ export class WorkspaceRuntime implements IWorkspaces { readonly list: SnapshotStore /** Workspace baseline and frame owner. */ private readonly manager: WorkspaceManager - /** In-flight blank-session creates keyed by workspace (connectWorkspace coalescing). */ + /** In-flight blank-session connects keyed by workspace (reuse or create). */ private readonly connecting = new Map>() /** Guards the runtime-owned one-shot initial-selection subscription. */ private initialSelectionStarted = false @@ -76,9 +76,11 @@ export class WorkspaceRuntime implements IWorkspaces { /** * Resolve the session a New Session flow lands in once this Workspace is - * chosen: reuse the workspace's existing blank session when one is in the - * list mirror, else create a fresh one on the host (`session.create` births - * the full Session+Agent — the client holds no intermediate state). The + * chosen: explicitly adopt the workspace's existing blank session when one + * is in the list mirror, else create a fresh one on the host + * (`session.create` births or resumes the full Session+Agent — the client + * holds no intermediate state). The adoption tells optional default owners + * that this exact session passed the reuse checks. * caller owns navigation: take the returned id to `sessions.open`. * Resolution guarantee (both arms): the returned id is already in the list * store and `sessions.binding(id)` resolves synchronously — draft hand-off @@ -107,7 +109,13 @@ export class WorkspaceRuntime implements IWorkspaces { const summary = sessions.byId[id] if (summary !== undefined && summary.blank && summary.cwd === workspace.path && workspace.sessionIds.includes(summary.id) - && !archived.includes(summary.id)) return summary.id + && !archived.includes(summary.id)) { + return this.sessions.create({ + workspaceId, + sessionId: summary.id, + reuseWorkspaceBlank: true, + }) + } } const attempt = this.sessions.create({ workspaceId }) .finally(() => { this.connecting.delete(workspaceId) }) diff --git a/packages/client/runtime/src/env.d.ts b/packages/client/runtime/src/env.d.ts deleted file mode 100644 index 54d3d9d8f3..0000000000 --- a/packages/client/runtime/src/env.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * Bundler-replaced NODE_ENV: vite/tsdown substitute the literal, so browsers - * never evaluate a bare `process`. tsconfig carries no node types on purpose. - */ -declare const process: { env: { NODE_ENV?: string } } diff --git a/packages/client/runtime/tests/context-provenance.client.spec.ts b/packages/client/runtime/tests/context-provenance.client.spec.ts index e243660d60..b64902719c 100644 Binary files a/packages/client/runtime/tests/context-provenance.client.spec.ts and b/packages/client/runtime/tests/context-provenance.client.spec.ts differ diff --git a/packages/client/runtime/tests/workspaces-service.client.spec.ts b/packages/client/runtime/tests/workspaces-service.client.spec.ts index cfdfa98e9b..68736f0a58 100644 --- a/packages/client/runtime/tests/workspaces-service.client.spec.ts +++ b/packages/client/runtime/tests/workspaces-service.client.spec.ts @@ -242,20 +242,28 @@ describe('WorkspaceRuntime', () => { { sessionId: sid('s-stray'), updatedAt: 4, running: false, blank: true, cwd: '/w/gamma' }, ] as never[], })) + api.onCreate = payload => Promise.resolve(ok({ + sessionId: (payload as { sessionId?: SessionId }).sessionId ?? sid('s-unexpected'), + })) await Promise.all([workspaces.refresh(), sessions.refresh()]) await Promise.resolve() - // Hit: same workspace → the parked member blank comes back (the earlier - // cwd-matching non-member stray is skipped), no create RPC. + // Hit: same workspace → the parked member blank is explicitly adopted + // after the earlier cwd-matching non-member stray is skipped. await expect(workspaces.connectWorkspace(wid('alpha'))).resolves.toBe('s-blank') - expect(api.callsOf('session.create')).toEqual([]) + expect(api.callsOf('session.create')).toEqual([{ + workspaceId: 'alpha', sessionId: 's-blank', reuseWorkspaceBlank: true, + }]) // Resolution guarantee: the id is binding-resolvable synchronously. expect(sessions.binding(sid('s-blank'))).toBeDefined() // Miss: beta has only a non-blank session → host create with workspaceId. api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-fresh') })) await expect(workspaces.connectWorkspace(wid('beta'))).resolves.toBe('s-fresh') - expect(api.callsOf('session.create')).toEqual([{ workspaceId: 'beta' }]) + expect(api.callsOf('session.create')).toEqual([ + { workspaceId: 'alpha', sessionId: 's-blank', reuseWorkspaceBlank: true }, + { workspaceId: 'beta' }, + ]) // Same guarantee on the create arm (draft hand-off writes the machine pre-open). expect(sessions.binding(sid('s-fresh'))).toBeDefined() @@ -263,7 +271,11 @@ describe('WorkspaceRuntime', () => { // never reused, a fresh accounted session is created instead. api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-fresh-3') })) await expect(workspaces.connectWorkspace(wid('gamma'))).resolves.toBe('s-fresh-3') - expect(api.callsOf('session.create')).toEqual([{ workspaceId: 'beta' }, { workspaceId: 'gamma' }]) + expect(api.callsOf('session.create')).toEqual([ + { workspaceId: 'alpha', sessionId: 's-blank', reuseWorkspaceBlank: true }, + { workspaceId: 'beta' }, + { workspaceId: 'gamma' }, + ]) // Unknown workspace fails loud instead of silently creating in nowhere. await expect(workspaces.connectWorkspace(wid('ghost'))).rejects.toThrow(/unknown workspace ghost/) @@ -284,6 +296,9 @@ describe('WorkspaceRuntime', () => { api.onList = () => Promise.resolve(ok({ items: [{ sessionId: sid('s-blank'), updatedAt: 2, running: false, blank: true, cwd: '/w/alpha' }] as never[], })) + api.onCreate = payload => Promise.resolve(ok({ + sessionId: (payload as { sessionId?: SessionId }).sessionId ?? sid('s-unexpected'), + })) await Promise.all([workspaces.refresh(), sessions.refresh()]) await Promise.resolve() const session = sessions.binding(sid('s-blank'))!.session @@ -292,7 +307,9 @@ describe('WorkspaceRuntime', () => { await Promise.resolve() // Failure leaves blank intact, so the same session is still the reuse hit. await expect(workspaces.connectWorkspace(wid('alpha'))).resolves.toBe('s-blank') - expect(api.callsOf('session.create')).toEqual([]) + expect(api.callsOf('session.create')).toEqual([{ + workspaceId: 'alpha', sessionId: 's-blank', reuseWorkspaceBlank: true, + }]) }) it('returns created Workspaces and preserves Host business errors', async () => { diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index 72f0346680..44aab8cf1b 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -17,6 +17,7 @@ import type { UserConfig } from 'tsdown' import { transform } from 'lightningcss' import { optionalStringArray } from './modules/src/client/manifest.ts' import { PLATFORM_MODULES, PRELOADED_CLIENT_EXTERNALS } from './web/src/platform.ts' +import { clientBuildEnvironmentDefines } from '../../scripts/client-build-environment.ts' /** * Virtual-id wrapper keeping module CSS away from tsdown's own css pipeline @@ -57,7 +58,7 @@ function styleInjectionModule( * Everything else under @deepseek-ai/* is either a module-table entry * (external) or a leak the purity gate rejects. */ -export const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|session|llm|tools|brand)(\/|$)/ +export const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|file-reference|session|llm|tools|brand)(\/|$)/ /** * Vendored framework libraries: rescoped into @deepseek-ai, so the gate below @@ -470,6 +471,7 @@ function clientConfig(id: string, entry: string): UserConfig { // key: zustand probes `import.meta.env ? import.meta.env.MODE : ...`, and // the truthiness probe would otherwise survive as an empty import.meta. define: { + ...clientBuildEnvironmentDefines(process.env), 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV ?? 'production'), 'import.meta.env.MODE': JSON.stringify(process.env.NODE_ENV ?? 'production'), 'import.meta.env': JSON.stringify({ MODE: process.env.NODE_ENV ?? 'production' }), diff --git a/packages/client/ui-agent-preset/package.json b/packages/client/ui-agent-preset/package.json index 8b4afb0fb3..81763a3e3e 100644 --- a/packages/client/ui-agent-preset/package.json +++ b/packages/client/ui-agent-preset/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-agent-preset", "description": "Agent-preset surfaces: the default for later sessions, this session's seat, and the composition editor", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-attachment/package.json b/packages/client/ui-attachment/package.json index 8e22c28e57..039e1a0d5f 100644 --- a/packages/client/ui-attachment/package.json +++ b/packages/client/ui-attachment/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-attachment", "description": "Dynamic attachment presentation plugin for conversation input and message-image slots", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-brand-official/README.i18n.yaml b/packages/client/ui-brand-official/README.i18n.yaml new file mode 100644 index 0000000000..9d438ad955 --- /dev/null +++ b/packages/client/ui-brand-official/README.i18n.yaml @@ -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/ui-brand-official/README.md +README.md: 7197bc7f4731cd3e6471549900e4868ad6d4796d +README.zh.md: 14d3ee3f1aa2f12ddd20b851d4034e1417baf5c9 diff --git a/packages/client/ui-brand-official/README.md b/packages/client/ui-brand-official/README.md new file mode 100644 index 0000000000..7197bc7f47 --- /dev/null +++ b/packages/client/ui-brand-official/README.md @@ -0,0 +1,20 @@ +# @deepseek-ai/dsh-client-ui-brand-official + +English | [中文](README.zh.md) + +This package fills `sidebar.brand.mark`, `sidebar.brand.name`, and `conversation.hero.brand.mark` only when `DSH_CLIENT_BUILD_PROFILE` is `official`. Other builds load the plugin but register no occupants, leaving the shell fallbacks visible. + +The three occupants install as one declaration-aware registration set through nested `slots.inject()` calls. The package therefore works whether its row activates before or after the sidebar and conversation declarers, withdraws all occupants when either declaration collapses, and leaves no partial brand mix during HMR. It retains no runtime state. The node half is an empty Loader seat, and the browser title remains a build-environment concern outside this package. + +## Model Experience + +None, as the package contributes browser presentation only; nothing here reaches a model request. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + +- **The package supplies one occupant set** — alternative presentation belongs in another Cordis package occupying the same slots. +- **The browser title is independent** — `DSH_CLIENT_TITLE` selects title text at build time rather than through a UI slot. diff --git a/packages/client/ui-brand-official/README.zh.md b/packages/client/ui-brand-official/README.zh.md new file mode 100644 index 0000000000..14d3ee3f1a --- /dev/null +++ b/packages/client/ui-brand-official/README.zh.md @@ -0,0 +1,20 @@ +# @deepseek-ai/dsh-client-ui-brand-official + +[English](README.md) | 中文 + +仅当 `DSH_CLIENT_BUILD_PROFILE` 为 `official` 时,本包才填充 `sidebar.brand.mark`、`sidebar.brand.name` 和 `conversation.hero.brand.mark`。其他构建仍会加载插件,但不注册 occupant,因此显示 shell fallback。 + +三个占位者通过嵌套的 `slots.inject()` 作为一组声明感知注册安装。因此无论该包的条目先于还是后于侧边栏和会话声明方激活,它都能工作;任一声明折叠时会撤回全部占位者,HMR 期间不会留下混合品牌。它不保留运行时状态。node 半边是空的 Loader seat;浏览器标题仍属于本包之外的构建环境事项。 + +## 模型体验 + +无,因为本包只贡献浏览器呈现;这里没有任何内容进入模型请求。 + +#### KV Cache 影响 + +无;本包既不组装也不发送 provider 请求。 + +## 已知限制与暂缓事项 + +- **本包只提供一组 occupant** —— 其他呈现应由占用相同 slot 的另一个 Cordis 包提供。 +- **浏览器标题相互独立** —— `DSH_CLIENT_TITLE` 在构建期选择标题文字,而不经过 UI slot。 diff --git a/packages/client/ui-brand-official/package.json b/packages/client/ui-brand-official/package.json new file mode 100644 index 0000000000..d08317fbbc --- /dev/null +++ b/packages/client/ui-brand-official/package.json @@ -0,0 +1,72 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-brand-official", + "description": "Official DeepSeek Harness brand occupants for the Web client's sidebar and conversation Hero slots", + "version": "0.1.0-rc.8", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-brand-official" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-sidebar" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^", + "@testing-library/react": "^16.1.0", + "@types/react": "~18.3.1", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ] +} diff --git a/packages/client/ui-brand-official/src/client/Brand.tsx b/packages/client/ui-brand-official/src/client/Brand.tsx new file mode 100644 index 0000000000..4e0a60fd26 --- /dev/null +++ b/packages/client/ui-brand-official/src/client/Brand.tsx @@ -0,0 +1,22 @@ +import { BrandWordmark, FishLogo } from '@deepseek-ai/dsh-client-ui-primitives' +import type { HeroBrandMarkOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { SidebarBrandMarkOwnerProps } from '@deepseek-ai/dsh-client-ui-sidebar/client' + +type OfficialBrandMarkProps = HeroBrandMarkOwnerProps & SidebarBrandMarkOwnerProps + +/** + * Render the official mark with the presentation requested by its host surface. + * @param props - Host-supplied mark presentation. + * @returns the official whale mark. + */ +export function OfficialBrandMark({ size, className }: OfficialBrandMarkProps) { + return +} + +/** + * Render the official name artwork without its independently slotted mark. + * @returns the official name wordmark. + */ +export function OfficialBrandName() { + return +} diff --git a/packages/client/ui-brand-official/src/client/index.ts b/packages/client/ui-brand-official/src/client/index.ts new file mode 100644 index 0000000000..b291bda3e7 --- /dev/null +++ b/packages/client/ui-brand-official/src/client/index.ts @@ -0,0 +1,23 @@ +/** Official DeepSeek Harness occupants for the generic browser-brand slots. */ +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client' +import { OfficialBrandMark, OfficialBrandName } from './Brand.tsx' + +/** Required service: the UI slot registry. */ +export const inject = ['slots'] + +/** + * Fill every shipped brand slot as one declaration-aware registration set. + * @param ctx - Client root context. + */ +export function apply(ctx: ClientContext): void { + if (process.env.DSH_CLIENT_BUILD_PROFILE !== 'official') return + ctx.slots.inject('sidebar.brand.mark', () => + ctx.slots.inject('sidebar.brand.name', () => + ctx.slots.inject('conversation.hero.brand.mark', function* () { + yield ctx.slots.register({ name: 'sidebar.brand.mark' }, OfficialBrandMark) + yield ctx.slots.register({ name: 'sidebar.brand.name' }, OfficialBrandName) + yield ctx.slots.register({ name: 'conversation.hero.brand.mark' }, OfficialBrandMark) + }))) +} diff --git a/packages/client/ui-brand-official/src/index.ts b/packages/client/ui-brand-official/src/index.ts new file mode 100644 index 0000000000..df38f3cfa5 --- /dev/null +++ b/packages/client/ui-brand-official/src/index.ts @@ -0,0 +1,7 @@ +/** + * Official browser-brand plugin, node half. The empty apply gives Loader a + * host-side row while the browser half ships through `exports["./client"]`. + */ + +/** Host plugin body — this package contributes browser presentation only. */ +export function apply(): void {} diff --git a/packages/client/ui-brand-official/src/invariant.ts b/packages/client/ui-brand-official/src/invariant.ts new file mode 100644 index 0000000000..574054c383 --- /dev/null +++ b/packages/client/ui-brand-official/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-brand-official`. + * @module @deepseek-ai/dsh-client-ui-brand-official/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-brand-official' + +/** Cordis companion plugin name. */ +export const name = 'client-ui-brand-official-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the package retains no mutable state, and its three + * slot occupants install and leave through one transactional effect. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/client/ui-brand-official/tests/browser-plugin.client.spec.tsx b/packages/client/ui-brand-official/tests/browser-plugin.client.spec.tsx new file mode 100644 index 0000000000..ee5275b9b7 --- /dev/null +++ b/packages/client/ui-brand-official/tests/browser-plugin.client.spec.tsx @@ -0,0 +1,79 @@ +// @vitest-environment jsdom +import { Context } from '@deepseek-ai/cordis' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, render } from '@testing-library/react' +import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' +import { apply, inject } from '../src/client/index.ts' +import { OfficialBrandMark, OfficialBrandName } from '../src/client/Brand.tsx' + +afterEach(() => { + cleanup() + vi.unstubAllEnvs() +}) + +const HOLES = [ + 'sidebar.brand.mark', + 'sidebar.brand.name', + 'conversation.hero.brand.mark', +] as const + +async function bench(declare = true) { + const ctx = new Context() + await ctx.plugin(SlotRegistry).await() + const slots = ctx.get('slots') as SlotRegistry + const declareHoles = () => slots.register({ + name: 'root', + children: Object.fromEntries(HOLES.map(name => [name, { kind: 'single', scope: 'root' }])), + } as never, () => null) + const disposeHoles = declare ? declareHoles() : undefined + return { ctx, slots, declareHoles, disposeHoles } +} + +describe('official browser-brand plugin', () => { + it('declares only the slot service it uses', () => { + expect(inject).toEqual(['slots']) + }) + + it('leaves every slot empty outside the official build profile', async () => { + vi.stubEnv('DSH_CLIENT_BUILD_PROFILE', 'local') + const subject = await bench() + await subject.ctx.plugin({ inject: [...inject], apply }).await() + for (const hole of HOLES) expect(subject.slots.entries(hole)).toHaveLength(0) + }) + + it('fills declarations before or after apply and removes every occupant on teardown', async () => { + vi.stubEnv('DSH_CLIENT_BUILD_PROFILE', 'official') + const before = await bench() + const fiber = before.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + for (const hole of HOLES) expect(before.slots.entries(hole)).toHaveLength(1) + + before.disposeHoles?.() + for (const hole of HOLES) expect(before.slots.entries(hole)).toHaveLength(0) + before.declareHoles() + await Promise.resolve() + for (const hole of HOLES) expect(before.slots.entries(hole)).toHaveLength(1) + + await fiber.dispose() + for (const hole of HOLES) expect(before.slots.entries(hole)).toHaveLength(0) + + const after = await bench(false) + await after.ctx.plugin({ inject: [...inject], apply }).await() + for (const hole of HOLES) expect(after.slots.entries(hole)).toHaveLength(0) + after.declareHoles() + await Promise.resolve() + for (const hole of HOLES) expect(after.slots.entries(hole)).toHaveLength(1) + }) + + it('renders the official name independently from both requested mark sizes', () => { + const name = render() + expect(name.container.querySelector('svg')?.getAttribute('viewBox')).toBe('26 0 156 24') + name.unmount() + + const mark = render() + expect(mark.container.querySelector('svg')?.getAttribute('width')).toBe('34') + expect(mark.container.querySelector('svg')?.getAttribute('class')).toBe('hero-mark') + mark.rerender() + expect(mark.container.querySelector('svg')?.getAttribute('width')).toBe('24') + }) +}) diff --git a/packages/client/ui-brand-official/tests/invariant.client.spec.ts b/packages/client/ui-brand-official/tests/invariant.client.spec.ts new file mode 100644 index 0000000000..d8d3dd2d95 --- /dev/null +++ b/packages/client/ui-brand-official/tests/invariant.client.spec.ts @@ -0,0 +1,18 @@ +import { Context } from '@deepseek-ai/cordis' +import InvariantRegistry from '@deepseek-ai/dsh-invariants' +import { describe, expect, it } from 'vitest' +import * as BrandInvariant from '../src/invariant.ts' +import { apply as nodeApply } from '../src/index.ts' + +describe('official brand invariant companion', () => { + it('reserves package ownership with an empty installer', async () => { + const ctx = new Context() + await ctx.plugin(InvariantRegistry, { enabled: true }) + + await expect(ctx.plugin(BrandInvariant).await()).resolves.toBeDefined() + }) + + it('keeps the node half as an inert Loader seat', () => { + expect(() => { nodeApply() }).not.toThrow() + }) +}) diff --git a/packages/client/ui-brand-official/tsconfig.json b/packages/client/ui-brand-official/tsconfig.json new file mode 100644 index 0000000000..f98c0a8b2f --- /dev/null +++ b/packages/client/ui-brand-official/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../runtime-diagnostics/invariants" + }, + { + "path": "../runtime" + }, + { + "path": "../ui-conversation" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../ui-sidebar" + }, + { + "path": "../ui-slots" + } + ] +} diff --git a/packages/client/ui-brand-official/tsdown.config.ts b/packages/client/ui-brand-official/tsdown.config.ts new file mode 100644 index 0000000000..abc830c1f6 --- /dev/null +++ b/packages/client/ui-brand-official/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-brand-official', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/client/ui-commands/package.json b/packages/client/ui-commands/package.json index c030024ff9..5cced81487 100644 --- a/packages/client/ui-commands/package.json +++ b/packages/client/ui-commands/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-commands", "description": "Client command surface: global directory cache, '/' source, three command UI kinds, popupSelect registry", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 18c6371734..44c63ba33c 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -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: 2e3b99fbd4fca6bbe7b12d8408edb30597f8259e -README.zh.md: 6f3aaa313c0f9372a6191e50dd34916fadffa8f3 +README.md: a90d1d9dd8156fc1063dc7358374f8b1715deb1a +README.zh.md: acab41ad8c95c9e24b39fc875f3047f429f3add2 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 2e3b99fbd4..a90d1d9dd8 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -6,7 +6,7 @@ Conversation domain: skeleton (header/tabs/composer/empty state), chat view (gro Compaction renders as one collapsed row at the checkpoint's flow position without replacing the transcript above it. Automatic compaction uses the context-compacted title. Every completed marker with a loaded `compaction/summary` event shows the replaced-item and estimated-token counts and discloses the summary on click. Manual `/compact` starts as a running `compact` row; on successful settlement its explicit summary-event reference folds that command into the checkpoint row under the same React key. A completed checkpoint keeps the context-compaction icon at rest and replaces it with the collapsed or expanded disclosure only on hover or keyboard focus. Input rejection, no compactable history, cancellation, and failure retain the generic command row and its handler-authored text. Pairing never depends on adjacency because durable context may be injected while compaction is running. The framed checkpoint payload is model-facing and never renders; when the cited `compaction/summary` event is outside the loaded window, the checkpoint remains visible but non-expandable. -The resident conversation shell survives no-session and session transitions. Without a current session it locks message actions and presents the whole dashed composer card as a trigger for the root-scoped `conversation.hero.workspace` Workspace picker; the textarea remains read-only and keyboard-accessible. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. The root always owns the same scrollport and Hero/composer subtree; separate strict-session header and body outlets fill their regions when the first Session arrives, so the Workspace picker, scroll body, composer seat, and textarea retain their React and DOM identity. 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 shows only the current session title and view tabs as ordinary column chrome; fork lineage remains session data and is not projected into the header. Beneath it the scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). That scrollport reserves its scrollbar gutter unconditionally, and a view opting into a composer overlay leaves it a scroll container, so the input card keeps one horizontal position whether or not the transcript scrolls and whichever view tab is shown ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host. Safari alone receives a pre-paint recovery when a native edit shortens the draft and leaves stale soft-wrap overflow; draft growth, programmatic updates, and other browsers never read layout for that recovery ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md)). +The resident conversation shell survives no-session and session transitions. Without a current session it locks message actions and presents the whole dashed composer card as a trigger for the root-scoped `conversation.hero.workspace` Workspace picker; the textarea remains read-only and keyboard-accessible. The Hero's leading mark is the independent root-scoped `conversation.hero.brand.mark` slot, with the fish mark as its fallback. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. The root always owns the same scrollport and Hero/composer subtree; separate strict-session header and body outlets fill their regions when the first Session arrives, so the Workspace picker, scroll body, composer seat, and textarea retain their React and DOM identity. 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 shows only the current session title and view tabs as ordinary column chrome; fork lineage remains session data and is not projected into the header. Beneath it the scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). That scrollport reserves its scrollbar gutter unconditionally, and a view opting into a composer overlay leaves it a scroll container, so the input card keeps one horizontal position whether or not the transcript scrolls and whichever view tab is shown ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host. Safari alone receives a pre-paint recovery when a native edit shortens the draft and leaves stale soft-wrap overflow; draft growth, programmatic updates, and other browsers never read layout for that recovery ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md)). Another plugin can make one session's composer inert through `ctx.conversation.blocks`: it sets a block carrying its own localized reason, and the bar renders the same disabled textarea with that reason as the placeholder — the no-workspace posture, reused. The push direction is the constraint, not a preference: the plugins that know a session cannot send (ui-model-selection, when no adapter serves its route) already depend on this package, so this package cannot read them. The model seat is the one control a block leaves live — every block this contract has is cleared by choosing a model, so locking it too would leave the composer asking for the only thing it prevents. A block is an affordance only; the Host refuses a prompt it cannot route regardless of what any client disables. The no-workspace state wins when both hold, because picking a workspace is the earlier prerequisite. @@ -14,11 +14,11 @@ The view ring is a slot: the strict session-body registration declares the sessi Chat business rows are independent registry contributions rather than a closed built-in union. A client plugin declaration-merges its typed `ChatNodeDataMap` key, registers a `ConversationNodeDefinition` on `ctx.conversationEvents`, and registers the matching keyed renderer on `conversation.chat.node`; it does not modify Session folds or a central renderer switch. The [Conversation Node cookbook](../../../docs/cookbook/adding-a-conversation-node.md) covers stable event ids, append/prepend replay, Location data, and renderer constraints. -Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-user-questions 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 runtime manager projects every approval or question wait through `SessionSummary.pendingInteraction`, including sessions never instantiated; `ui-workspace` owns its sidebar presentation. Pending waits leave the message flow entirely: questions (ui-user-questions) 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. Safe preset picks submit `/permission ` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing. +Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-user-questions 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 runtime manager projects every approval or question wait through `SessionSummary.pendingInteraction`, including sessions never instantiated; `ui-workspace` owns its sidebar presentation. Pending waits leave the message flow entirely: questions (ui-user-questions) 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 built-in preset ids render as localized product labels only when the host keeps their canonical names, while explicit host labels remain unchanged and unknown kebab-case names render in title case. Safe preset picks submit `/permission ` immediately through the bar's injected `command` callback, while `danger-full-access` first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing. The session header renders the session-scoped `'conversation.session.header.actions'` list beside the title and the independent `'conversation.session.header.utilities'` list at the right edge. Session context and lineage controls remain in `actions`; optional Session utilities cannot reorder or move them. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership. -Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A source that names no producer shows the role alone. The shared `DisclosureRow` primitive gives this context surface the same compact geometry as other flow rows while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap and synthesizes no tool state or summary ([historical disclosure decision](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md), [producer-label decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). That body follows the form the producer declared on its durable source: `instructions` names the reconciled files above their text, `catalog` lists the entries the source recorded instead of the model-facing prose, and every other value — absent, unknown to this version, or carrying no usable fields — renders the opaque body, which shows the model-facing text with its real line breaks and the remaining source fields. The opaque body is the documented default, not a leftover: a resumed, forked, or foreign log must render whether or not its producer is mounted here. A durable or pending steering bubble shares the user bubble's presentation unadorned; its mid-turn position in the flow is the only steering signal the transcript shows. +Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A direct message that cites another session precedes its recall row in durable order. The Chat snapshot associates exact labels only from that immediately following sourced recall, preserving multi-word titles without carrying one recall's labels onto a later direct message. Recall uses a chat-bubble glyph while other context keeps the document glyph; a source that names no producer shows the role alone. Composer and user-bubble references use the same inline language: a chat-bubble, file, or folder glyph plus business-color text, without a nested capsule. Like claimed slash commands, composer references keep their complete display text in the transparent textarea and use the aligned backdrop for color and the leading domain glyph; native text metrics own width, wrapping, selection, and caret placement. The occurrence range remains structured for serialization and boundary deletion, while an edit inside it converts the remaining characters to ordinary text. The session draft mirror stores each occurrence's clipboard projection, so a remount without the occurrence table restores canonical parseable reference text instead of a display-only label. The shared `DisclosureRow` primitive gives this context surface the same compact geometry as other flow rows while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap and synthesizes no tool state or summary ([historical disclosure decision](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md), [producer-label decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). That body follows the form the producer declared on its durable source: `instructions` names the reconciled files above their text, `catalog` lists the entries the source recorded instead of the model-facing prose, and every other value — absent, unknown to this version, or carrying no usable fields — renders the opaque body, which shows the model-facing text with its real line breaks and the remaining source fields. The opaque body is the documented default, not a leftover: a resumed, forked, or foreign log must render whether or not its producer is mounted here. A durable or pending steering bubble shares the user bubble's presentation unadorned; its mid-turn position in the flow is the only steering signal the transcript shows. A Think row stays collapsed by default and exposes live reasoning throughput without expanding the chain of thought: while its reasoning block is the streaming tail, the summary switches from the settled first line to the latest non-blank line and its one-line scrollport follows each delta to the inline end. Expanding the row removes the moving summary and leaves the full reasoning in ordinary page flow, so page reading never fights an internal follower; settlement restores the stable first-line summary at the left edge ([decision](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md)). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 6f3aaa313c..acab41ad8c 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -6,7 +6,7 @@ 压缩(compaction)在检查点自身的消息流位置渲染为一行折叠标记,不替换其上方的 transcript(文本记录)。自动压缩使用「上下文已压缩」标题。每个已加载对应 `compaction/summary` 事件的完成标记都会显示被替换条目数量和估算 token 数量,并可点击展开摘要。手动 `/compact` 开始时显示为运行中的 `compact` 行;成功结算后,其显式摘要事件引用会在保持同一 React key 的前提下把该命令折叠进检查点行。完成的检查点静止时保留上下文压缩(context compaction)图标,仅在悬停或键盘聚焦时将其替换为收起/展开指示图标。输入被拒绝、没有可压缩历史、取消和失败时仍使用通用命令行及处理器撰写的文本。配对绝不依赖相邻关系,因为压缩运行期间可能注入持久上下文。面向模型的带框检查点载荷绝不渲染;被引用的 `compaction/summary` 事件位于已加载窗口之外时,检查点仍然可见但不可展开。 -常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会锁定消息操作,并让整张虚线编辑器卡片成为根作用域 `conversation.hero.workspace` Workspace picker 的入口;textarea 保持只读且支持键盘操作。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。根组件始终拥有同一个滚动容器与 Hero/编辑器子树;首个会话到达时,彼此独立的严格会话页头和主体 outlet 只填入各自区域,因此 Workspace picker、滚动主体、编辑器 seat 与 textarea 都保留原有 React 和 DOM identity。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段,会话标题栏作为普通列 chrome,仅显示当前会话标题和视图标签;fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。该滚动容器无条件预留自己的滚动条槽,选用编辑器 overlay 的视图也仍把它保留为滚动容器,因此无论对话记录是否滚动、无论展示哪个视图标签,输入卡片都保持同一个横向位置([决策](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md))。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。只有 Safari 会在原生编辑缩短草稿并留下陈旧软换行溢出时执行绘制前恢复;草稿增长、程序化更新与其他浏览器都不会为这项恢复读取布局([决策](../../../.agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md))。 +常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会锁定消息操作,并让整张虚线编辑器卡片成为根作用域 `conversation.hero.workspace` Workspace picker 的入口;textarea 保持只读且支持键盘操作。Hero 前方的标记是独立的根作用域 `conversation.hero.brand.mark` slot,未被占用时回退到鱼形标记。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。根组件始终拥有同一个滚动容器与 Hero/编辑器子树;首个会话到达时,彼此独立的严格会话页头和主体 outlet 只填入各自区域,因此 Workspace picker、滚动主体、编辑器 seat 与 textarea 都保留原有 React 和 DOM identity。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段,会话标题栏作为普通列 chrome,仅显示当前会话标题和视图标签;fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。该滚动容器无条件预留自己的滚动条槽,选用编辑器 overlay 的视图也仍把它保留为滚动容器,因此无论对话记录是否滚动、无论展示哪个视图标签,输入卡片都保持同一个横向位置([决策](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md))。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。只有 Safari 会在原生编辑缩短草稿并留下陈旧软换行溢出时执行绘制前恢复;草稿增长、程序化更新与其他浏览器都不会为这项恢复读取布局([决策](../../../.agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md))。 别的插件可以经 `ctx.conversation.blocks` 让某个会话的编辑器变为惰性:它设置一个携带自己本地化理由的 block,输入栏就渲染同一个禁用的 textarea,并把该理由作为 placeholder——复用无 Workspace 时的那套姿态。推送方向是约束而非偏好:知道某会话发不出消息的插件(ui-model-selection,在没有适配器服务其路由时)本就依赖本包,因此本包读不到它们。模型 seat 是 block 唯一保留可用的控件——这份约定里的每个 block 都靠选模型来解除,把它一起锁上会让编辑器索要它自己拦下的那件事。block 只是提示性设计;无论客户端禁用了什么,宿主都会拒绝一个它无法路由的提示词。两者同时成立时以无 Workspace 姿态为准,因为选 Workspace 是更靠前的前提。 @@ -16,7 +16,7 @@ Chat 业务行是彼此独立的注册表贡献,不是封闭的内建联合。 会话页头会在标题旁渲染会话作用域的 `'conversation.session.header.actions'` 列表,并在最右侧渲染独立的 `'conversation.session.header.utilities'` 列表。会话上下文和谱系控件保留在 `actions` 中;可选的会话工具不会改变它们的顺序或位置。编辑器链的 currency 包含当前对话 `session`;ui-subagent 会选取 one-shot 或 parent 不可用的已寻址会话,并按原因显示只读文案,而普通 InputBar 会让所有已寻址 child 仅保留 Send,因为继续执行服务不公开逐 Activation 取消操作,`session.cancel` 也会绕过其所有权。 -已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill(技能)目录、工作区指令文件与被召回的会话。来源未提供生产者名称时只显示角色。共享的 `DisclosureRow` 原子组件让该上下文界面与消息流中的其他紧凑行保持相同几何,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,且不会合成工具状态或摘要([历史展开项决策](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md)、[生产者标签决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。该内容区按生产方在持久来源上声明的形态渲染:`instructions` 在正文之上列出它对账过的文件,`catalog` 列出来源记录的条目而非面向模型的正文,其余取值——未声明、本版本不认识、或字段不可用——一律渲染 opaque 内容区,即按真实换行展示面向模型的文本,并把剩余来源字段列出。opaque 不是兜底剩余物而是有文档的默认:恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处,都必须渲染得出来。持久或待处理的 steering(中途引导)气泡沿用用户气泡的呈现,不加任何装饰;transcript 中唯一的 steering 信号是它出现在轮次中途的位置。 +已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill(技能)目录、工作区指令文件与被召回的会话。引用其他会话的直接消息在持久顺序中位于其召回行之前。Chat 快照只从紧随其后的带来源召回中关联准确标签,因此既能保留多词标题,也不会把一条召回的标签带到后续直接消息上。召回使用聊天气泡图标,其他上下文保留文档图标;来源未提供生产者名称时只显示角色。输入框与用户气泡中的引用使用同一种行内语言:聊天气泡、文件或文件夹图标加业务色文字,不嵌套胶囊容器。与已认领的 slash command 相同,输入框引用会把完整展示文本保留在透明 textarea 中,再用对齐的 backdrop 提供颜色和开头的领域图标;宽度、换行、选择区与光标位置均由原生文本度量决定。occurrence 范围仍为序列化与边界整段删除保留结构身份,在范围内部编辑则会把剩余字符转为普通文本。会话草稿镜像会存储每个 occurrence 的剪贴板投影,因此在 occurrence 表缺失的情况下重新挂载时,会恢复可解析的规范引用文本,而不是仅供显示的标签。共享的 `DisclosureRow` 原子组件让该上下文界面与消息流中的其他紧凑行保持相同几何,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,且不会合成工具状态或摘要([历史展开项决策](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md)、[生产者标签决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。该内容区按生产方在持久来源上声明的形态渲染:`instructions` 在正文之上列出它对账过的文件,`catalog` 列出来源记录的条目而非面向模型的正文,其余取值——未声明、本版本不认识、或字段不可用——一律渲染 opaque 内容区,即按真实换行展示面向模型的文本,并把剩余来源字段列出。opaque 不是兜底剩余物而是有文档的默认:恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处,都必须渲染得出来。持久或待处理的 steering(中途引导)气泡沿用用户气泡的呈现,不加任何装饰;transcript 中唯一的 steering 信号是它出现在轮次中途的位置。 Think 行默认保持折叠,并在不展开思维链的情况下暴露实时推理(reasoning)吞吐:当推理块是流式输出尾部时,摘要从结算后的首行切换到最新的非空行,其单行滚动区会随每个 delta 追到行内末端。展开该行会移除移动摘要,让完整推理进入普通页面流,因此页面阅读不会与内部跟随器争夺滚动;结算后恢复左对齐的稳定首行摘要([决策](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md))。 @@ -24,7 +24,7 @@ Think 行默认保持折叠,并在不展开思维链的情况下暴露实时 聊天流会将跨重试轮次连续出现的模型重试节点投影为一个稳定的弱化状态行,并用最新一次尝试更新该行;每个重试事件仍保留在运行时快照与会话日志中。前端倒计时以客户端收到事件的时刻为计划延迟的起点,避免 Host 与浏览器的时钟偏差;剩余时间向上取整到秒,且下限为 1 秒。最近一次尚未完成的重试会显示从左到右的文字渐变动画。后续轮次事实用于区分已开始的尝试与在退避期间取消的尝试,Host 的 running 位只控制实时动画;随后该行会显示静态的已完成或已取消标签。normal 策略行显示有限重试上限;always 策略行显示 `∞`。激活该行会显示最近一次重试的精确延迟和失败消息。客户端运行时会在相应重试节点到达前移除每个失败步骤的流式输出尾部;后续某次尝试成功后,该状态仍保持可见。未进入重试的终态失败会在其轮次边界渲染为持久的内联状态,展示适合显示的持久消息与可选错误码,但不会提供 Host 无法兑现的操作;AUTH 文案绝不会回显提供方给出的凭据片段。 -审批通过本包声明的链条接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-user-questions 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。运行时 manager 会将所有审批或问题等待通过 `SessionSummary.pendingInteraction` 投影出来,未实例化的会话也不例外;`ui-workspace` 负责其侧边栏呈现。未决等待完全离开消息流:问题(ui-user-questions)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,其中 kebab-case 预设名渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission `,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用;取消、Escape、关闭按钮与点击遮罩都不会提交命令。 +审批通过本包声明的链条接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-user-questions 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。运行时 manager 会将所有审批或问题等待通过 `SessionSummary.pendingInteraction` 投影出来,未实例化的会话也不例外;`ui-workspace` 负责其侧边栏呈现。未决等待完全离开消息流:问题(ui-user-questions)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,其中内置预设 id 仅在 host 保留规范名称时渲染为本地化产品标签,显式 host 标签保持原样,未知 kebab-case 预设名仍渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission `,而 `danger-full-access` 选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用;取消、Escape、关闭按钮与点击遮罩都不会提交命令。 `TodoDock` 以 `order: 0` 占用 `'conversation.input.dock'` 列表 slot(位于 Goal 与 Queue 之前),作为计划条读取 host 计算的 `todos` 投影(当前计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`。面板接收纯列表,列表为空时自我隐藏;列表非空时默认折叠,表头显示标题及以 `·` 连接的各状态计数(如 `1 已完成 · 2 进行中 · 1 待处理`,省略零计数)。dock adapter 拥有 selection,因此面板保持为 props 的纯函数。输入区 composer 链隐藏的一切也会隐藏整个 dock。`todo_write` 工具行属于 [`ui-tool`](../ui-tool/README.md)。 diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 8fd8c44d4c..a8596f1209 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-conversation", "description": "Conversation domain: skeleton, ordered chat flow, composer with the Host-backed busy-Enter preference, and details host", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 6770f349a0..294009a09b 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -206,6 +206,7 @@ export function apply(ctx: Context): void { 'conversation.composer.dock': { kind: 'list', scope: 'session' }, 'conversation.input.left': { kind: 'list', scope: 'session' }, 'conversation.input.right': { kind: 'list', scope: 'session' }, + 'conversation.hero.brand.mark': { kind: 'single', scope: 'root' }, 'conversation.hero.workspace': { kind: 'single', scope: 'root' }, 'conversation.hero.agentPreset': { kind: 'single', scope: 'root' }, }, @@ -337,6 +338,7 @@ export function apply(ctx: Context): void { inputTriggers.toggleSource('command', { trigger: '/', query: '', + quoted: false, position: snapshot.draft.slice(0, selection.start).trim() === '' ? 'leading' : 'inline', span: { ...selection, draftRev: snapshot.draftRev }, }) diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css index 8fdba2baa0..c9e8d19d77 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css @@ -17,6 +17,28 @@ gap: 16px; } +/* Wide markdown tables (the renderer's ≥4-column md-table-wide hook) span + the whole transcript width instead of clipping at the 748px message + column, while the table content keeps starting at its normal x (the lead + padding compensates the negative margin) — deepsuite chat TableWrapper + parity, with 100cqw (the ChatView scroll container) standing in for its + JS-measured --dsl-virtual-list-width. `--dsh-table-spare` clamps to zero + when the transcript is narrower than the message column, so narrow + viewports keep the plain in-column scroll with no sideways shift. + Percentages resolve against the wrapper's containing block, so a table + indented inside a list still reaches the same transcript edges. */ +.body :global(.md-table-wide) { + --dsh-table-spare: max(0px, calc((100cqw - var(--dsh-chat-content-width)) / 2)); + --dsh-table-lead: calc(var(--dsh-table-spare) + min(var(--dsh-chat-content-width), 100cqw) - 100%); + box-sizing: border-box; + width: calc(100% + var(--dsh-table-lead) + var(--dsh-table-spare)); + /* The base .tableScroll caps at the column (max-width: 100%); the breakout + is exactly the case that must exceed it. */ + max-width: none; + margin-left: calc(-1 * var(--dsh-table-lead)); + padding-left: var(--dsh-table-lead); +} + /* Interrupted-turn terminal marker: quiet inline tag, no animation. */ .stopped { align-self: flex-start; diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.module.css b/packages/client/ui-conversation/src/client/chat/ChatView.module.css index ffee12984f..73fa688f95 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.module.css +++ b/packages/client/ui-conversation/src/client/chat/ChatView.module.css @@ -18,6 +18,11 @@ /* Sides = composer clearance + 16px: on narrow viewports the transcript stays exactly 32px narrower than the input card (the shared width rule). */ padding: 16px calc(var(--dsh-composer-side-clearance) + 16px); + /* Inline-size query container: wide markdown tables size their breakout + against this box's content width (100cqw in AssistantMarkdown's + md-table-wide rule) — the CSS stand-in for deepsuite chat's JS-measured + --dsl-virtual-list-width. */ + container-type: inline-size; } :global([data-conversation-scroll]) .root { diff --git a/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.tsx b/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.tsx index 889b15b085..3e9e0f10b1 100644 --- a/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.tsx +++ b/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.tsx @@ -2,6 +2,7 @@ import { useState } from 'react' import type { ContextMessageNode } from '@deepseek-ai/dsh-client-runtime/client' import type { ChatViewSlotProps } from '../contract/slots.ts' import { DisclosureRow, IconBrowseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { ReferenceIcon } from '../reference/ReferenceIcon.tsx' import { contextBody } from './ContextBody.tsx' import css from './ContextInjectionRow.module.css' @@ -37,7 +38,9 @@ export function ContextInjectionRow({ content, source, provenance, form, t }: Co return ( } + icon={provenance.role === 'recall' + ? + : } chevronClassName={css.chevron} title={t(provenance.role === 'recall' ? 'message.contextRecall' : 'message.contextInjection')} collapsedContent={provenance.label === null ? undefined : ( diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css index 36824d228d..54e5f6f98d 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css @@ -28,6 +28,11 @@ color: var(--dsw-alias-label-primary); } +.referenceSummary { + color: var(--dsw-alias-label-tertiary); + font-size: 12px; + line-height: 18px; +} .contextRow { padding: 2px 0; } @@ -268,17 +273,19 @@ } } -/* Reference chip projection inside a user bubble (`name` model - spans render as chips; free geometry — no textarea pairing here). */ +/* Inline references use domain glyphs and business-color text without another + container inside the user bubble. */ .refChip { - display: inline-block; + display: inline-flex; + align-items: center; + gap: 4px; margin: 0 2px; - padding: 0 8px; - border-radius: 6px; - background: rgba(97, 135, 216, 0.22); - color: var(--dsw-alias-label-primary); - font-size: 0.85em; - line-height: 1.6; + color: var(--dsw-alias-state-business-primary); + font-weight: 500; white-space: nowrap; vertical-align: baseline; } + +.refIcon { + flex: none; +} diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index ecb0d11caa..8a882a70c5 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -10,6 +10,7 @@ import type { } from '@deepseek-ai/dsh-client-runtime/client' import { JsonBlock, MessageText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatNodeOwnerProps, ChatNodeViewProps, ChatViewSlotProps } from '../contract/slots.ts' +import { ReferenceIcon } from '../reference/ReferenceIcon.tsx' import { CompactionItem } from './CompactionItem.tsx' import { ContextInjectionRow } from './ContextInjectionRow.tsx' import { MessageIconActions } from './MessageIconActions.tsx' @@ -152,21 +153,59 @@ function TurnMaxTokensItem({ t }: { * scan as the composer, minus the lexicon: sent tokens were validated at * compose time, so shape alone decorates). */ -function projectUserText(text: string): ReactNode { - const re = /(^|\s)([/@][\w-]+)(?=\s|$)/g - const parts: ReactNode[] = [] - let cursor = 0 +function projectUserText(text: string, sessionLabels: readonly string[]): ReactNode { + const ranges: { start: number; end: number; label: string; kind: 'session' | 'plain' }[] = [] + for (const rawLabel of [...new Set(sessionLabels)].sort((a, b) => b.length - a.length)) { + const label = `@${rawLabel}` + let start = text.indexOf(label) + while (start >= 0) { + ranges.push({ start, end: start + label.length, label, kind: 'session' }) + start = text.indexOf(label, start + label.length) + } + } + const re = /(^|\s)(\/[\w-]+|@"[^"\n]+"|@[^\s]+)/gu let m: RegExpExecArray | null while ((m = re.exec(text)) !== null) { const tokenStart = m.index + (m[1]?.length ?? 0) - const label = m[2] ?? '' + const rawLabel = m[2] ?? '' + const label = rawLabel.startsWith('@"') + ? rawLabel + : rawLabel.replace(/[.,;:!?,。;:!?]+$/gu, '') + if (label.length <= 1) continue + ranges.push({ start: tokenStart, end: tokenStart + label.length, label, kind: 'plain' }) + } + ranges.sort((a, b) => a.start - b.start + || (a.kind === b.kind ? b.end - a.end : a.kind === 'session' ? -1 : 1)) + const parts: ReactNode[] = [] + let cursor = 0 + for (const range of ranges) { + if (range.start < cursor) continue + const { start: tokenStart, end, label, kind } = range if (tokenStart > cursor) parts.push() + const referenceKind = kind === 'session' + ? 'session' + : label.startsWith('@') + ? label.endsWith('/') ? 'folder' : 'file' + : undefined + const displayLabel = referenceKind === undefined + ? label + : referenceKind === 'session' + ? label.slice(1) + : label.slice(1).replace(/^"|"$/gu, '').split(/[\\/]/u).filter(Boolean).at(-1) ?? label.slice(1) parts.push( - - {label} + + {referenceKind !== undefined && ( + + )} + {displayLabel} , ) - cursor = tokenStart + label.length + cursor = end } if (parts.length === 0) return if (cursor < text.length) parts.push() @@ -175,7 +214,7 @@ function projectUserText(text: string): ReactNode { /** Right-aligned bubble shared by user and steering rows. */ function UserStyleBubble({ - content, renderMessageImages, actions, pending = false, t, + content, renderMessageImages, actions, pending = false, referenceLabels = [], t, }: { content: readonly unknown[] renderMessageImages: ChatNodeOwnerProps['renderMessageImages'] @@ -183,6 +222,8 @@ function UserStyleBubble({ actions?: (text: string) => ReactNode /** Whether this is the Host-authoritative pre-admission steering projection. */ pending?: boolean + /** Exact session mention labels associated by the adjacent recall node. */ + referenceLabels?: readonly string[] t: ChatViewSlotProps['t'] }): ReactNode { const { text, images, rest } = contentParts(content) @@ -193,9 +234,14 @@ function UserStyleBubble({
{renderMessageImages({ images, align: 'end' })} {showBubble &&
- {projectUserText(text)} + {projectUserText(text, referenceLabels)} {rest.map((block, i) => )}
} + {referenceLabels.length > 0 && ( +
+ {t('message.referenceSummary', { labels: referenceLabels.join(t('message.referenceSeparator')) })} +
+ )}
{actions?.(text)} @@ -240,6 +286,7 @@ export const UserMessageNodeView = memo(function UserMessageNodeView({ ( diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts b/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts index 880bbafa44..df8f87ee53 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts @@ -163,6 +163,7 @@ function finalNode( firstTokenTime: state.firstTokenTime ?? null, completedTime: event.time, }, + ...event.data.interrupted === true ? { interrupted: true } : {}, } } const location = context.start?.location ?? context.matches.at(-1)?.location diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts b/packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts index be4e27111a..651fc780a3 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts @@ -5,6 +5,7 @@ import type { ConversationViewBuilder, ConversationViewDefinition, LegacyConversationSlice, PartialAssistant, RunningToolCall, } from '@deepseek-ai/dsh-client-runtime/client' +import { sessionRecallLabels } from '@deepseek-ai/dsh-client-runtime/client' import type { ChatNode } from '../contract/chat-nodes.ts' import { isRunningTool } from '../contract/chat-nodes.ts' @@ -138,6 +139,99 @@ function orderedVisible(nodes: readonly ChatConversationViewNode[]): ChatConvers .sort((left, right) => left.anchorSeq - right.anchorSeq || left.key.localeCompare(right.key)) } +function referenceMessageSeq(node: ChatConversationViewNode): number | undefined { + const candidate = node as ChatNode + return candidate.kind === 'user' || candidate.kind === 'steering' + ? candidate.data.seq + : undefined +} + +function followingRecall(node: ChatConversationViewNode): { + readonly messageSeq: number + readonly labels: readonly string[] +} | undefined { + const candidate = node as ChatNode + if (candidate.kind !== 'context') return undefined + return { + messageSeq: candidate.data.seq - 1, + labels: sessionRecallLabels(candidate.data.source), + } +} + +function withReferenceLabels( + node: ChatConversationViewNode, + labels: readonly string[], +): ChatConversationViewNode { + const candidate = node as ChatNode + if (candidate.kind !== 'user' && candidate.kind !== 'steering') return node + const current = candidate.data.referenceLabels ?? EMPTY_KEYS + const hasLabels = Object.hasOwn(candidate.data, 'referenceLabels') + if (sameReferences(current, labels) && hasLabels === (labels.length > 0)) return node + const data: Record = { ...candidate.data } + if (labels.length === 0) delete data.referenceLabels + else data.referenceLabels = labels + return { ...candidate, data } +} + +/** Associates a direct message with the sourced recall event that immediately follows it. */ +class ReferenceLabelProjector { + private readonly messagesBySeq = new Map() + private readonly labelsByMessageSeq = new Map() + + replace(nodes: readonly ChatConversationViewNode[]): readonly ChatConversationViewNode[] { + this.messagesBySeq.clear() + this.labelsByMessageSeq.clear() + for (const node of nodes) { + const messageSeq = referenceMessageSeq(node) + if (messageSeq !== undefined) this.messagesBySeq.set(messageSeq, node.key) + const recall = followingRecall(node) + if (recall !== undefined && recall.labels.length > 0) { + this.labelsByMessageSeq.set(recall.messageSeq, recall.labels) + } + } + return nodes.map((node) => { + const messageSeq = referenceMessageSeq(node) + return messageSeq === undefined + ? node + : withReferenceLabels(node, this.labelsByMessageSeq.get(messageSeq) ?? EMPTY_KEYS) + }) + } + + apply( + upserts: readonly ChatConversationViewNode[], + store: ChatNodeStore, + ): readonly ChatConversationViewNode[] { + const byKey = new Map(upserts.map(node => [node.key, node])) + const affected = new Set() + for (const node of upserts) { + const messageSeq = referenceMessageSeq(node) + if (messageSeq !== undefined) { + this.messagesBySeq.set(messageSeq, node.key) + affected.add(messageSeq) + } + const recall = followingRecall(node) + if (recall === undefined) continue + const current = this.labelsByMessageSeq.get(recall.messageSeq) + if (recall.labels.length === 0) this.labelsByMessageSeq.delete(recall.messageSeq) + else { + this.labelsByMessageSeq.set( + recall.messageSeq, + current !== undefined && sameReferences(current, recall.labels) ? current : recall.labels, + ) + } + affected.add(recall.messageSeq) + } + for (const messageSeq of affected) { + const key = this.messagesBySeq.get(messageSeq) + if (key === undefined) continue + const node = byKey.get(key) ?? store.get(key) + if (node === undefined) continue + byKey.set(key, withReferenceLabels(node, this.labelsByMessageSeq.get(messageSeq) ?? EMPTY_KEYS)) + } + return [...byKey.values()] + } +} + interface LegacyContribution { readonly anchorSeq: number readonly nodes: readonly ConversationNode[] @@ -384,6 +478,7 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder node.key) + const nodes = this.referenceLabels.replace(input.nodes) + this.store.replace(nodes) + this.order = orderedVisible(nodes).map(node => node.key) this.locations.rebuild(this.order, this.store) - return this.snapshot(input.timeline, this.legacy.replace(input.nodes, input.timeline)) + return this.snapshot(input.timeline, this.legacy.replace(nodes, input.timeline)) } apply(input: { readonly upserts: readonly ChatConversationViewNode[] readonly timeline: ConversationTimelineSnapshot }): ChatSnapshot { + const upserts = this.referenceLabels.apply(input.upserts, this.store) let structural = false const contentOnly: ChatConversationViewNode[] = [] - for (const node of input.upserts) { + for (const node of upserts) { const previous = this.store.get(node.key) const nodeStructural = previous === undefined || previous.anchorSeq !== node.anchorSeq @@ -416,14 +513,14 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder node.key) this.order = sameReferences(this.order, next) ? this.order : next this.locations.rebuild(this.order, this.store) } this.locations.touch(contentOnly) - return this.snapshot(input.timeline, this.legacy.apply(input.upserts, input.timeline)) + return this.snapshot(input.timeline, this.legacy.apply(upserts, input.timeline)) } private snapshot( diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/message.ts b/packages/client/ui-conversation/src/client/conversation-nodes/message.ts index 085127f9c5..c60bbf79f1 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/message.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/message.ts @@ -8,14 +8,24 @@ import { import type { InboxState } from './inbox.ts' import { chatNode } from './common.ts' -type MessageNode = UserMessageNode | SteeringMessageNode | ContextMessageNode +interface ReferencedUserMessageNode extends UserMessageNode { + /** Labels cited by the immediately following session-reference context. */ + readonly referenceLabels?: readonly string[] +} + +interface ReferencedSteeringMessageNode extends SteeringMessageNode { + /** Labels cited by the immediately following session-reference context. */ + readonly referenceLabels?: readonly string[] +} + +type MessageNode = ReferencedUserMessageNode | ReferencedSteeringMessageNode | ContextMessageNode declare module '@deepseek-ai/dsh-client-ui-conversation/client' { interface ChatNodeDataMap { /** Ordinary turn-opening user message. */ - user: UserMessageNode + user: ReferencedUserMessageNode /** User message admitted into an active turn. */ - steering: SteeringMessageNode + steering: ReferencedSteeringMessageNode /** Non-user context injected into model history. */ context: ContextMessageNode } diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index 4a8b27acbb..814734411b 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -32,7 +32,7 @@ export type { ChatStore, ChatViewInjected, ChatViewSlotProps, CommandRowOwnerProps, CommandRowProps, ComposerBarInjected, ComposerAttachment, ComposerAttachmentsOwnerProps, ComposerAttachmentsProps, ComposerChainProps, ConversationInjected, ConversationSessionHeaderInjected, ConversationSessionInjected, ConversationSlotProps, ConvViewOwnerProps, - ConvViewProps, DetailsInjected, DetailsSlotProps, DetailsToolOwnerProps, EmptyWorkspaceOwnerProps, + ConvViewProps, DetailsInjected, DetailsSlotProps, DetailsToolOwnerProps, EmptyWorkspaceOwnerProps, HeroBrandMarkOwnerProps, MessageImagesOwnerProps, MessageImagesProps, RenderMessageImages, TurnTailOwnerProps, UseChatNodeTurnData, } from './contract/slots.ts' // Export discipline: packages/client/AGENTS.md. diff --git a/packages/client/ui-conversation/src/client/input/contract.ts b/packages/client/ui-conversation/src/client/input/contract.ts index 91a2494a4d..b9ace8b863 100644 --- a/packages/client/ui-conversation/src/client/input/contract.ts +++ b/packages/client/ui-conversation/src/client/input/contract.ts @@ -149,9 +149,9 @@ export interface EditRange extends EditSelection { } /** - * One reference chip occurrence, backing exactly one U+FFFC placeholder in - * the draft. Identity is occurrenceId — same-named - * references stay independently addressable. label/clipboardText are the + * One reference occurrence backed by its complete inline display text in the + * draft. Identity is occurrenceId — same-named + * references stay independently addressable. label/appearance/clipboardText are the * owner's insert-time projections, cached so the chip survives owner loss * (invalid flips instead of dropping the occurrence). */ @@ -162,10 +162,14 @@ export interface Occurrence { readonly source: string /** Owner-scoped reference id. */ readonly ref: string - /** Placeholder offset in the draft; the occurrence occupies exactly [offset, offset+1). */ + /** Display-text offset in the draft. */ readonly offset: number - /** Chip display label (insert-time cache). */ + /** Display-text length; the occurrence occupies exactly [offset, offset+length). */ + readonly length: number + /** Inline display label (insert-time cache). */ readonly label: string + /** Optional domain glyph (insert-time cache). */ + readonly appearance?: ReferenceInsert['appearance'] /** Clipboard / persistence projection, e.g. `/name` (insert-time cache, never the model form). */ readonly clipboardText: string /** Owner-resolution failure flag: chip renders invalid; serialization must fail. */ @@ -215,7 +219,7 @@ export interface InputState { readonly phase: 'plain' | 'adjudicating' | 'claimed' | 'submitting' /** Present exactly while claimed/submitting (claim snapshot during flight; submit closure withheld). */ readonly claim?: { readonly token: string; readonly hint?: string; readonly images?: boolean } - /** Chip occurrence table, sorted by offset (one U+FFFC per entry). */ + /** Reference occurrence table, sorted by offset. */ readonly occurrences: readonly Occurrence[] /** Live paste-match attempt (absent when no paste is matchable). */ readonly paste?: PasteAttemptState @@ -232,7 +236,7 @@ export interface InputState { export interface SubmitAttempt { readonly seq: number readonly signal: AbortSignal - /** Draft at enter time; rollback restores it only while the live draft still equals it. */ + /** Draft at enter time; settlement clears it only after acceptance. */ readonly draftSnapshot: string /** Default-message delivery intent retained while slash adjudication is pending. */ readonly mode: InputSubmitMode @@ -248,7 +252,7 @@ export type InputEvent = /** Full next draft from the textarea; editRange narrows the occurrence math (absent → diff scan). */ | { readonly type: 'draft-changed'; readonly draft: string; readonly editRange?: EditRange } | { readonly type: 'begin-command'; readonly claim: CommandClaim; readonly span: TokenSpan } - /** Place one U+FFFC at the span and mint the occurrence (scoped insert-reference event payload). */ + /** Place one inline reference at the span and mint the occurrence (scoped insert-reference event payload). */ | { readonly type: 'insert-ref'; readonly reference: ReferenceInsert; readonly span: TokenSpan } /** Delete a settled command token; success is observable as a draftRev advance. */ | { readonly type: 'consume-token'; readonly guard: ConsumeTokenGuard } @@ -271,10 +275,7 @@ export type InputEvent = | { readonly type: 'adjudicated'; readonly attempt: SubmitAttempt; readonly outcome: PickOutcome } | { readonly type: 'adjudication-failed'; readonly attempt: SubmitAttempt; readonly message: string } | { readonly type: 'submit-settled'; readonly attempt: SubmitAttempt; readonly ok: boolean; readonly outcome?: SubmitOutcome; readonly message?: string } - /** - * An ordinary (default-sink) send was accepted: clear the draft as a COMMIT — - * undo must not resurrect sent content (mirrors submit-settled's success arm). - */ + /** Commit an image-only send whose empty draft did not need an attempt. */ | { readonly type: 'send-committed' } | { readonly type: 'release' } @@ -286,5 +287,5 @@ export type InputEvent = export type InputEffect = | { readonly type: 'adjudicate'; readonly attempt: SubmitAttempt; readonly draft: string } | { readonly type: 'begin-submit'; readonly attempt: SubmitAttempt; readonly claim: CommandClaim; readonly args: string } - | { readonly type: 'default-sink'; readonly draft: string; readonly mode: InputSubmitMode } + | { readonly type: 'default-sink'; readonly attempt: SubmitAttempt; readonly draft: string; readonly mode: InputSubmitMode } | { readonly type: 'notice'; readonly level: 'info' | 'error'; readonly text: string } diff --git a/packages/client/ui-conversation/src/client/input/decorations.ts b/packages/client/ui-conversation/src/client/input/decorations.ts index bd3dd09040..1ae5e9404b 100644 --- a/packages/client/ui-conversation/src/client/input/decorations.ts +++ b/packages/client/ui-conversation/src/client/input/decorations.ts @@ -1,6 +1,6 @@ /** - * Draft decoration pure core (chips render from the occurrence - * table at placeholder offsets; the claim token renders as a mirror-layer + * Draft decoration pure core (references render from occurrence ranges; the + * claim token renders as a mirror-layer * highlight, the claim hint as ghost text). Zero React — the skeleton renders * the instructions; tests drive this directly. */ @@ -12,13 +12,19 @@ export interface TokenRange { readonly end: number } -/** One chip render instruction: the placeholder at `offset` draws as `label`. */ +/** One structured inline-reference render instruction. */ export interface ChipRender { /** Stable render key (same-labeled chips stay independent). */ readonly occurrenceId: number - /** Placeholder offset in the draft (the chip occupies [offset, offset+1)). */ + /** Display-text offset in the draft. */ readonly offset: number + /** Display-text length in the draft. */ + readonly length: number + /** Exact inline text whose native glyph metrics determine layout. */ + readonly text: string readonly label: string + /** Optional domain glyph beside the label. */ + readonly appearance?: 'session' | 'file' | 'folder' /** Owner-resolution failure styling bit. */ readonly invalid: boolean } @@ -34,6 +40,8 @@ export interface TextRefRange { readonly start: number readonly end: number readonly trigger: '/' | '@' + /** Optional icon domain for syntax-recognizable plain references. */ + readonly appearance?: 'folder' } /** Decoration product: claim token range + chip instructions + text-ref ranges + the ghost hint. */ @@ -42,7 +50,7 @@ export interface DraftDecorations { readonly token: TokenRange | null /** Chip render instructions in draft order (occurrence table is offset-sorted). */ readonly chips: readonly ChipRender[] - /** Scan-derived plain-text reference ranges (empty without a lexicon). */ + /** Scan-derived lexicon tokens and syntax-recognizable folder ranges. */ readonly textRefs: readonly TextRefRange[] /** Ghost hint shown while the claim's args are blank; null otherwise. */ readonly hint: string | null @@ -50,6 +58,7 @@ export interface DraftDecorations { /** Token matcher: a trigger char at line start or after whitespace, then a word-ish name (never crosses \n). */ const TEXT_REF_RE = /(^|\s)([/@])([\w-]+)/g +const FOLDER_REF_RE = /(^|\s)(@(?:"[^"\n]*\/|[^\s"]+\/))/g /** * Scan the draft for plain-text reference tokens against the hot lexicons. @@ -63,19 +72,31 @@ const TEXT_REF_RE = /(^|\s)([/@])([\w-]+)/g export function scanTextRefs( draft: string, lexicon: ReadonlyMap<'/' | '@', readonly string[]>, ): TextRefRange[] { - if (lexicon.size === 0 || draft === '') return [] + if (draft === '') return [] const out: TextRefRange[] = [] - TEXT_REF_RE.lastIndex = 0 - let m: RegExpExecArray | null - while ((m = TEXT_REF_RE.exec(draft)) !== null) { - const trigger = m[2] as '/' | '@' - const name = m[3] ?? '' - if (lexicon.get(trigger)?.includes(name)) { - const start = m.index + (m[1]?.length ?? 0) - out.push({ start, end: start + 1 + name.length, trigger }) + if (lexicon.size > 0) { + TEXT_REF_RE.lastIndex = 0 + let m: RegExpExecArray | null + while ((m = TEXT_REF_RE.exec(draft)) !== null) { + const trigger = m[2] as '/' | '@' + const name = m[3] ?? '' + if (lexicon.get(trigger)?.includes(name)) { + const start = m.index + (m[1]?.length ?? 0) + out.push({ start, end: start + 1 + name.length, trigger }) + } } } - return out + FOLDER_REF_RE.lastIndex = 0 + let folder: RegExpExecArray | null + while ((folder = FOLDER_REF_RE.exec(draft)) !== null) { + const token = folder[2] ?? '' + const start = folder.index + (folder[1]?.length ?? 0) + const end = start + token.length + if (!out.some(range => range.start < end && range.end > start)) { + out.push({ start, end, trigger: '@', appearance: 'folder' }) + } + } + return out.sort((left, right) => left.start - right.start) } /** The empty lexicon (default: zero text-ref decorations, old call sites unchanged). */ @@ -97,7 +118,10 @@ export function deriveDecorations( const chips = occurrences.map(o => ({ occurrenceId: o.occurrenceId, offset: o.offset, + length: o.length, + text: draft.slice(o.offset, o.offset + o.length), label: o.label, + ...o.appearance === undefined ? {} : { appearance: o.appearance }, invalid: o.invalid === true, })) const hint = claimActive && claim.hint !== undefined && draft.slice(claim.token.length).trim() === '' diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index 2783aea854..cc42d0c1ad 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -10,14 +10,14 @@ import type { ClientContext, ObservableSnapshot, SnapshotStore } from '@deepseek import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, PickOutcome, - ReferenceInsert, InputTriggerController, SubmitImageAttachment, TokenSpan, + ReferenceInsert, InputTriggerController, SubmitImageAttachment, SubmitOutcome, TokenSpan, } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { DraftAttachmentId, EditRange, EditSelection, InputActions, InputEffect, InputNotice, InputState, PasteComponent, QueuedMessage, SessionInput, SubmitAttempt, } from './contract.ts' import type { InputSubmitMode } from '../contract/composer-submission.ts' -import { InputMachine } from './machine.ts' +import { InputMachine, projectClipboard } from './machine.ts' /** Popup face the shell needs (dismissal only; typed structurally to avoid a value import). */ export interface PopupDismissFace { @@ -45,7 +45,12 @@ export interface SessionInputDeps { */ steerQueue?: (() => void) | undefined /** The plain-message sink (send choreography / materialize fork — the hub owns it). */ - defaultSink(text: string, imageIds: readonly DraftAttachmentId[], mode: InputSubmitMode): void + defaultSink( + text: string, + imageIds: readonly DraftAttachmentId[], + mode: InputSubmitMode, + signal: AbortSignal, + ): Promise /** Command-plane image plumbing (the hub owns the conversation face and the copy). */ commandImages: { /** Resolve ordered draft ids to wire payloads without sending them; rejects when an id no longer resolves. */ @@ -93,10 +98,12 @@ export class SessionInputShell implements SessionInput { // production (the machine's no-clock default is a constant for pure tests). private readonly core = new InputMachine({ now: () => Date.now() }) private noticeSeq = 0 - private lastDraft = '' + private lastMirroredDraft = '' private imageIds: readonly DraftAttachmentId[] = [] + /** One image-only send at a time: Enter during the Host round-trip is a no-op. */ + private imageSendInFlight = false private disposed = false - /** Draft persistence mirror (chat store write; receives the clipboard projection, never raw placeholders). */ + /** Draft persistence mirror (chat store write; receives the clipboard projection, never display-only ranges). */ private mirrorFn: ((text: string) => void) | undefined constructor(private readonly deps: SessionInputDeps) { @@ -150,16 +157,6 @@ export class SessionInputShell implements SessionInput { this.publish() } - /** - * Restore a failed attempt before any images added after its admission. - * @param ids - failed attempt image ids. - */ - restoreImages(ids: readonly DraftAttachmentId[]): void { - const current = new Set(this.imageIds) - this.imageIds = [...ids.filter(id => !current.has(id)), ...this.imageIds] - this.publish() - } - /** * Clear the draft as a successful-send commit: no undo unit is recorded and * the undo history is cut, so Ctrl/Cmd-Z cannot resurrect sent content @@ -211,7 +208,19 @@ export class SessionInputShell implements SessionInput { */ submit(mode: InputSubmitMode = 'queue'): void { if (this.snapshot.draft.trim() === '' && this.imageIds.length > 0) { - if (this.snapshot.phase === 'plain') this.deps.defaultSink('', [...this.imageIds], mode) + if (this.snapshot.phase === 'plain' && !this.imageSendInFlight) { + const imageIds = [...this.imageIds] + this.imageSendInFlight = true + void this.deps.defaultSink('', imageIds, mode, new AbortController().signal).then((outcome) => { + this.imageSendInFlight = false + if (this.disposed) return + if (outcome.kind === 'success') this.commitSend(imageIds) + else if (outcome.text !== undefined) this.notify('error', outcome.text) + }, (error: unknown) => { + this.imageSendInFlight = false + if (!this.disposed) this.notify('error', error instanceof Error ? error.message : String(error)) + }) + } return } // Claimed pre-gate: a claim that does not declare image acceptance never @@ -350,13 +359,21 @@ export class SessionInputShell implements SessionInput { * a scan-derived decoration, never state. * @param text - the plain reference text to splice in (e.g. `/name `). * @param span - pick-time span snapshot (draftRev CAS). + * @param keepCompleting - re-track at the caret after the splice so an open + * token (a directory pick's trailing slash) reopens the menu. * @returns whether the text was applied. */ - insertText(text: string, span: TokenSpan): boolean { + insertText(text: string, span: TokenSpan, keepCompleting = false): boolean { const snapshot = this.core.state if (span.draftRev !== snapshot.draftRev) return false const draft = snapshot.draft this.setDraft(draft.slice(0, span.start) + text + draft.slice(span.end)) + if (keepCompleting) { + // Machine-driven draft replacement never passes through onChange, so + // re-track at the caret inside the still-open token (see space()). + const next = this.snapshot + this.deps.inputTriggers?.()?.track(next.draft, span.start + text.length, { tier: guardOf(next.phase) }, next.draftRev) + } return true } @@ -421,7 +438,7 @@ export class SessionInputShell implements SessionInput { return } case 'default-sink': { - this.sinkSerialized(fx.draft, fx.mode) + this.sinkSerialized(fx.attempt, fx.draft, fx.mode) return } default: @@ -431,42 +448,78 @@ export class SessionInputShell implements SessionInput { /** * Prompt serialization before the sink: expand each - * placeholder to its owner's model form via the session controller's + * inline reference range to its owner's model form via the session controller's * codec routing. Owner missing / serialize failure / disposal blocks the * send — notice + draft and chips retained, never a silent downgrade to * the clipboard text. Chip-free drafts skip the async detour. */ - private sinkSerialized(draft: string, mode: InputSubmitMode): void { + private sinkSerialized(attempt: SubmitAttempt, draft: string, mode: InputSubmitMode): void { const imageIds = [...this.imageIds] const occurrences = this.core.state.occurrences if (occurrences.length === 0) { - this.deps.defaultSink(draft.trim(), imageIds, mode) + this.settleSubmit(attempt, this.deps.defaultSink(draft.trim(), imageIds, mode, attempt.signal), imageIds) return } const inputTriggers = this.deps.inputTriggers?.() const controller = new AbortController() void Promise.all(occurrences.map(async (o) => { if (inputTriggers === undefined) throw new Error(`no serializer for reference source "${o.source}"`) - return { offset: o.offset, text: await inputTriggers.serializeReference(o.source, o.ref, controller.signal) } + return { + offset: o.offset, + length: o.length, + text: await inputTriggers.serializeReference(o.source, o.ref, controller.signal), + } })).then( (parts) => { if (this.disposed) return - // Splice model forms over their placeholders (offsets are draft-time; + // Splice model forms over their display ranges (offsets are draft-time; // parts arrive offset-sorted since the table is). let out = '' let cursor = 0 for (const part of parts) { out += draft.slice(cursor, part.offset) + part.text - cursor = part.offset + 1 + cursor = part.offset + part.length } out += draft.slice(cursor) - this.deps.defaultSink(out.trim(), imageIds, mode) + this.settleSubmit(attempt, this.deps.defaultSink(out.trim(), imageIds, mode, attempt.signal), imageIds) }, (error: unknown) => { controller.abort() - if (this.disposed) return + if (this.dead(attempt)) return const message = error instanceof Error ? error.message : String(error) - this.notify('error', message) + this.run(this.core.dispatch({ type: 'submit-settled', attempt, ok: false, message })) + }, + ) + } + + /** Settle one admission attempt; successful sends consume only their captured images. */ + private settleSubmit( + attempt: SubmitAttempt, + pending: Promise, + imageIds: readonly DraftAttachmentId[] = [], + ): void { + pending.then( + (outcome) => { + if (this.dead(attempt)) return + if (outcome.kind === 'success' && imageIds.length > 0) { + const submitted = new Set(imageIds) + this.imageIds = this.imageIds.filter(id => !submitted.has(id)) + } + this.run(this.core.dispatch({ + type: 'submit-settled', + attempt, + ok: outcome.kind === 'success', + outcome, + })) + }, + (error: unknown) => { + if (this.dead(attempt)) return + this.run(this.core.dispatch({ + type: 'submit-settled', + attempt, + ok: false, + message: error instanceof Error ? error.message : String(error), + })) }, ) } @@ -519,6 +572,7 @@ export class SessionInputShell implements SessionInput { } this.run(this.core.dispatch({ type: 'submit-settled', attempt, ok: outcome.kind === 'success', outcome, + ...(outcome.kind === 'error' && outcome.text === undefined ? { message: 'command failed' } : {}), })) }, (error: unknown) => { @@ -542,9 +596,10 @@ export class SessionInputShell implements SessionInput { private publish(): void { const next = this.compose() this.state.set(next) - if (next.draft !== this.lastDraft) { - this.lastDraft = next.draft - this.mirrorFn?.(next.draft) + const mirroredDraft = projectClipboard(next) + if (mirroredDraft !== this.lastMirroredDraft) { + this.lastMirroredDraft = mirroredDraft + this.mirrorFn?.(mirroredDraft) } } } diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index 789f18766c..7e3b95a314 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -9,7 +9,7 @@ * real host entity, so the sink is one unconditional prompt path. */ import type { ClientContext, ISessions, SessionBinding, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client' -import type { InputTriggerController, SubmitImageAttachment } from '@deepseek-ai/dsh-client-ui-input-trigger/client' +import type { InputTriggerController, SubmitImageAttachment, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client' import { queueReadFaceOf } from '../queue/store.ts' import type { ComposerKeyboard, DraftAttachmentId, SessionInputResolver, SessionInput } from './contract.ts' @@ -29,7 +29,8 @@ interface ConversationAttachmentFace { text: string, imageIds: readonly DraftAttachmentId[], mode: InputSubmitMode, - ): Promise + signal?: AbortSignal, + ): Promise serializeDraftImages(imageIds: readonly DraftAttachmentId[]): Promise releaseDraftImage(id: DraftAttachmentId): void } @@ -76,7 +77,7 @@ export class InputHub implements SessionInputResolver { inputTriggers: () => this.controller(actx), popup: () => this.popup(actx), queue: queueReadFaceOf(session), - defaultSink: (text, imageIds, mode) => { this.sink(session, text, imageIds, mode) }, + defaultSink: (text, imageIds, mode, signal) => this.sink(session, text, imageIds, mode, signal), steerQueue: () => { void this.steerQueue(session, shell) }, commandImages: { serialize: ids => this.conversation().serializeDraftImages(ids), @@ -105,7 +106,7 @@ export class InputHub implements SessionInputResolver { actx.on('slash/input-consume-token', req => shell.consumeToken(req.guard) ? true : undefined), actx.on('slash/input-insert-text', req => - shell.insertText(req.text, req.span) ? true : undefined), + shell.insertText(req.text, req.span, req.continue === true) ? true : undefined), ] return () => { for (const off of offs) off() @@ -166,20 +167,10 @@ export class InputHub implements SessionInputResolver { text: string, imageIds: readonly DraftAttachmentId[], mode: InputSubmitMode, - ): void { - if (text === '' && imageIds.length === 0) return - const shell = this.shells.get(session.sessionId) - // Commit, not an editable clear: undo must not resurrect sent content. - shell?.commitSend(imageIds) - void this.conversation().sendSession(session, text, imageIds, mode).catch(() => { - if (this.shells.get(session.sessionId) === shell) { - shell?.restoreImages(imageIds) - if (shell?.snapshot.draft === '') shell.setDraft(text) - return - } - const conversation = this.rootCtx.get('conversation') as ConversationAttachmentFace | undefined - for (const id of imageIds) conversation?.releaseDraftImage(id) - }) + signal: AbortSignal, + ): Promise { + if (text === '' && imageIds.length === 0) return Promise.resolve({ kind: 'success' }) + return this.conversation().sendSession(session, text, imageIds, mode, signal) } /** diff --git a/packages/client/ui-conversation/src/client/input/machine.ts b/packages/client/ui-conversation/src/client/input/machine.ts index f42b827d52..a1a49004a4 100644 --- a/packages/client/ui-conversation/src/client/input/machine.ts +++ b/packages/client/ui-conversation/src/client/input/machine.ts @@ -4,8 +4,8 @@ * clock. Package-private — the SessionInput shell is the only caller and the * sole executor of the returned effects. * - * Draft truth: the draft string holds one U+FFFC placeholder per chip; the - * occurrence table carries identity and the owner's cached projections. Every + * Draft truth: the draft string holds each reference's complete inline display + * text; the occurrence table carries identity, range, and the owner's cached projections. Every * draft mutation is one transaction — draft edit, occurrence reconciliation, * and undo-log push are atomic inside dispatch() — and bumps draftRev, which * is what lets span CAS reduce to a revision-equality check: equal rev ⟹ @@ -20,9 +20,21 @@ import type { InputState, Occurrence, PasteAttemptState, PasteComponent, SubmitAttempt, } from './contract.ts' -/** The object-replacement character backing every chip occurrence in the draft. */ +/** Legacy fixed-width object replacement character rejected from pasted text. */ export const PLACEHOLDER = '' +const REFERENCE_PLACEHOLDER_RE = /[\uE100-\uE11D\uFFFC]/gu + +/** + * Build the inline draft text whose leading marker is decorated as the + * reference icon in the backdrop. + * @param reference - reference insertion with its cached display projection. + * @returns display text with one marker glyph followed by the complete label. + */ +export function referenceDraftText(reference: Pick): string { + return `@${reference.label}` +} + /** The machine never writes the queue; the wiring layer overlays the queue store's projection. */ const EMPTY_QUEUE: InputState['queue'] = [] @@ -67,10 +79,9 @@ function diffEdit(prev: string, next: string): EditRange { } /** - * Expand the draft's placeholders into their occurrences' clipboard text - * (the persistence mirror and clipboard both write this - * projection — U+FFFC never leaves the machine). Table order is offset - * order, so one linear walk pairs placeholders with entries. + * Expand the draft's reference ranges into their occurrences' clipboard text + * for persistence and clipboard projection. Table order is offset order, so + * one linear walk pairs ranges with entries. * @param state - published input state. * @returns the plain-text projection of the draft. */ @@ -81,7 +92,7 @@ export function projectClipboard(state: Pick= range.end) kept.push(delta === 0 ? o : { ...o, offset: o.offset + delta }) } this.occurrences = kept @@ -225,14 +236,16 @@ export class InputMachine { } /** Mint one occurrence at a draft offset. */ - private mint(reference: ReferenceInsert, offset: number): Occurrence { + private mint(reference: ReferenceInsert, offset: number, length: number): Occurrence { this.occurrenceSeq += 1 return { occurrenceId: this.occurrenceSeq, source: reference.source, ref: reference.ref, offset, + length, label: reference.label, + ...reference.appearance === undefined ? {} : { appearance: reference.appearance }, clipboardText: reference.clipboardText, } } @@ -293,19 +306,20 @@ export class InputMachine { } /** - * Shared chip-insertion transaction: replace [span) with one placeholder + * Shared reference-insertion transaction: replace [span) with one inline * 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). + * space follows the reference unless one is already next. + * @returns the inserted length (display text plus optional gap). */ private replaceSpanWithChip(reference: ReferenceInsert, span: TokenSpan): number { this.pushTxn() this.typingRun = undefined const tail = this.draft.slice(span.end) const gap = tail.length === 0 || tail[0] !== ' ' ? ' ' : '' - const inserted = PLACEHOLDER + gap + const displayText = referenceDraftText(reference) + const inserted = displayText + gap this.reconcile({ start: span.start, end: span.end, insertedLength: inserted.length }) - this.withMinted([this.mint(reference, span.start)]) + this.withMinted([this.mint(reference, span.start, displayText.length)]) this.adopt(this.draft.slice(0, span.start) + inserted + tail) this.watchClaim() return inserted.length @@ -392,7 +406,7 @@ export class InputMachine { // ---- paste plane ---- /** - * Paste as one transaction: the text (U+FFFC-sanitized) replaces the + * Paste as one transaction: the text (reference-placeholder-sanitized) replaces the * selection; hot-snapshot sync matches componentize inside the SAME * transaction (one undo returns to pre-paste); a match attempt opens for * the async remainder while the phase still accepts reference mutations. @@ -403,19 +417,20 @@ export class InputMachine { ): InputEffect[] { const { start, end } = selection if (start < 0 || start > end || end > this.draft.length) return [] - const text = rawText.split(PLACEHOLDER).join('') + const text = rawText.replace(REFERENCE_PLACEHOLDER_RE, '') this.pushTxn(selection) this.typingRun = undefined // Componentize: replace each matched token range (paste-text coordinates, - // disjoint by contract) with a placeholder while assembling the insert. + // disjoint by contract) with inline display text while assembling the insert. const sorted = [...components].sort((a, b) => a.start - b.start) const minted: Occurrence[] = [] let inserted = '' let cursor = 0 for (const c of sorted) { inserted += text.slice(cursor, c.start) - minted.push(this.mint(c.reference, start + inserted.length)) - inserted += PLACEHOLDER + const displayText = referenceDraftText(c.reference) + minted.push(this.mint(c.reference, start + inserted.length, displayText.length)) + inserted += displayText cursor = c.end } inserted += text.slice(cursor) @@ -481,7 +496,9 @@ export class InputMachine { this.phase = 'adjudicating' return [{ type: 'adjudicate', attempt, draft: this.draft }] } - return [{ type: 'default-sink', draft: this.draft, mode }] + const attempt = this.beginAttempt(mode) + this.phase = 'submitting' + return [{ type: 'default-sink', attempt, draft: this.draft, mode }] } private onAdjudicated(attempt: SubmitAttempt, outcome: Extract['outcome']): InputEffect[] { @@ -499,11 +516,18 @@ export class InputMachine { } // 'handled' (source dealt internally), {insert} (no enter-time span // semantics), or a miss: all land plain; only the miss flows to the sink. + if (outcome === undefined) { + this.phase = 'submitting' + return [{ + type: 'default-sink', + attempt, + draft: attempt.draftSnapshot, + mode: attempt.mode, + }] + } this.inflight = undefined this.phase = 'plain' - return outcome === undefined - ? [{ type: 'default-sink', draft: attempt.draftSnapshot, mode: attempt.mode }] - : [] + return [] } private onAdjudicationFailed(attempt: SubmitAttempt, message: string): InputEffect[] { @@ -522,7 +546,13 @@ export class InputMachine { this.phase = 'plain' this.claim = undefined this.occurrences = [] - this.adopt('') + // Text appended after the sent snapshot during the Host round-trip + // survives the commit; edits interleaved with committed content cannot + // be separated from it, so only a pure suffix is retained. + const snapshot = flight.attempt.draftSnapshot + this.adopt(this.draft !== snapshot && this.draft.startsWith(snapshot) + ? this.draft.slice(snapshot.length) + : '') // Committed content is gone for good: undo must not resurrect a sent draft. this.log = [] this.redoStack = [] @@ -532,24 +562,24 @@ export class InputMachine { ? [{ type: 'notice', level: ev.outcome.kind === 'error' ? 'error' : 'info', text: ev.outcome.text }] : [] } - const text = ev.message ?? ev.outcome?.text ?? 'command failed' - // Drift guard: keep the enter-time draft (same claim) only while the - // live draft still equals it; user input typed during flight wins. + const text = ev.message ?? ev.outcome?.text + // Keep the same command claim only while the live draft still equals the + // enter-time draft; user input typed during flight wins. // Claimed re-entry additionally requires the watch to hold — an // enter-path snapshot may carry leading whitespace the token never had. if (this.draft === flight.attempt.draftSnapshot && this.claim !== undefined && this.draft.startsWith(this.claim.token)) { this.phase = 'claimed' - return [{ type: 'notice', level: 'error', text }] + return text === undefined ? [] : [{ type: 'notice', level: 'error', text }] } this.phase = 'plain' this.claim = undefined - return [{ type: 'notice', level: 'error', text }] + return text === undefined ? [] : [{ type: 'notice', level: 'error', text }] } - /** Ordinary send accepted: clear as a commit (no undo unit; sent content - * must not be resurrectable — same discipline as submit-settled success). */ + /** Cut undo state after an accepted image-only send. */ private onSendCommitted(): InputEffect[] { + if (this.phase !== 'plain') return [] this.claim = undefined this.occurrences = [] this.adopt('') diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index bb33e1a55a..db4d218d21 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -66,11 +66,14 @@ export const zh = { 'settings.enter.description': '仅在智能体运行时生效;Cmd/Ctrl+Enter 使用另一行为', 'settings.enter.queue': '排队发送', 'settings.enter.steer': '插话发送', - 'access.confirm.title': '确认启用 Full access?', - 'access.confirm.description': '启用 Full access 后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。', + 'access.preset.readOnly': '仅可查看', + 'access.preset.workspaceWrite': '可写入工作区', + 'access.preset.fullAccess': '完全权限', + 'access.confirm.title': '确认启用完全权限?', + 'access.confirm.description': '启用完全权限后,智能体将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。', 'access.confirm.acknowledge': '我已了解风险,并愿意继续', 'access.confirm.cancel': '取消', - 'access.confirm.enable': '启用 Full access', + 'access.confirm.enable': '启用完全权限', 'hero.headline': '探索未至之境', 'hero.preview': '预览版', 'hero.chooseWorkspace': '选择工作区', @@ -99,6 +102,8 @@ export const zh = { 'message.extraBlock': '附加内容块', 'message.contextInjection': '上下文注入', 'message.contextRecall': '跨会话召回', + 'message.referenceSummary': '引用会话 · {labels}', + 'message.referenceSeparator': '、', 'message.context.instructions.loaded': '已载入', 'message.context.instructions.added': '已新增', 'message.context.instructions.updated': '已更新', @@ -241,6 +246,9 @@ export const en = { 'settings.enter.description': 'Busy only; Cmd/Ctrl+Enter uses the other behavior', 'settings.enter.queue': 'Queue', 'settings.enter.steer': 'Steer', + 'access.preset.readOnly': 'Read Only', + 'access.preset.workspaceWrite': 'Workspace Write', + 'access.preset.fullAccess': 'Full access', 'access.confirm.title': 'Enable Full access?', 'access.confirm.description': 'Full access reduces confirmation steps and lets the agent perform more actions directly, including sensitive operations, file changes, or external commands. Only use it when you trust the current task.', 'access.confirm.acknowledge': 'I understand the risks and want to continue', @@ -274,6 +282,8 @@ export const en = { 'message.extraBlock': 'Extra content block', 'message.contextInjection': 'Context injection', 'message.contextRecall': 'Session recall', + 'message.referenceSummary': 'Referenced session · {labels}', + 'message.referenceSeparator': ', ', 'message.context.instructions.loaded': 'loaded', 'message.context.instructions.added': 'added', 'message.context.instructions.updated': 'updated', diff --git a/packages/client/ui-conversation/src/client/reference/ReferenceIcon.tsx b/packages/client/ui-conversation/src/client/reference/ReferenceIcon.tsx new file mode 100644 index 0000000000..fcc89fe30e --- /dev/null +++ b/packages/client/ui-conversation/src/client/reference/ReferenceIcon.tsx @@ -0,0 +1,35 @@ +import type { ReactNode } from 'react' +import { + IconBrowseOutline16, IconFolderClose16, +} from '@deepseek-ai/dsh-client-ui-primitives' + +/** Reference domains with distinct composer and transcript glyphs. */ +export type ReferenceIconKind = 'session' | 'file' | 'folder' + +/** Props shared by inline reference glyphs. */ +export interface ReferenceIconProps { + kind: ReferenceIconKind + size?: number + className?: string | undefined +} + +/** + * Render the icon that identifies one inline reference domain. + * @param props - Reference kind, optional size, and optional CSS class. + * @returns The corresponding current-color SVG glyph. + */ +export function ReferenceIcon({ kind, size = 16, className }: ReferenceIconProps): ReactNode { + switch (kind) { + case 'session': + return ( + + + + ) + case 'file': return + case 'folder': return + } +} diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 9a25e13dc0..a37816472c 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -13,7 +13,7 @@ import type { Context } from '@deepseek-ai/cordis' // error, so scope resolution goes through the sessions service (scopeOf // method) instead of the standalone helper. import type { ISessions, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client' -import type { SubmitImageAttachment } from '@deepseek-ai/dsh-client-ui-input-trigger/client' +import type { SubmitImageAttachment, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment' import type { ComposerAttachment } from './contract/slots.ts' import type { QueueAction, QueueItemId } from './contract/queue.ts' @@ -139,22 +139,26 @@ export class ConversationController extends Service implements IConversation { * @param text - serialized prompt text. * @param imageIds - ordered draft-local attachment ids. * @param mode - queue or steer delivery selected by composer policy. + * @param signal - optional cancellation for the complete Host admission. + * @returns the Host admission outcome; local attachment preparation failures reject. */ async sendSession( session: SessionFace, text: string, imageIds: readonly DraftAttachmentId[], mode: InputSubmitMode, - ): Promise { + signal?: AbortSignal, + ): Promise { const attachments = this.draftImages(imageIds) if (attachments.length !== imageIds.length) { throw new Error('conversation.sendSession: one or more draft images are no longer available') } const uploaded = await this.serializeImages(attachments.map(attachment => attachment.file)) const content = [...uploaded, ...(text === '' ? [] : [{ type: 'text' as const, text }])] - const result = await session.prompt(content, mode) - if (!result.ok) throw new Error(`conversation.send failed: ${result.error.code}: ${result.error.message}`) + const result = await session.prompt(content, mode, signal) + if (!result.ok) return { kind: 'error' } this.releaseDraftImages(attachments) + return { kind: 'success' } } /** diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index ef1221cd99..07655d7dba 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -159,7 +159,7 @@ export function ConversationRoot({ const composerBar = (
{hero && } - {hero && } + {hero && } {hero && heroWorkspaceRow} {zone !== undefined && renderSlot('conversation.input.dock', zone)} {inputBar} diff --git a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx index 4865ceecfa..9db09e3141 100644 --- a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx @@ -102,6 +102,8 @@ export function HeroGlow({ className }: { className?: string | undefined }) { export interface HeroShellProps { /** The owner's locale seat, passed down as a plain prop. */ t: HeroTranslate + /** Authorized renderer for the hero brand-mark slot. */ + renderSlot: ConversationSlotProps['renderSlot'] /** Overlay content after the stack (modals). */ children?: ReactNode } @@ -112,14 +114,16 @@ export interface HeroShellProps { * @param props - see {@link HeroShellProps}. * @returns the centered hero element tree. */ -export function HeroShell({ t, children }: HeroShellProps) { +export function HeroShell({ t, renderSlot, children }: HeroShellProps) { return (
{/* figma 34:10412: fish 34×25 leading the headline, gap 10. */} - + {renderSlot('conversation.hero.brand.mark', { size: 34, className: css.fish }, { + fallback: , + })} {t('hero.headline')} {t('hero.preview')} diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index de3a1739fc..e5b85df3e9 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -1,12 +1,3 @@ -/* One-glyph font: maps ONLY U+FFFC to a blank 4em-advance glyph (every other - codepoint falls through to the next family). Loaded first in the composer - font stack, it gives the placeholder a real cell width INSIDE the textarea, - so the backdrop chip (same char, same stack) matches it by construction — - the two layers cannot drift and the chip gets a usable label cell. */ -@font-face { - font-family: 'DshChipCell'; - src: url('data:font/ttf;base64,AAEAAAAKAIAAAwAgT1MvMkT8SmIAAAEoAAAAYGNtYXAADQBPAAABkAAAADRnbHlmAAAAAAAAAcwAAAABaGVhZCwtPGoAAACsAAAANmhoZWEDIg7bAAAA5AAAACRobXR4EZQAAAAAAYgAAAAIbG9jYQAAAAAAAAHEAAAABm1heHAAAwACAAABCAAAACBuYW1lvljk2gAAAdAAAABscG9zdNNweNQAAAI8AAAALQABAAAAAQAAdia1tV8PPPUAAwPoAAAAAOaLfcUAAAAA5ot9xQAAAAAAAAAAAAAAAwACAAAAAAAAAAEAAAMg/zgAAA+gAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAACAAEAAAACAAAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAwjKAZAABQAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAPz8/PwAA//z//AMg/zgAAAMgAMgAAAAAAAAAAAAAAAAAAAAgAAAB9AAAD6AAAAAAAAIAAAADAAAAFAADAAEAAAAUAAQAIAAAAAQABAABAAD//P//AAD//P//AAUAAQAAAAAAAAAAAAAAAAAAAAAAAAAEADYAAQAAAAAAAQALAAAAAQAAAAAAAgAHAAsAAwABBAkAAQAWABIAAwABBAkAAgAOAChEc2hDaGlwQ2VsbFJlZ3VsYXIARABzAGgAQwBoAGkAcABDAGUAbABsAFIAZQBnAHUAbABhAHIAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAABAgZvYmpyZXAAAAA=') format('truetype'); -} /* Floating capsule input (figma Input_Bottom 75:8208): card floats above the viewport bottom inside the centered message column; textarea on top, action @@ -145,9 +136,9 @@ position: relative; } -/* Decoration backdrop: same metrics as the textarea, transparent glyphs; only - the highlight backgrounds and the ghost hint show through the transparent - textarea background above it. */ +/* Decoration backdrop: same metrics as the transparent-text textarea. It owns + every visible glyph plus the range colors and ghost hint; the textarea above + retains the native selection and caret. */ .backdrop { position: absolute; inset: 0; @@ -156,6 +147,11 @@ pointer-events: none; } +.backdropDisabled, +.backdropDisabled :is(.hlToken, .hint, .textRef, .chip, .chipInvalid) { + color: var(--dsw-alias-label-tertiary); +} + .hlToken { background-color: transparent; color: var(--dsw-alias-state-warn-label); @@ -200,6 +196,7 @@ outline: none; background: transparent; color: transparent; + -webkit-text-fill-color: transparent; /* Business blue, not brand-primary: that token resolves to ink in this sheet. */ caret-color: var(--dsw-alias-state-business-primary); } @@ -214,10 +211,7 @@ /* figma .InputText 34:10434: pl 16 / pr 12 / pt 4. Backdrop MUST share these metrics or the highlight ranges drift off the glyphs. */ padding: 4px 12px 0 16px; - /* DshChipCell first: ONLY U+FFFC resolves there (4em blank cell — the chip - slot); everything else falls through to the app stack. All three layers - share the stack, so placeholder advances agree by construction. */ - font-family: 'DshChipCell', var(--dsw-font-family); + font-family: var(--dsw-font-family); font-size: inherit; /* Three consumers, not two: the mirror sizes the stack, the layers must break lines identically, and the caret reveal parses this value to step one line @@ -242,12 +236,15 @@ /* figma 34:10434: #ADB2B8 light / #81858C dark — the caption pair exactly. */ .input::placeholder { color: var(--dsw-alias-label-caption); + -webkit-text-fill-color: var(--dsw-alias-label-caption); user-select: none; } -/* Running lock: grayed but the draft stays visible; the turn ending re-enables. */ +/* The backdrop owns disabled draft color; the textarea remains caret-only so + its marker glyphs cannot cover the reference icons beneath it. */ .input:disabled { - color: var(--dsw-alias-label-tertiary); + color: transparent; + -webkit-text-fill-color: transparent; cursor: not-allowed; } @@ -427,43 +424,50 @@ display: none; } -/* Reference chip: rendered in the backdrop at the placeholder offset. Hard - alignment constraint: the chip's advance must equal the textarea's U+FFFC - advance EXACTLY or every glyph after it drifts (caret/selection follow the - textarea character stream). The ::before renders the same U+FFFC through - the same font stack (DshChipCell 4em cell), so both layers agree by - construction — no measured widths. The label overlays the cell, clipped - with an ellipsis; the full name rides the title tooltip. */ -.chip { +.textRefTrigger { position: relative; - border-radius: 6px; - background: rgba(97, 135, 216, 0.22); } -.chip::before { - content: '\FFFC'; +.textRefTriggerGlyph { color: transparent; } -.chipLabel { - /* Compensated-scale centering: overflow clipping happens BEFORE transform, - so the box is laid out at 1/0.72 of the cell and scaled back down — the - clip edge then lands on the visual cell edge, not mid-glyph. */ +.textRefIcon { position: absolute; - left: 50%; top: 50%; - width: calc(100% / 0.72 - 10px); - display: flex; - align-items: center; - justify-content: center; - overflow: hidden; - color: var(--dsw-alias-label-primary); - white-space: nowrap; - transform: translate(-50%, -50%) scale(0.72); + left: 50%; + transform: translate(-50%, -50%); +} + +/* Structured references use the same inline-backdrop technique as /skill: + their complete display text remains in the textarea, so wrapping and caret + geometry come from the browser's native glyph metrics. The leading marker + reserves the icon's advance while the backdrop paints the domain glyph. */ +.chip { + position: relative; + color: var(--dsw-alias-state-business-primary); + background: transparent; + -webkit-box-decoration-break: clone; + box-decoration-break: clone; +} + +.chipTrigger { + position: relative; +} + +.chipTriggerGlyph { + color: transparent; +} + +.chipIcon { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); } .chipInvalid { - background: rgba(216, 97, 97, 0.2); text-decoration: line-through; opacity: 0.7; + color: var(--dsw-alias-state-error-primary); } diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index bdb5c592cd..2d96e3bf33 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -25,6 +25,7 @@ import type { ComposerBarProps } from '../contract/slots.ts' import { deriveDecorations } from '../input/decorations.ts' import type { DraftDecorations } from '../input/decorations.ts' import { attachmentErrorText, imageSizeText } from '../image-labels.ts' +import { ReferenceIcon } from '../reference/ReferenceIcon.tsx' import { ContextMeter } from './ContextMeter.tsx' import { PermissionSelect } from './PermissionSelect.tsx' import { isSafariBrowser, repairSafariTextareaLayout } from './safari.ts' @@ -268,6 +269,14 @@ export function InputBar({ return () => { el.removeEventListener('wheel', onWheel) } }, []) + // selectionStart/End are number|null in lib.dom; the type-aware lint program narrows them. + /* oxlint-disable typescript/no-unnecessary-condition */ + const selectionOf = (el: HTMLTextAreaElement) => ({ + start: el.selectionStart ?? 0, + end: el.selectionEnd ?? el.selectionStart ?? 0, + }) + /* oxlint-enable typescript/no-unnecessary-condition */ + const onKeyDown = (e: KeyboardEvent): void => { if (workspaceTrigger) { if (e.key === 'Enter' || e.key === ' ') { @@ -278,13 +287,31 @@ export function InputBar({ } // Absent machine without a Workspace recovery action stays disabled; the // guard narrows the faces for the paths below. - if (keyboard === undefined || inputActions === undefined) return + if (input === undefined || keyboard === undefined || inputActions === undefined) return // Shift+Enter is the native newline UNCONDITIONALLY — decided before the // IME guard so a composition-closing Shift+Enter still breaks the line. if (e.key === 'Enter' && e.shiftKey) return // keyCode 229 is the legacy IME-composition signal engines emit without isComposing. // oxlint-disable-next-line typescript/no-deprecated const composing = composingRef.current || e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229 + if (!composing && !machineBusy && !locked + && (e.key === 'Backspace' || e.key === 'Delete')) { + const selection = selectionOf(e.currentTarget) + if (selection.start === selection.end) { + const occurrence = input.occurrences.find(o => e.key === 'Backspace' + ? o.offset + o.length === selection.start + : o.offset === selection.start) + if (occurrence !== undefined) { + e.preventDefault() + const start = occurrence.offset + const end = occurrence.offset + occurrence.length + keyboard.setDraft(draft.slice(0, start) + draft.slice(end), { start, end, insertedLength: 0 }) + restoreCaret(e.currentTarget, start) + keyboard.track(keyboard.snapshot.draft, start) + return + } + } + } if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { if (keyboard.arbitrate(e.key === 'ArrowUp' ? 'up' : 'down', composing) === 'consumed') e.preventDefault() return @@ -351,45 +378,32 @@ export function InputBar({ keyboard.track(next, e.target.selectionStart ?? next.length) } - // ---- chip atomicity (DOM layer; the machine sees only transactions) ---- - // Placeholders occupy exactly one char, so caret positions are always - // BETWEEN them — what needs normalizing is deletion (whole chip per - // Backspace/Delete via native single-char semantics, which U+FFFC already - // gives us) and selection endpoints: Shift-extension snapping is native - // too (one char = one step). Mouse selection of a chip is handled in the - // backdrop click handler below. Undo/redo must NOT reach the browser: the - // machine owns the transaction log. - // selectionStart/End are number|null in lib.dom; the type-aware lint program narrows them. - /* oxlint-disable typescript/no-unnecessary-condition */ - const selectionOf = (el: HTMLTextAreaElement) => ({ - start: el.selectionStart ?? 0, - end: el.selectionEnd ?? el.selectionStart ?? 0, - }) - /* oxlint-enable typescript/no-unnecessary-condition */ - const onCopyOrCut = (e: React.ClipboardEvent, cut: boolean): void => { if (input === undefined || keyboard === undefined) return // absent machine: no draft can be copied or cut const el = e.currentTarget const { start, end } = selectionOf(el) if (start === end) return - const slice = draft.slice(start, end) - const touched = input.occurrences.filter(o => o.offset >= start && o.offset < end) + const touched = input.occurrences.filter(o => o.offset < end && o.offset + o.length > start) if (touched.length === 0 && !cut) return // plain copy of plain text: native path is fine e.preventDefault() - // Expand placeholders to their owner clipboard projections. + const copyStart = touched.reduce((value, o) => Math.min(value, o.offset), start) + const copyEnd = touched.reduce((value, o) => Math.max(value, o.offset + o.length), end) + // Expand structured ranges to their owner clipboard projections. let text = '' - let cursor = start + let cursor = copyStart for (const o of touched) { text += draft.slice(cursor, o.offset) + o.clipboardText - cursor = o.offset + 1 + cursor = o.offset + o.length } - text += draft.slice(cursor, end) + text += draft.slice(cursor, copyEnd) e.clipboardData.setData('text/plain', text) if (cut && !machineBusy && !locked) { - keyboard.setDraft(draft.slice(0, start) + draft.slice(end), { start, end, insertedLength: 0 }) - restoreCaret(el, start) + keyboard.setDraft( + draft.slice(0, copyStart) + draft.slice(copyEnd), + { start: copyStart, end: copyEnd, insertedLength: 0 }, + ) + restoreCaret(el, copyStart) } - void slice } const onPaste = (e: React.ClipboardEvent): void => { @@ -496,16 +510,15 @@ export function InputBar({ ? null : - // Mirror-layer decorations: a visible backdrop with transparent text. The - // claim token highlights through behind the textarea glyphs; each U+FFFC - // placeholder renders as a chip (the textarea's own glyph is invisible, the - // backdrop chip supplies the visual); the claim hint is ghost text. + // Mirror-layer decorations: a visible backdrop with transparent textarea + // text. Claim tokens and references retain the draft's own glyph metrics, + // so their decoration cannot drift from wrapping, selection, or the caret. const deco = input === undefined ? INERT_DECORATIONS : deriveDecorations(input, lexicon) const backdrop: ReactNode[] = [] { - // Segment boundaries: the token range end, every chip offset, and every - // text-ref range — merged in draft order (the sources never - // overlap: chips sit on placeholders, text-refs on plain tokens, the + // Segment boundaries: the token range end, every structured-reference + // offset, and every text-ref range — merged in draft order (the sources never + // overlap: structured references own their ranges, text-refs own plain tokens, the // claim token only leads). let cursor = 0 const pushPlain = (upTo: number): void => { @@ -522,10 +535,10 @@ export function InputBar({ } type Boundary = | { at: number; kind: 'chip'; chip: (typeof deco.chips)[number] } - | { at: number; kind: 'text-ref'; ref: (typeof deco.textRefs)[number] } + | { at: number; kind: 'text-ref'; ref: (typeof deco.textRefs)[number]; ordinal: number } const boundaries: Boundary[] = [ ...deco.chips.map(chip => ({ at: chip.offset, kind: 'chip' as const, chip })), - ...deco.textRefs.map(ref => ({ at: ref.start, kind: 'text-ref' as const, ref })), + ...deco.textRefs.map((ref, ordinal) => ({ at: ref.start, kind: 'text-ref' as const, ref, ordinal })), ].sort((a, b) => a.at - b.at) for (const b of boundaries) { if (b.at < cursor) continue // claim-token overlap: the leading mark wins @@ -533,27 +546,49 @@ export function InputBar({ if (b.kind === 'chip') { const chip = b.chip backdrop.push( - // The cell's ::before renders U+FFFC itself so its advance equals the - // textarea's placeholder exactly (same char, same font); the label is - // a clipped overlay that never affects layout. - {chip.label} + {chip.appearance === undefined + ? chip.text[0] + : ( + + {chip.text[0]} + + + )} + {chip.text.slice(1)} , ) - cursor = chip.offset + 1 // the placeholder char the chip stands for + cursor = chip.offset + chip.length } else { // Plain-range highlight: the glyphs stay the // textarea's (advance untouched); the mark paints the chip look. + // The key is the draft-order ordinal: a fresh scan derives these + // ranges every render, so none of them carries identity past its + // position, and a draft-offset key would unmount the mark and its + // icon for every character typed ahead of it. Structured references + // key by occurrenceId, the identity their occurrence table owns. + const text = draft.slice(b.ref.start, b.ref.end) backdrop.push( - - {draft.slice(b.ref.start, b.ref.end)} + + {b.ref.appearance === 'folder' + ? ( + <> + + {text[0]} + + + {text.slice(1)} + + ) + : text} , ) cursor = b.ref.end @@ -621,7 +656,14 @@ export function InputBar({ which a compositor-driven gesture outruns and leaves the words trailing the caret. */}
-
{backdrop}
+
+ {backdrop} +