Merge remote-tracking branch 'origin/master' into fix/landlock-runner-failure-classification
This commit is contained in:
@@ -51,7 +51,7 @@ Non-negotiables across the layers:
|
||||
|
||||
- **Business data lives in the object layer, never a store.** Entry-declared stores carry shared viewing/interaction state (selection, drafts, panel widths); sessions, frames, and connections stay in the object layer.
|
||||
- **rpcId is strictly bidirectional**: the initiator mints, the responder echoes; business signatures see only `RpcRequest<P>`, minting stays in the carrier layer ([layering and RPC protocol note](../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)).
|
||||
- **Notifier dual-channel discipline**: `notifyNow` only as the direct echo of a user gesture; frame-driven updates always go through `markDirty` (microtask-batched). See `runtime/src/client/sessions/notifier.ts`.
|
||||
- **Notifier publication discipline**: `notifyNow` is only the direct echo of a user gesture; structural updates use microtask-batched `markDirty`, while visible streaming chunks use cumulative `markFrameDirty`. See `runtime/src/client/sessions/notifier.ts`.
|
||||
- **The web layer is pure presentation.** Nothing that is "how to draw" (tool-card views, queue states) enters the session log; the host computes such data per frame or pushes it live, and replay recomputes it — falling back to the generic form when it can't. A new *model-visible* input still requires a session event (repo-wide rule).
|
||||
|
||||
## Directory regime (plugin packages)
|
||||
|
||||
@@ -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/README.md
|
||||
README.md: b111d67fa49e06227e324a33bd53417ad28c3a5b
|
||||
README.zh.md: b498008eb82f6ab357718f2af761f38e51140ef8
|
||||
README.md: 31d884c04a8b0233713b77b82b3d9cc7052003ca
|
||||
README.zh.md: 9c95db529306519136d6d68758889350e5dd65e4
|
||||
|
||||
@@ -11,7 +11,7 @@ The browser side of the dsh web GUI: shell kernel, module system, wire consumer,
|
||||
| `web-react/` | Shell-side React glue: `createSlotRenderer` + `SessionProvider` render seats | (renderer install) |
|
||||
| `connection/` | Wire consumer both ends: browser `ctx.connection` (shared api client + stream loop) and the node half mounting the `/api` route with its browser-trust fence | `ctx.connection` |
|
||||
| `runtime/` | Client cordis boot and React-free object services: slots, Sessions, Workspaces, per-session bindings | `ctx.slots` `ctx.sessions` `ctx.workspaces` |
|
||||
| `hmr/` | Dev-only hot reload for fetch-arrival client plugins (`--dev` graphs) | (dev entry) |
|
||||
| `hmr/` | Dev-only hot reload for script-loaded client plugins (`--dev` graphs) | (dev entry) |
|
||||
| `locale/` | Browser locale preference (`zh`/`en`) plus the ns×locale dictionary registry | `ctx.locale` |
|
||||
| `ui-slots/` | Slot registry pure core: SlotMap merging, single `register` API, the four-share props family | (types + core) |
|
||||
| `ui-theme/` | Theme preference over the `--dsw-*` token stylesheets (`light`/`dark`/`system`) | `ctx.theme` |
|
||||
|
||||
@@ -11,7 +11,7 @@ dsh web GUI 的浏览器侧:shell 内核、模块系统、协议消费层、
|
||||
| `web-react/` | shell 侧 React 胶水:`createSlotRenderer` + `SessionProvider` 渲染座位 | (渲染器安装) |
|
||||
| `connection/` | 协议两端的消费者:浏览器侧 `ctx.connection`(共享 api 客户端 + 流循环),node 半侧挂载带浏览器信任栅栏的 `/api` 路由 | `ctx.connection` |
|
||||
| `runtime/` | 客户端 cordis 启动与无 React 对象服务:slots、Session、Workspace、逐会话绑定 | `ctx.slots` `ctx.sessions` `ctx.workspaces` |
|
||||
| `hmr/` | 仅开发用的 fetch 到达型客户端插件热重载(`--dev` 图) | (开发条目) |
|
||||
| `hmr/` | 仅开发用的外部脚本加载型客户端插件热重载(`--dev` 图) | (开发条目) |
|
||||
| `locale/` | 浏览器语言偏好(`zh`/`en`)与 ns×locale 词典注册表 | `ctx.locale` |
|
||||
| `ui-slots/` | slot 注册表纯核心:SlotMap 合并、单一 `register` API、四份额 props 族 | (类型 + 核心) |
|
||||
| `ui-theme/` | 基于 `--dsw-*` token 样式表的主题偏好(`light`/`dark`/`system`) | `ctx.theme` |
|
||||
|
||||
@@ -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/connection/README.md
|
||||
README.md: f537fee3273e3b5d2411197cf1a1a6e0d34af5f9
|
||||
README.zh.md: a29d2c00e7df3f6290a03ffdad59b70b43702aca
|
||||
README.md: faf093964a740092983e13bf88f2cccd853c3e36
|
||||
README.zh.md: b06ab245dedbde13957aa416be044ef107b2753c
|
||||
|
||||
@@ -2,11 +2,15 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. Loopback hostname classification stays package-internal: the `/api` Host fence uses it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3.
|
||||
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The real browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the fixture and in-process carriers continue to satisfy the same two-stream abstraction. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md); the protocol contract is api-contracts v3 §3.
|
||||
|
||||
## /api browser-trust fence
|
||||
|
||||
The node half guards every request under `/api` before bridging (`src/api-request-trust.ts`). Every request — browser-marked or not — must present a `Host` that is a loopback authority or matches a `trustedHosts` entry: exact on `host:port` entries, any port on port-less entries, both sides compared through WHATWG normalization (DNS-rebinding defense). There is deliberately no shortcut for requests without browser markers: over plain HTTP a browser attaches neither `Origin` nor Fetch-Metadata to reads (EventSource, images, navigations — those headers go only to trustworthy destinations), so an unmarked request may still be a rebound browser read with a readable response, and Host is the one header rebinding cannot forge; non-browser clients pass the same fence via loopback, the CLI-derived LAN IP literals, or a declared authority. When markers are present, an attached `Origin` must equal the Host authority, and an explicit `sec-fetch-site: cross-site` marker is refused. A `trustedHosts` entry that is not a bare, canonical `host[:port]` authority — one WHATWG parsing reads back exactly as written — fails the plugin load loudly: parsing would otherwise quietly authorize the hostname inside `harness.internal/path`, or broaden a dangling-colon or zero-padded port to an any-port grant. Failures answer plain 403 before any RPC dispatch. A non-loopback (`--host 0.0.0.0`) deployment therefore needs its serving authorities trusted: the dsh CLI derives the machine's LAN IP literals itself and its `--trusted-host` flag declares named ones, so `trustedHosts` in cordis.yml is for compositions the CLI does not boot. The fence is deliberately not an authentication layer — reachability policy stays with the webserver binding, and auth remains deferred work. Decision record: [the api browser-trust boundary Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md).
|
||||
The node half guards every entry under `/api` before bridging or upgrading (`src/api-request-trust.ts`). Every request — browser-marked or not — must present a `Host` that is a loopback authority or matches a `trustedHosts` entry: exact on `host:port` entries, any port on port-less entries, both sides compared through WHATWG normalization (DNS-rebinding defense). There is deliberately no shortcut for unmarked HTTP requests: over plain HTTP a browser attaches neither `Origin` nor Fetch-Metadata to image and navigation reads, so an unmarked request may still be a rebound browser read with a readable response, and Host is the one header rebinding cannot forge; a browser WebSocket handshake carries `Origin` and passes the same comparison. Non-browser clients pass the same fence via loopback, the CLI-derived LAN IP literals, or a declared authority. When markers are present, an attached `Origin` must equal the Host authority, and an explicit `sec-fetch-site: cross-site` marker is refused. A `trustedHosts` entry that is not a bare, canonical `host[:port]` authority — one WHATWG parsing reads back exactly as written — fails the plugin load loudly: parsing would otherwise quietly authorize the hostname inside `harness.internal/path`, or broaden a dangling-colon or zero-padded port to an any-port grant. HTTP failures answer plain 403 before any RPC dispatch; upgrade failures reject the handshake before any event stream starts. A non-loopback (`--host 0.0.0.0`) deployment therefore needs its serving authorities trusted: the dsh CLI derives the machine's LAN IP literals itself and its `--trusted-host` flag declares named ones, so `trustedHosts` in cordis.yml is for compositions the CLI does not boot. The fence is deliberately not an authentication layer — reachability policy stays with the webserver binding, and auth remains deferred work. Decision record: [the api browser-trust boundary Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md).
|
||||
|
||||
## `/api` WebSocket downlinks
|
||||
|
||||
`/api/events.mux` and `/api/events.host` each accept a WebSocket upgrade and send only the corresponding `ServerRequest` text messages to the browser; the client sends no application data over these sockets. If either socket ends, the current connection generation fails and rebuilds both streams; readiness still requires both sockets to be open and the `host.describe` HTTP call to succeed. Host teardown terminates both sockets, aborts their sources, and waits for source cleanup before returning. Ordinary network GETs to these paths return 426 with no SSE fallback; `toFetchHandler`'s SSE codec serves only the isomorphic in-process carrier.
|
||||
|
||||
## Keyless fixture
|
||||
|
||||
|
||||
@@ -2,11 +2,15 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。
|
||||
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。真实浏览器载体以 HTTP POST 发送 unary/respond,并为 `events.mux` 与 `events.host` 各开一条只下行的 WebSocket;fixture 与进程内载体继续满足同一双流抽象。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md);协议契约见 api-contracts v3 §3。
|
||||
|
||||
## /api 浏览器信任栅栏
|
||||
|
||||
node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust.ts`)。每个请求——无论是否带浏览器标记——`Host` 都必须是回环地址权威,或与某个 `trustedHosts` 条目匹配:带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较(DNS rebinding 防御)。刻意不为无浏览器标记的请求开捷径:明文 HTTP 下浏览器的读取(EventSource、图片、导航——这些头只发给可信目标)既不带 `Origin` 也不带 Fetch-Metadata,因此无标记请求仍可能是被重绑页面发起的、响应可被读走的读取,而 Host 是重绑唯一伪造不了的请求头;非浏览器客户端经由回环地址、CLI 推导的 LAN IP 字面量或已声明的权威通过同一道栅栏。当标记存在时,`Origin` 必须与 Host 权威完全一致;显式的 `sec-fetch-site: cross-site` 标记一律拒绝。不是纯的、规范形 `host[:port]` 权威的 `trustedHosts` 条目——即 WHATWG 解析读回后与原文不完全一致的——会让插件加载大声失败:否则解析会悄悄授权 `harness.internal/path` 这类笔误里的 hostname,或把悬空冒号、补零端口放大成任意端口授权。失败在任何 RPC 分发之前以纯 403 应答。因此非回环(`--host 0.0.0.0`)部署需要让自己的服务权威被信任:dsh CLI 会自行推导本机的 LAN IP 字面量,其 `--trusted-host` flag 用于声明具名权威,所以 cordis.yml 中的 `trustedHosts` 面向 CLI 不参与引导的组合。这道栅栏刻意不承担认证职责——可达性策略归 webserver 绑定配置,认证仍是延期工作。决策记录:[api 浏览器信任边界 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md)。
|
||||
node 半侧在桥接或 upgrade 前守卫 `/api` 下的每个入口(`src/api-request-trust.ts`)。每个请求——无论是否带浏览器标记——`Host` 都必须是回环地址权威,或与某个 `trustedHosts` 条目匹配:带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较(DNS rebinding 防御)。刻意不为无浏览器标记的 HTTP 请求开捷径:明文 HTTP 下浏览器的图片与导航读取既不带 `Origin` 也不带 Fetch-Metadata,因此无标记请求仍可能是被重绑页面发起的、响应可被读走的读取,而 Host 是重绑唯一伪造不了的请求头;WebSocket 浏览器握手会带 `Origin` 并通过同一道比较。非浏览器客户端经由回环地址、CLI 推导的 LAN IP 字面量或已声明的权威通过同一道栅栏。当标记存在时,`Origin` 必须与 Host 权威完全一致;显式的 `sec-fetch-site: cross-site` 标记一律拒绝。不是纯的、规范形 `host[:port]` 权威的 `trustedHosts` 条目——即 WHATWG 解析读回后与原文不完全一致的——会让插件加载大声失败:否则解析会悄悄授权 `harness.internal/path` 这类笔误里的 hostname,或把悬空冒号、补零端口放大成任意端口授权。HTTP 失败在任何 RPC 分发之前以纯 403 应答,upgrade 失败在启动任何 event stream 前拒绝握手。因此非回环(`--host 0.0.0.0`)部署需要让自己的服务权威被信任:dsh CLI 会自行推导本机的 LAN IP 字面量,其 `--trusted-host` flag 用于声明具名权威,所以 cordis.yml 中的 `trustedHosts` 面向 CLI 不参与引导的组合。这道栅栏刻意不承担认证职责——可达性策略归 webserver 绑定配置,认证仍是延期工作。决策记录:[api 浏览器信任边界 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md)。
|
||||
|
||||
## `/api` WebSocket 下行
|
||||
|
||||
`/api/events.mux` 与 `/api/events.host` 各接受一条 WebSocket upgrade,并只向浏览器发送对应的 `ServerRequest` text message;客户端不会在这些 socket 上发送业务数据。任一 socket 结束都会使当前 connection generation 失败并重建两条流,连接就绪仍要求两条 socket open 且 `host.describe` HTTP 调用成功。Host teardown 会终止两条 socket、中止各自的 source,并等待 source 清理完成后再返回。普通网络 GET 这些路径会返回 426,不保留 SSE 回退;`toFetchHandler` 的 SSE 编解码只服务进程内同构载体。
|
||||
|
||||
## 无密钥 fixture
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-connection",
|
||||
"description": "Wire consumer layer: IApiClient subclasses, ConnectionController (SSE dual-stream + reconnect), fixture api (no cordis)",
|
||||
"description": "Wire consumer layer: HTTP-up/WebSocket-down client, ConnectionController dual streams with reconnect, and fixture api",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -34,7 +34,8 @@
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"schemastery": "^3.18.0"
|
||||
"schemastery": "^3.18.0",
|
||||
"ws": "^8.21.0"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
@@ -52,6 +53,7 @@
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/ws": "^8.18.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
/**
|
||||
* The /api URL prefix — single source for both halves of the web transport.
|
||||
* The node half registers this prefix on the web server; browser-side path
|
||||
* literals currently live in the apiproxy client layer (out of scope here).
|
||||
* The node half registers this prefix on the web server; both halves share the
|
||||
* event paths below for the browser WebSocket downlinks.
|
||||
*/
|
||||
|
||||
/** Route prefix owning every api request (`/api` and `/api/<anything>`). */
|
||||
export const API_PATH = '/api'
|
||||
|
||||
/** Browser mux-frame WebSocket pathname. */
|
||||
export const MUX_EVENTS_PATH = `${API_PATH}/events.mux`
|
||||
|
||||
/** Browser host-frame WebSocket pathname. */
|
||||
export const HOST_EVENTS_PATH = `${API_PATH}/events.host`
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* the attacker's domain while the socket reaches this server) and cross-site
|
||||
* requests fired from a malicious page. The Host fence binds every request,
|
||||
* browser-looking or not: over plain HTTP a browser attaches neither Origin
|
||||
* nor Fetch-Metadata to reads (EventSource, images, navigations — those
|
||||
* nor Fetch-Metadata to reads (images and navigations — those
|
||||
* headers go only to trustworthy destinations), so an unmarked request may
|
||||
* still be a rebound browser read and Host is the one header rebinding cannot
|
||||
* forge. Non-browser and remote clients pass the same fence via loopback, the
|
||||
@@ -97,7 +97,7 @@ export function isTrustedApiRequest(request: ApiTrustRequest, trustedHosts: read
|
||||
// fills Host from the URL it believes it is talking to, so a rebound page
|
||||
// carries the attacker's domain here even though the socket lands on this
|
||||
// server. There is no marker shortcut — a browser read over plain HTTP
|
||||
// (EventSource, images, navigations) arrives with neither Origin nor
|
||||
// (images and navigations) arrives with neither Origin nor
|
||||
// Fetch-Metadata, indistinguishable from curl, and its response is readable
|
||||
// by the rebound page.
|
||||
const host = header(request.headers, 'host')
|
||||
|
||||
@@ -126,7 +126,7 @@ export class ConnectionController {
|
||||
|
||||
try {
|
||||
// Strict readiness handshake (audit C2): describe proves unary reachability, onOpen
|
||||
// proves each SSE transport is established (response headers in, before any frame) —
|
||||
// proves each physical stream is established before any frame —
|
||||
// only then may onConnected fire, so the resync it triggers cannot outrun the
|
||||
// subscribed baseline. The timeout guards against a carrier that never fires onOpen
|
||||
// (see ConnectionConfig.streamOpenTimeoutMs).
|
||||
|
||||
@@ -1179,6 +1179,16 @@ interface StreamConn<F> {
|
||||
push(envelope: RpcRequest<F>): void
|
||||
}
|
||||
|
||||
interface ReasoningChunkStormState {
|
||||
sessionId: string
|
||||
chunkCount: number
|
||||
chunksPerInterval: number
|
||||
intervalMs: number
|
||||
emitted: number
|
||||
marker: string
|
||||
emitting: boolean
|
||||
}
|
||||
|
||||
/** Deterministic fixture branches used by keyless Web assembly tests. */
|
||||
export interface FixtureOptions {
|
||||
/** Start with no real Workspace or Session. */
|
||||
@@ -1461,6 +1471,8 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
const streamBreakers = new Set<() => void>()
|
||||
/** Retry scenarios opened by timing hooks and completed in a later browser assertion phase. */
|
||||
const retryScenarios = new Map<SessionId, { turn: number; stepStarted: boolean }>()
|
||||
/** The single opt-in browser stress producer; normal fixture journeys never start it. */
|
||||
let activeReasoningChunkStorm: ReasoningChunkStormState | null = null
|
||||
|
||||
// Timing-acceptance hooks (browser test backdoor): the in-memory fixture is ideally timed, which
|
||||
// is exactly what masked the open-window and reconnect-gap bugs (audit S1/S3). These let
|
||||
@@ -1484,6 +1496,86 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
const messageSeqs = log.filter(event => event.type === 'user/message').map(event => event.seq)
|
||||
append(sid(id), { type: 'session/title', data: { title, messageSeqs, source: { kind: 'provider', provider: 'fixture' } } })
|
||||
},
|
||||
/** Start an externally paced reasoning stream for the opt-in browser stress lane. */
|
||||
startReasoningChunkStorm(
|
||||
id: string,
|
||||
chunkCount: number,
|
||||
chunksPerInterval: number,
|
||||
intervalMs: number,
|
||||
): string {
|
||||
if (!Number.isSafeInteger(chunkCount) || chunkCount < 1) {
|
||||
throw new Error('fixture: reasoning chunk count must be a positive safe integer')
|
||||
}
|
||||
if (!Number.isSafeInteger(chunksPerInterval) || chunksPerInterval < 1) {
|
||||
throw new Error('fixture: reasoning chunks per interval must be a positive safe integer')
|
||||
}
|
||||
if (!Number.isSafeInteger(intervalMs) || intervalMs < 1) {
|
||||
throw new Error('fixture: reasoning interval must be a positive safe integer')
|
||||
}
|
||||
if (activeReasoningChunkStorm?.emitting === true) {
|
||||
throw new Error('fixture: reasoning chunk storm already running')
|
||||
}
|
||||
|
||||
const sessionId = sid(id)
|
||||
const log = logOf(sessionId)
|
||||
let turn = nextTurn.get(sessionId) ?? 0
|
||||
for (const event of log) {
|
||||
const candidate = (event as unknown as { data?: { turn?: unknown } }).data?.turn
|
||||
if (typeof candidate === 'number') turn = Math.max(turn, candidate + 1)
|
||||
}
|
||||
nextTurn.set(sessionId, turn + 1)
|
||||
const marker = `REASONING_STRESS_COMPLETE:${String(turn)}:${String(chunkCount)}`
|
||||
const state: ReasoningChunkStormState = {
|
||||
sessionId: id,
|
||||
chunkCount,
|
||||
chunksPerInterval,
|
||||
intervalMs,
|
||||
emitted: 0,
|
||||
marker,
|
||||
emitting: true,
|
||||
}
|
||||
activeReasoningChunkStorm = state
|
||||
|
||||
setRunning(sessionId, true)
|
||||
append(sessionId, { type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
|
||||
append(sessionId, {
|
||||
type: 'user/message', surfaceOp: 'append',
|
||||
data: userMessage(text(`Reasoning chunk stress: ${String(chunkCount)} chunks.`)),
|
||||
})
|
||||
append(sessionId, { type: 'step/start', data: { turn, step: 0 } })
|
||||
append(sessionId, {
|
||||
type: 'assistant/chunk',
|
||||
data: { turn, step: 0, chunk: { type: 'block-start', index: 0, blockType: 'reasoning' } },
|
||||
})
|
||||
|
||||
const startedAt = Date.now()
|
||||
const pump = (): void => {
|
||||
const elapsedIntervals = Math.floor((Date.now() - startedAt) / intervalMs) + 1
|
||||
const due = Math.max(state.emitted + chunksPerInterval, elapsedIntervals * chunksPerInterval)
|
||||
const end = Math.min(due, chunkCount)
|
||||
for (let index = state.emitted; index < end; index++) {
|
||||
const chunkText = index === chunkCount - 1
|
||||
? `\n${marker}`
|
||||
: index % 64 === 63 ? '推理\n' : '推理'
|
||||
append(sessionId, {
|
||||
type: 'assistant/chunk',
|
||||
data: { turn, step: 0, chunk: { type: 'reasoning-delta', index: 0, text: chunkText } },
|
||||
})
|
||||
}
|
||||
state.emitted = end
|
||||
if (end < chunkCount) {
|
||||
setTimeout(pump, intervalMs)
|
||||
} else {
|
||||
state.emitting = false
|
||||
}
|
||||
}
|
||||
setTimeout(pump, 0)
|
||||
return marker
|
||||
},
|
||||
/** Return a copy so browser probes cannot mutate the active producer. */
|
||||
reasoningChunkStormState(): ReasoningChunkStormState | null {
|
||||
return activeReasoningChunkStorm === null ? null : { ...activeReasoningChunkStorm }
|
||||
},
|
||||
/** Open one failed model step whose partial remains visible until llm/retry arrives. */
|
||||
beginModelRetry(id: string): void {
|
||||
const sessionId = sid(id)
|
||||
|
||||
@@ -1,12 +1,91 @@
|
||||
// WebApiClient: the browser platform subclass — transport = global fetch over same-origin
|
||||
// /api/* (base resolution handled by AbstractApiClient). Envelope observation comes from the
|
||||
// base batching aspect; subscribers attach via subscribeEnvelopes (see boot).
|
||||
/** Browser API carrier: HTTP upstream plus one WebSocket per downstream event stream. */
|
||||
|
||||
import type { ApiProxy, HostFrame, MuxFrame, RpcRequest, ServerRequest } from './api.ts'
|
||||
import { AbstractApiClient } from './api.ts'
|
||||
import { hostFrameSchema, muxFrameSchema } from '@deepseek-ai/dsh-host-apiproxy/api/events.schema'
|
||||
import { serverRequestSchema } from '@deepseek-ai/dsh-host-apiproxy/api/rpc.schema'
|
||||
import { HOST_EVENTS_PATH, MUX_EVENTS_PATH } from '../api-path.ts'
|
||||
|
||||
/** Browser platform subclass: transport = global fetch over same-origin /api/*. */
|
||||
type SocketItem<F> = { kind: 'frame'; envelope: RpcRequest<F> } | { kind: 'end' }
|
||||
type Parser<F> = { parse(value: unknown): F }
|
||||
|
||||
/** Browser platform subclass: unary/respond use fetch; mux/host use downlink-only WebSockets. */
|
||||
export class WebApiClient extends AbstractApiClient {
|
||||
protected doFetch(input: URL, init?: RequestInit): Promise<Response> {
|
||||
return globalThis.fetch(input, init)
|
||||
}
|
||||
|
||||
protected override openMux(
|
||||
_payload: Parameters<ApiProxy['events']['mux']>[0]['payload'],
|
||||
signal: AbortSignal,
|
||||
onOpen?: () => void,
|
||||
): AsyncIterable<RpcRequest<MuxFrame>> {
|
||||
return this.readWebSocket(MUX_EVENTS_PATH, signal, muxFrameSchema, onOpen)
|
||||
}
|
||||
|
||||
protected override openHost(
|
||||
_payload: Parameters<ApiProxy['events']['host']>[0]['payload'],
|
||||
signal: AbortSignal,
|
||||
onOpen?: () => void,
|
||||
): AsyncIterable<RpcRequest<HostFrame>> {
|
||||
return this.readWebSocket(HOST_EVENTS_PATH, signal, hostFrameSchema, onOpen)
|
||||
}
|
||||
|
||||
private async *readWebSocket<F extends MuxFrame | HostFrame>(
|
||||
path: string,
|
||||
signal: AbortSignal,
|
||||
frameSchema: Parser<F>,
|
||||
onOpen?: () => void,
|
||||
): AsyncGenerator<RpcRequest<F>> {
|
||||
const url = new URL(path, this.resolveBase())
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const socket = new WebSocket(url)
|
||||
const inbox: SocketItem<F>[] = []
|
||||
let wake: (() => void) | undefined
|
||||
const enqueue = (item: SocketItem<F>): void => {
|
||||
inbox.push(item)
|
||||
wake?.()
|
||||
wake = undefined
|
||||
}
|
||||
const handleOpen = (): void => { onOpen?.() }
|
||||
const handleMessage = (event: MessageEvent): void => {
|
||||
let full: ServerRequest
|
||||
let frame: F
|
||||
try {
|
||||
if (typeof event.data !== 'string') throw new Error('binary WebSocket frame')
|
||||
full = serverRequestSchema.parse(JSON.parse(event.data))
|
||||
frame = frameSchema.parse(full.payload)
|
||||
} catch (error) {
|
||||
console.error(`[client-connection] dropping malformed WebSocket frame on ${path}:`, error)
|
||||
return
|
||||
}
|
||||
this.onEnvelope(full)
|
||||
enqueue({ kind: 'frame', envelope: { rpcId: full.rpcId, payload: frame } })
|
||||
}
|
||||
const handleClose = (): void => { enqueue({ kind: 'end' }) }
|
||||
const handleAbort = (): void => {
|
||||
if (socket.readyState === WebSocket.CONNECTING || socket.readyState === WebSocket.OPEN) socket.close()
|
||||
}
|
||||
socket.addEventListener('open', handleOpen)
|
||||
socket.addEventListener('message', handleMessage)
|
||||
socket.addEventListener('close', handleClose, { once: true })
|
||||
signal.addEventListener('abort', handleAbort, { once: true })
|
||||
if (signal.aborted) handleAbort()
|
||||
try {
|
||||
while (true) {
|
||||
while (inbox.length > 0) {
|
||||
const item = inbox.shift() as SocketItem<F>
|
||||
if (item.kind === 'end') return
|
||||
yield item.envelope
|
||||
}
|
||||
await new Promise<void>((resolve) => { wake = resolve })
|
||||
}
|
||||
} finally {
|
||||
signal.removeEventListener('abort', handleAbort)
|
||||
socket.removeEventListener('open', handleOpen)
|
||||
socket.removeEventListener('message', handleMessage)
|
||||
socket.removeEventListener('close', handleClose)
|
||||
handleAbort()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
// Activates the httpServer Context merge used below.
|
||||
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import type { WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
import { API_PATH } from './api-path.ts'
|
||||
import { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts'
|
||||
import { bridge } from './http-bridge.ts'
|
||||
import { assertTrustedAuthority, isTrustedApiRequest } from './api-request-trust.ts'
|
||||
import { rejectWebSocketUpgrade, WebSocketDownlinks } from './websocket-downlink.ts'
|
||||
|
||||
export { API_PATH } from './api-path.ts'
|
||||
export { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts'
|
||||
|
||||
/** Stable Cordis plugin name. */
|
||||
export const name = 'client-connection'
|
||||
@@ -76,6 +77,7 @@ export function apply(ctx: Context, config?: ConnectionConfig): void {
|
||||
// silently authorizing its hostname prefix at request time.
|
||||
for (const entry of trustedHosts) assertTrustedAuthority(entry)
|
||||
const apiHandler = toFetchHandler(ctx.apiProxy)
|
||||
const downlinks = new WebSocketDownlinks(ctx.apiProxy)
|
||||
const route: WebRoute = {
|
||||
kind: 'prefix',
|
||||
path: API_PATH,
|
||||
@@ -92,8 +94,31 @@ export function apply(ctx: Context, config?: ConnectionConfig): void {
|
||||
res.end('forbidden')
|
||||
return
|
||||
}
|
||||
if (req.method === 'GET' && (pathname === MUX_EVENTS_PATH || pathname === HOST_EVENTS_PATH)) {
|
||||
res.writeHead(426, { connection: 'Upgrade', upgrade: 'websocket' })
|
||||
res.end('upgrade required')
|
||||
return
|
||||
}
|
||||
await bridge(req, res, apiHandler)
|
||||
},
|
||||
}
|
||||
ctx.effect(() => ctx.httpServer.register(route), 'client-connection: /api route')
|
||||
const registerDownlink = (
|
||||
path: string,
|
||||
handle: WebUpgradeRoute['handler'],
|
||||
): void => {
|
||||
ctx.effect(() => ctx.httpServer.registerUpgrade({
|
||||
path,
|
||||
handler: (req, socket, head) => {
|
||||
if (!isTrustedApiRequest(req, trustedHosts)) {
|
||||
rejectWebSocketUpgrade(socket)
|
||||
return
|
||||
}
|
||||
return handle(req, socket, head)
|
||||
},
|
||||
}), `client-connection: ${path} WebSocket`)
|
||||
}
|
||||
ctx.effect(() => () => downlinks.close(), 'client-connection: WebSocket downlinks')
|
||||
registerDownlink(MUX_EVENTS_PATH, (req, socket, head) => { downlinks.handleMux(req, socket, head) })
|
||||
registerDownlink(HOST_EVENTS_PATH, (req, socket, head) => { downlinks.handleHost(req, socket, head) })
|
||||
}
|
||||
|
||||
153
packages/client/connection/src/websocket-downlink.ts
Normal file
153
packages/client/connection/src/websocket-downlink.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
/** Host-side WebSocket carrier for the two server-to-browser event streams. */
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { IncomingMessage } from 'node:http'
|
||||
import type { Duplex } from 'node:stream'
|
||||
import WebSocket, { WebSocketServer } from 'ws'
|
||||
import type {
|
||||
ApiProxy, HostFrame, MuxFrame, RpcRequest, ServerRequest,
|
||||
} from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
|
||||
type Frame = MuxFrame | HostFrame
|
||||
|
||||
function serverRequest(frame: RpcRequest<Frame>): ServerRequest {
|
||||
return {
|
||||
type: 'server-request',
|
||||
rpcId: frame.rpcId,
|
||||
method: frame.payload.type,
|
||||
payload: frame.payload,
|
||||
}
|
||||
}
|
||||
|
||||
function send(socket: WebSocket, frame: RpcRequest<Frame>): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (socket.readyState !== WebSocket.OPEN) {
|
||||
reject(new Error('websocket downlink closed before frame delivery'))
|
||||
return
|
||||
}
|
||||
socket.send(JSON.stringify(serverRequest(frame)), (error) => {
|
||||
if (error) reject(error)
|
||||
else resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function failureFrame(error: unknown): RpcRequest<Frame> {
|
||||
return {
|
||||
rpcId: RpcId(randomUUID()),
|
||||
payload: {
|
||||
type: 'stream/error',
|
||||
error: { code: 'internal', message: String(error), details: {} },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns WebSocket negotiation and frame pumping for the connection plugin's
|
||||
* two downlinks. Client messages are a protocol violation: upstream traffic
|
||||
* remains on HTTP.
|
||||
*/
|
||||
export class WebSocketDownlinks {
|
||||
private readonly server = new WebSocketServer({ noServer: true })
|
||||
private readonly pumps = new Set<Promise<void>>()
|
||||
|
||||
/** @param api - host API supplying the typed event streams. */
|
||||
constructor(private readonly api: ApiProxy) {}
|
||||
|
||||
/**
|
||||
* Upgrade one socket and pump the mux stream until either side closes.
|
||||
* @param req - HTTP upgrade request.
|
||||
* @param socket - Raw socket transferred by the HTTP server.
|
||||
* @param head - Bytes already read after the upgrade headers.
|
||||
*/
|
||||
handleMux(req: IncomingMessage, socket: Duplex, head: Buffer): void {
|
||||
this.upgrade(req, socket, head, signal => this.api.events.mux({
|
||||
rpcId: RpcId(randomUUID()),
|
||||
payload: {},
|
||||
}, signal))
|
||||
}
|
||||
|
||||
/**
|
||||
* Upgrade one socket and pump the host stream until either side closes.
|
||||
* @param req - HTTP upgrade request.
|
||||
* @param socket - Raw socket transferred by the HTTP server.
|
||||
* @param head - Bytes already read after the upgrade headers.
|
||||
*/
|
||||
handleHost(req: IncomingMessage, socket: Duplex, head: Buffer): void {
|
||||
this.upgrade(req, socket, head, signal => this.api.events.host({
|
||||
rpcId: RpcId(randomUUID()),
|
||||
payload: {},
|
||||
}, signal))
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminate owned sockets and await the no-server acceptor plus frame pumps.
|
||||
* @returns A promise resolving after every socket and source iterator stops.
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
for (const socket of this.server.clients) socket.terminate()
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.server.close((error) => {
|
||||
if (error === undefined) resolve()
|
||||
else reject(error)
|
||||
})
|
||||
})
|
||||
await Promise.all(this.pumps)
|
||||
}
|
||||
|
||||
private upgrade<F extends Frame>(
|
||||
req: IncomingMessage,
|
||||
socket: Duplex,
|
||||
head: Buffer,
|
||||
open: (signal: AbortSignal) => AsyncIterable<RpcRequest<F>>,
|
||||
): void {
|
||||
this.server.handleUpgrade(req, socket, head, (websocket) => {
|
||||
const abort = new AbortController()
|
||||
websocket.once('close', () => { abort.abort() })
|
||||
websocket.once('error', () => { abort.abort() })
|
||||
websocket.once('message', () => {
|
||||
websocket.close(1008, 'downlink only')
|
||||
})
|
||||
const pump = this.pump(websocket, open(abort.signal), abort)
|
||||
this.pumps.add(pump)
|
||||
void pump.then(() => { this.pumps.delete(pump) })
|
||||
})
|
||||
}
|
||||
|
||||
private async pump<F extends Frame>(
|
||||
socket: WebSocket,
|
||||
frames: AsyncIterable<RpcRequest<F>>,
|
||||
abort: AbortController,
|
||||
): Promise<void> {
|
||||
try {
|
||||
for await (const frame of frames) await send(socket, frame)
|
||||
} catch (error) {
|
||||
if (!abort.signal.aborted) {
|
||||
try {
|
||||
await send(socket, failureFrame(error))
|
||||
} catch {
|
||||
// Socket loss won the race; no downstream remains to receive the failure frame.
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
abort.abort()
|
||||
if (socket.readyState === WebSocket.OPEN) socket.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject an untrusted upgrade before protocol negotiation.
|
||||
* @param socket - Raw HTTP socket that remains owned by the caller.
|
||||
*/
|
||||
export function rejectWebSocketUpgrade(socket: Duplex): void {
|
||||
socket.end([
|
||||
'HTTP/1.1 403 Forbidden',
|
||||
'Connection: close',
|
||||
'Content-Type: text/plain; charset=utf-8',
|
||||
'Content-Length: 9',
|
||||
'',
|
||||
'forbidden',
|
||||
].join('\r\n'))
|
||||
}
|
||||
@@ -3,15 +3,55 @@
|
||||
* selection off the page URL, and the single-consumer stream-loop ownership.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { apply, type ConnectionHandle } from '../src/client/index.ts'
|
||||
import type { RpcMessage } from '../src/client/api.ts'
|
||||
import { RpcId } from '../src/client/api.ts'
|
||||
import { FixtureApiClient } from '../src/client/fixture.ts'
|
||||
import { WebApiClient } from '../src/client/web-api-client.ts'
|
||||
|
||||
type Win = { location?: { hostname: string; search: string } }
|
||||
type Win = { location?: { hostname: string; search: string; origin?: string } }
|
||||
type WebSocketGlobal = { WebSocket?: typeof WebSocket }
|
||||
|
||||
const originalWebSocket = globalThis.WebSocket
|
||||
const sockets: FakeWebSocket[] = []
|
||||
|
||||
class FakeWebSocket extends EventTarget {
|
||||
static readonly CONNECTING = 0
|
||||
static readonly OPEN = 1
|
||||
static readonly CLOSING = 2
|
||||
static readonly CLOSED = 3
|
||||
|
||||
readonly url: string
|
||||
readyState = FakeWebSocket.CONNECTING
|
||||
|
||||
constructor(url: string | URL) {
|
||||
super()
|
||||
this.url = String(url)
|
||||
sockets.push(this)
|
||||
queueMicrotask(() => {
|
||||
if (this.readyState !== FakeWebSocket.CONNECTING) return
|
||||
this.readyState = FakeWebSocket.OPEN
|
||||
this.dispatchEvent(new Event('open'))
|
||||
})
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.readyState === FakeWebSocket.CLOSED) return
|
||||
this.readyState = FakeWebSocket.CLOSED
|
||||
this.dispatchEvent(new Event('close'))
|
||||
}
|
||||
|
||||
receive(data: unknown): void {
|
||||
this.dispatchEvent(new MessageEvent('message', { data }))
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete (globalThis as Win).location
|
||||
sockets.length = 0
|
||||
if (originalWebSocket === undefined) delete (globalThis as WebSocketGlobal).WebSocket
|
||||
else globalThis.WebSocket = originalWebSocket
|
||||
})
|
||||
|
||||
async function mount(): Promise<ConnectionHandle> {
|
||||
@@ -53,7 +93,7 @@ describe('connection client apply', () => {
|
||||
loop.stop() // teardown must not throw; the fixture streams abort quietly
|
||||
})
|
||||
|
||||
it('WebApiClient carries requests over globalThis.fetch', async () => {
|
||||
it('WebApiClient keeps unary calls and respond on globalThis.fetch', async () => {
|
||||
;(globalThis as Win).location = { hostname: 'localhost', search: '' }
|
||||
const handle = await mount()
|
||||
const original = globalThis.fetch
|
||||
@@ -65,9 +105,102 @@ describe('connection client apply', () => {
|
||||
try {
|
||||
// Schema rejection is fine — the transport hop is the assertion.
|
||||
await (handle.api as WebApiClient).host.describe({}).catch(() => undefined)
|
||||
await handle.api.respond({
|
||||
type: 'client-response',
|
||||
rpcId: RpcId('response-over-http'),
|
||||
result: { ok: true, value: {} },
|
||||
}).catch(() => undefined)
|
||||
} finally {
|
||||
globalThis.fetch = original
|
||||
}
|
||||
expect(seen.some(u => u.includes('/api/'))).toBe(true)
|
||||
expect(seen.some(u => u.includes('/api/host.describe'))).toBe(true)
|
||||
expect(seen.some(u => u.includes('/api/respond'))).toBe(true)
|
||||
})
|
||||
|
||||
it('opens one WebSocket per downlink, parses frames, and aborts both without using fetch', async () => {
|
||||
;(globalThis as Win).location = {
|
||||
hostname: 'localhost', search: '', origin: 'http://localhost:3080',
|
||||
}
|
||||
;(globalThis as WebSocketGlobal).WebSocket = FakeWebSocket as unknown as typeof WebSocket
|
||||
const fetch = vi.spyOn(globalThis, 'fetch')
|
||||
const client = (await mount()).api as WebApiClient
|
||||
const envelopes: RpcMessage[][] = []
|
||||
client.subscribeEnvelopes((batch) => { envelopes.push([...batch]) })
|
||||
const opened: string[] = []
|
||||
const muxAbort = new AbortController()
|
||||
const hostAbort = new AbortController()
|
||||
const mux = client.events.mux({}, muxAbort.signal, () => { opened.push('mux') })[Symbol.asyncIterator]()
|
||||
const host = client.events.host({}, hostAbort.signal, () => { opened.push('host') })[Symbol.asyncIterator]()
|
||||
const muxFrame = mux.next()
|
||||
const hostFrame = host.next()
|
||||
await vi.waitFor(() => { expect(sockets).toHaveLength(2) })
|
||||
expect(sockets.map(socket => socket.url)).toEqual([
|
||||
'ws://localhost:3080/api/events.mux',
|
||||
'ws://localhost:3080/api/events.host',
|
||||
])
|
||||
await vi.waitFor(() => { expect(opened).toEqual(['mux', 'host']) })
|
||||
|
||||
const errors = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
sockets[0]!.receive(new Uint8Array([1, 2, 3]))
|
||||
sockets[1]!.receive(JSON.stringify({ type: 'server-request', rpcId: 'bad', method: 'host/session-status', payload: {} }))
|
||||
sockets[0]!.receive(JSON.stringify({
|
||||
type: 'server-request',
|
||||
rpcId: 'mux-browser',
|
||||
method: 'session/subscribed',
|
||||
payload: { type: 'session/subscribed', sessionId: 'session-browser', lastSeq: 8 },
|
||||
}))
|
||||
sockets[1]!.receive(JSON.stringify({
|
||||
type: 'server-request',
|
||||
rpcId: 'host-browser',
|
||||
method: 'host/commands-changed',
|
||||
payload: { type: 'host/commands-changed' },
|
||||
}))
|
||||
expect(await muxFrame).toMatchObject({
|
||||
value: { rpcId: 'mux-browser', payload: { type: 'session/subscribed', lastSeq: 8 } },
|
||||
})
|
||||
expect(await hostFrame).toMatchObject({
|
||||
value: { rpcId: 'host-browser', payload: { type: 'host/commands-changed' } },
|
||||
})
|
||||
expect(errors).toHaveBeenCalledTimes(2)
|
||||
await vi.waitFor(() => { expect(envelopes.flat()).toHaveLength(2) })
|
||||
expect(fetch).not.toHaveBeenCalled()
|
||||
|
||||
const muxEnd = mux.next()
|
||||
const hostEnd = host.next()
|
||||
muxAbort.abort()
|
||||
hostAbort.abort()
|
||||
await expect(muxEnd).resolves.toMatchObject({ done: true })
|
||||
await expect(hostEnd).resolves.toMatchObject({ done: true })
|
||||
expect(sockets.every(socket => socket.readyState === FakeWebSocket.CLOSED)).toBe(true)
|
||||
errors.mockRestore()
|
||||
fetch.mockRestore()
|
||||
})
|
||||
|
||||
it('maps an HTTPS page origin to a secure WebSocket URL', async () => {
|
||||
;(globalThis as Win).location = {
|
||||
hostname: 'harness.example', search: '', origin: 'https://harness.example',
|
||||
}
|
||||
;(globalThis as WebSocketGlobal).WebSocket = FakeWebSocket as unknown as typeof WebSocket
|
||||
const client = (await mount()).api
|
||||
const abort = new AbortController()
|
||||
const iterator = client.events.mux({}, abort.signal)[Symbol.asyncIterator]()
|
||||
const pending = iterator.next()
|
||||
await vi.waitFor(() => { expect(sockets[0]?.url).toBe('wss://harness.example/api/events.mux') })
|
||||
abort.abort()
|
||||
await expect(pending).resolves.toMatchObject({ done: true })
|
||||
})
|
||||
|
||||
it('closes a WebSocket immediately when its signal was already aborted', async () => {
|
||||
;(globalThis as Win).location = {
|
||||
hostname: 'localhost', search: '', origin: 'http://localhost:3080',
|
||||
}
|
||||
;(globalThis as WebSocketGlobal).WebSocket = FakeWebSocket as unknown as typeof WebSocket
|
||||
const client = (await mount()).api
|
||||
const abort = new AbortController()
|
||||
abort.abort()
|
||||
const iterator = client.events.mux({}, abort.signal)[Symbol.asyncIterator]()
|
||||
await expect(iterator.next()).resolves.toMatchObject({ done: true })
|
||||
expect(sockets).toHaveLength(1)
|
||||
expect(sockets[0]?.readyState).toBe(FakeWebSocket.CLOSED)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,6 +19,16 @@ interface TimingHooks {
|
||||
failNextHistory(): void
|
||||
appendUser(id: string, msg: string): void
|
||||
appendTitle(id: string, title: string): void
|
||||
startReasoningChunkStorm(id: string, chunkCount: number, chunksPerInterval: number, intervalMs: number): string
|
||||
reasoningChunkStormState(): {
|
||||
sessionId: string
|
||||
chunkCount: number
|
||||
chunksPerInterval: number
|
||||
intervalMs: number
|
||||
emitted: number
|
||||
marker: string
|
||||
emitting: boolean
|
||||
} | null
|
||||
beginModelRetry(id: string): void
|
||||
scheduleModelRetry(id: string, retry?: number, delayMs?: number): void
|
||||
cancelModelRetryDuringBackoff(id: string, delayMs?: number): void
|
||||
@@ -873,6 +883,50 @@ describe('createFixtureApi', () => {
|
||||
expect(abort.signal.aborted).toBe(false)
|
||||
expect(habort.signal.aborted).toBe(false)
|
||||
})
|
||||
|
||||
it('paces the opt-in reasoning stress hook from an external interval', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(0)
|
||||
const api = createFixtureApi()
|
||||
const hooks = timing()
|
||||
expect(hooks.reasoningChunkStormState()).toBeNull()
|
||||
expect(() => hooks.startReasoningChunkStorm('fx-alpha', 0, 1, 16)).toThrow(/chunk count/)
|
||||
expect(() => hooks.startReasoningChunkStorm('fx-alpha', 1, 0, 16)).toThrow(/chunks per interval/)
|
||||
expect(() => hooks.startReasoningChunkStorm('fx-alpha', 1, 1, 0)).toThrow(/reasoning interval/)
|
||||
const abort = new AbortController()
|
||||
try {
|
||||
const streamed = collect(api.events.mux(req({}), abort.signal), abort, frames => frames.some(frame => (
|
||||
frame.type === 'session/event'
|
||||
&& frame.event.type === 'assistant/chunk'
|
||||
&& frame.event.data.chunk.type === 'reasoning-delta'
|
||||
&& frame.event.data.chunk.text.includes('REASONING_STRESS_COMPLETE')
|
||||
)))
|
||||
const marker = hooks.startReasoningChunkStorm('fx-alpha', 3, 2, 16)
|
||||
expect(() => hooks.startReasoningChunkStorm('fx-alpha', 1, 1, 16)).toThrow(/already running/)
|
||||
expect(hooks.reasoningChunkStormState()).toMatchObject({ emitted: 0, emitting: true, marker })
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(hooks.reasoningChunkStormState()).toMatchObject({ emitted: 2, emitting: true })
|
||||
await vi.advanceTimersByTimeAsync(16)
|
||||
expect(hooks.reasoningChunkStormState()).toEqual({
|
||||
sessionId: 'fx-alpha', chunkCount: 3, chunksPerInterval: 2, intervalMs: 16,
|
||||
emitted: 3, marker, emitting: false,
|
||||
})
|
||||
|
||||
const frames = await streamed
|
||||
const deltas = frames.flatMap(frame => (
|
||||
frame.type === 'session/event'
|
||||
&& frame.event.type === 'assistant/chunk'
|
||||
&& frame.event.data.chunk.type === 'reasoning-delta'
|
||||
? [frame.event.data.chunk.text]
|
||||
: []
|
||||
))
|
||||
expect(deltas).toEqual(['推理', '推理', `\n${marker}`])
|
||||
} finally {
|
||||
abort.abort()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('FixtureApiClient (protocol-level fake carrier)', () => {
|
||||
|
||||
@@ -1,22 +1,29 @@
|
||||
/** Node half: registers the /api prefix route bridging to the api gateway. */
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { EventEmitter, once } from 'node:events'
|
||||
import { createServer, request as httpRequest } from 'node:http'
|
||||
import { Readable } from 'node:stream'
|
||||
import { PassThrough, Readable } from 'node:stream'
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { API_PATH, apply, inject } from '../src/index.ts'
|
||||
import type { HttpServerService, WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { API_PATH, apply, HOST_EVENTS_PATH, inject, MUX_EVENTS_PATH } from '../src/index.ts'
|
||||
|
||||
/** Structural httpServer fake: the plugin only touches register(). */
|
||||
function fakeHttpServer(routes: WebRoute[]): Pick<HttpServerService, 'register' | 'tapIndex' | 'port'> {
|
||||
/** Structural httpServer fake recording both route registries. */
|
||||
function fakeHttpServer(
|
||||
routes: WebRoute[],
|
||||
upgrades: WebUpgradeRoute[],
|
||||
): Pick<HttpServerService, 'register' | 'registerUpgrade' | 'tapIndex' | 'port'> {
|
||||
return {
|
||||
register(route) {
|
||||
routes.push(route)
|
||||
return () => { routes.splice(routes.indexOf(route), 1) }
|
||||
},
|
||||
registerUpgrade(route) {
|
||||
upgrades.push(route)
|
||||
return () => { upgrades.splice(upgrades.indexOf(route), 1) }
|
||||
},
|
||||
tapIndex: () => () => {},
|
||||
port: 0,
|
||||
}
|
||||
@@ -45,33 +52,67 @@ function fakeResponse(): { response: ServerResponse; state: { status?: number; b
|
||||
return { response, state }
|
||||
}
|
||||
|
||||
async function mounted(config?: { trustedHosts?: string[] }): Promise<{ routes: WebRoute[]; dispose: () => Promise<void> }> {
|
||||
async function mounted(config?: { trustedHosts?: string[] }): Promise<{
|
||||
routes: WebRoute[]
|
||||
upgrades: WebUpgradeRoute[]
|
||||
dispose: () => Promise<void>
|
||||
}> {
|
||||
const ctx = new Context()
|
||||
const routes: WebRoute[] = []
|
||||
ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService)
|
||||
const upgrades: WebUpgradeRoute[] = []
|
||||
ctx.provide('httpServer', fakeHttpServer(routes, upgrades) as HttpServerService)
|
||||
ctx.provide('apiProxy', {} as unknown as ApiProxy)
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply }, config)
|
||||
await fiber.await()
|
||||
return { routes, dispose: () => fiber.dispose() }
|
||||
return { routes, upgrades, dispose: () => fiber.dispose() }
|
||||
}
|
||||
|
||||
describe('connection node half', () => {
|
||||
it('fails the load on a trustedHosts entry that is not a bare authority', async () => {
|
||||
const routes: WebRoute[] = []
|
||||
const upgrades: WebUpgradeRoute[] = []
|
||||
const ctx = new Context()
|
||||
ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService)
|
||||
ctx.provide('httpServer', fakeHttpServer(routes, upgrades) as HttpServerService)
|
||||
ctx.provide('apiProxy', {} as unknown as ApiProxy)
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.internal/path'] })
|
||||
await expect(fiber).rejects.toThrow(/not a bare host\[:port\] authority/)
|
||||
expect(routes).toHaveLength(0)
|
||||
expect(upgrades).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('registers the /api prefix route and removes it with the fiber', async () => {
|
||||
const { routes, dispose } = await mounted()
|
||||
it('registers one HTTP route plus one upgrade route per downlink and removes all three with the fiber', async () => {
|
||||
const { routes, upgrades, dispose } = await mounted()
|
||||
expect(routes).toHaveLength(1)
|
||||
expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH })
|
||||
expect(upgrades.map(route => route.path)).toEqual([MUX_EVENTS_PATH, HOST_EVENTS_PATH])
|
||||
await dispose()
|
||||
expect(routes).toHaveLength(0)
|
||||
expect(upgrades).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('requires WebSocket upgrade for network GETs to either event path', async () => {
|
||||
const { routes, dispose } = await mounted()
|
||||
for (const path of [MUX_EVENTS_PATH, HOST_EVENTS_PATH]) {
|
||||
const { response, state } = fakeResponse()
|
||||
await routes[0]!.handler(fakeRequest({ host: '127.0.0.1:3080' }, path), response)
|
||||
expect(state.status).toBe(426)
|
||||
expect(state.body).toBe('upgrade required')
|
||||
}
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it('rejects an untrusted WebSocket upgrade before protocol negotiation', async () => {
|
||||
const { upgrades, dispose } = await mounted()
|
||||
const socket = new PassThrough()
|
||||
const chunks: Buffer[] = []
|
||||
socket.on('data', (chunk: Buffer) => { chunks.push(chunk) })
|
||||
const ended = once(socket, 'end')
|
||||
await upgrades[0]!.handler(fakeRequest({
|
||||
host: 'harness.example', origin: 'http://harness.example', 'sec-fetch-site': 'same-origin',
|
||||
}, MUX_EVENTS_PATH), socket, Buffer.alloc(0))
|
||||
await ended
|
||||
expect(Buffer.concat(chunks).toString()).toContain('HTTP/1.1 403 Forbidden')
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it('refuses an untrusted Host on any /api path before the bridge runs', async () => {
|
||||
|
||||
308
packages/client/connection/tests/websocket-downlink.spec.ts
Normal file
308
packages/client/connection/tests/websocket-downlink.spec.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
import { once } from 'node:events'
|
||||
import { createServer } from 'node:http'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import WebSocket from 'ws'
|
||||
import type {
|
||||
ApiProxy, HostFrame, MuxFrame, RpcRequest, ServerRequest,
|
||||
} from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { HOST_EVENTS_PATH, MUX_EVENTS_PATH } from '../src/api-path.ts'
|
||||
import { WebSocketDownlinks } from '../src/websocket-downlink.ts'
|
||||
|
||||
type MuxSource = (signal: AbortSignal) => AsyncIterable<RpcRequest<MuxFrame>>
|
||||
type HostSource = (signal: AbortSignal) => AsyncIterable<RpcRequest<HostFrame>>
|
||||
|
||||
const running: (() => Promise<void>)[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(running.splice(0).map(close => close()))
|
||||
})
|
||||
|
||||
function untilAbort(signal: AbortSignal): Promise<void> {
|
||||
if (signal.aborted) return Promise.resolve()
|
||||
return new Promise((resolve) => {
|
||||
signal.addEventListener('abort', () => { resolve() }, { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
async function * idle<F>(signal: AbortSignal): AsyncGenerator<RpcRequest<F>> {
|
||||
await untilAbort(signal)
|
||||
}
|
||||
|
||||
function api(mux: MuxSource, host: HostSource): ApiProxy {
|
||||
return {
|
||||
events: {
|
||||
mux: (_request, signal) => mux(signal),
|
||||
host: (_request, signal) => host(signal),
|
||||
},
|
||||
} as ApiProxy
|
||||
}
|
||||
|
||||
async function serve(downlinks: WebSocketDownlinks): Promise<{
|
||||
origin: string
|
||||
close: () => Promise<void>
|
||||
}> {
|
||||
const server = createServer()
|
||||
server.on('upgrade', (request, socket, head) => {
|
||||
const pathname = new URL(request.url ?? '/', 'http://dsh.internal').pathname
|
||||
if (pathname === MUX_EVENTS_PATH) downlinks.handleMux(request, socket, head)
|
||||
else if (pathname === HOST_EVENTS_PATH) downlinks.handleHost(request, socket, head)
|
||||
else socket.destroy()
|
||||
})
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
const port = (server.address() as AddressInfo).port
|
||||
return {
|
||||
origin: `ws://127.0.0.1:${String(port)}`,
|
||||
close: async () => {
|
||||
await downlinks.close()
|
||||
await new Promise<void>(resolve => server.close(() => { resolve() }))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function read(socket: WebSocket): Promise<ServerRequest> {
|
||||
return once(socket, 'message').then(([data]) => JSON.parse(String(data)) as ServerRequest)
|
||||
}
|
||||
|
||||
async function acceptedSocket(downlinks: WebSocketDownlinks): Promise<WebSocket> {
|
||||
const server = (downlinks as unknown as { server: { clients: Set<WebSocket> } }).server
|
||||
let accepted: WebSocket | undefined
|
||||
await vi.waitFor(() => {
|
||||
accepted = server.clients.values().next().value
|
||||
expect(accepted).toBeDefined()
|
||||
})
|
||||
return accepted as WebSocket
|
||||
}
|
||||
|
||||
describe('WebSocket downlinks', () => {
|
||||
it('carries mux and host over independent downstream sockets and cancels each source on close', async () => {
|
||||
let muxAborted = false
|
||||
let hostAborted = false
|
||||
const downlinks = new WebSocketDownlinks(api(
|
||||
async function * (signal) {
|
||||
try {
|
||||
yield {
|
||||
rpcId: RpcId('mux-1'),
|
||||
payload: { type: 'session/subscribed', sessionId: 'session-1' as never, lastSeq: 4 },
|
||||
}
|
||||
await untilAbort(signal)
|
||||
} finally {
|
||||
muxAborted = true
|
||||
}
|
||||
},
|
||||
async function * (signal) {
|
||||
try {
|
||||
yield { rpcId: RpcId('host-1'), payload: { type: 'host/commands-changed' } }
|
||||
await untilAbort(signal)
|
||||
} finally {
|
||||
hostAborted = true
|
||||
}
|
||||
},
|
||||
))
|
||||
const host = await serve(downlinks)
|
||||
running.push(host.close)
|
||||
|
||||
const mux = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`)
|
||||
const hostSocket = new WebSocket(`${host.origin}${HOST_EVENTS_PATH}`)
|
||||
const muxFrame = read(mux)
|
||||
const hostFrame = read(hostSocket)
|
||||
expect(await muxFrame).toEqual({
|
||||
type: 'server-request',
|
||||
rpcId: 'mux-1',
|
||||
method: 'session/subscribed',
|
||||
payload: { type: 'session/subscribed', sessionId: 'session-1', lastSeq: 4 },
|
||||
})
|
||||
expect(await hostFrame).toEqual({
|
||||
type: 'server-request',
|
||||
rpcId: 'host-1',
|
||||
method: 'host/commands-changed',
|
||||
payload: { type: 'host/commands-changed' },
|
||||
})
|
||||
|
||||
const muxClosed = once(mux, 'close')
|
||||
const hostClosed = once(hostSocket, 'close')
|
||||
mux.close()
|
||||
hostSocket.close()
|
||||
await Promise.all([muxClosed, hostClosed])
|
||||
await vi.waitFor(() => {
|
||||
expect(muxAborted).toBe(true)
|
||||
expect(hostAborted).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects client messages because upstream remains HTTP', async () => {
|
||||
let aborted = false
|
||||
const downlinks = new WebSocketDownlinks(api(
|
||||
async function * (signal) {
|
||||
try {
|
||||
await untilAbort(signal)
|
||||
} finally {
|
||||
aborted = true
|
||||
}
|
||||
},
|
||||
idle,
|
||||
))
|
||||
const host = await serve(downlinks)
|
||||
running.push(host.close)
|
||||
const socket = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`)
|
||||
await once(socket, 'open')
|
||||
const closed = once(socket, 'close')
|
||||
socket.send('upstream payload')
|
||||
const [code, reason] = await closed as [number, Buffer]
|
||||
expect(code).toBe(1008)
|
||||
expect(String(reason)).toBe('downlink only')
|
||||
await vi.waitFor(() => { expect(aborted).toBe(true) })
|
||||
})
|
||||
|
||||
it('sends stream/error before closing when a source fails', async () => {
|
||||
const downlinks = new WebSocketDownlinks(api(
|
||||
async function * () {
|
||||
throw new Error('mux source failed')
|
||||
},
|
||||
idle,
|
||||
))
|
||||
const host = await serve(downlinks)
|
||||
running.push(host.close)
|
||||
const socket = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`)
|
||||
const failure = read(socket)
|
||||
const closed = once(socket, 'close')
|
||||
expect((await failure).payload).toEqual({
|
||||
type: 'stream/error',
|
||||
error: { code: 'internal', message: 'Error: mux source failed', details: {} },
|
||||
})
|
||||
await closed
|
||||
})
|
||||
|
||||
it('aborts the source when an accepted socket reports a transport error', async () => {
|
||||
let aborted = false
|
||||
const downlinks = new WebSocketDownlinks(api(
|
||||
async function * (signal) {
|
||||
try {
|
||||
await untilAbort(signal)
|
||||
} finally {
|
||||
aborted = true
|
||||
}
|
||||
},
|
||||
idle,
|
||||
))
|
||||
const host = await serve(downlinks)
|
||||
running.push(host.close)
|
||||
const socket = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`)
|
||||
await once(socket, 'open')
|
||||
const accepted = await acceptedSocket(downlinks)
|
||||
const closed = once(socket, 'close')
|
||||
accepted.emit('error', new Error('transport failed'))
|
||||
await closed
|
||||
expect(aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('drops a source frame that races after the client has closed', async () => {
|
||||
let release!: () => void
|
||||
const gate = new Promise<void>((resolve) => { release = resolve })
|
||||
let finish!: () => void
|
||||
const finished = new Promise<void>((resolve) => { finish = resolve })
|
||||
let sourceSignal: AbortSignal | undefined
|
||||
const downlinks = new WebSocketDownlinks(api(
|
||||
async function * (signal) {
|
||||
sourceSignal = signal
|
||||
try {
|
||||
await gate
|
||||
yield {
|
||||
rpcId: RpcId('late'),
|
||||
payload: { type: 'session/subscribed', sessionId: 'session-late' as never, lastSeq: 0 },
|
||||
}
|
||||
} finally {
|
||||
finish()
|
||||
}
|
||||
},
|
||||
idle,
|
||||
))
|
||||
const host = await serve(downlinks)
|
||||
running.push(host.close)
|
||||
const socket = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`)
|
||||
await once(socket, 'open')
|
||||
const closed = once(socket, 'close')
|
||||
socket.close()
|
||||
await closed
|
||||
await vi.waitFor(() => { expect(sourceSignal?.aborted).toBe(true) })
|
||||
release()
|
||||
await finished
|
||||
})
|
||||
|
||||
it('contains socket send callback failures and closes the downlink', async () => {
|
||||
let release!: () => void
|
||||
const gate = new Promise<void>((resolve) => { release = resolve })
|
||||
const downlinks = new WebSocketDownlinks(api(
|
||||
async function * () {
|
||||
await gate
|
||||
yield {
|
||||
rpcId: RpcId('send-failure'),
|
||||
payload: { type: 'session/subscribed', sessionId: 'session-send' as never, lastSeq: 0 },
|
||||
}
|
||||
},
|
||||
idle,
|
||||
))
|
||||
const host = await serve(downlinks)
|
||||
running.push(host.close)
|
||||
const socket = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`)
|
||||
await once(socket, 'open')
|
||||
const accepted = await acceptedSocket(downlinks)
|
||||
const send = vi.spyOn(accepted, 'send').mockImplementation(((
|
||||
_data: unknown,
|
||||
optionsOrCallback?: unknown,
|
||||
callback?: (error?: Error) => void,
|
||||
) => {
|
||||
const done = typeof optionsOrCallback === 'function'
|
||||
? optionsOrCallback as (error?: Error) => void
|
||||
: callback
|
||||
done?.(new Error('socket send failed'))
|
||||
}) as WebSocket['send'])
|
||||
const closed = once(socket, 'close')
|
||||
release()
|
||||
await closed
|
||||
expect(send).toHaveBeenCalledTimes(2)
|
||||
send.mockRestore()
|
||||
})
|
||||
|
||||
it('rejects when its acceptor has already closed', async () => {
|
||||
const downlinks = new WebSocketDownlinks(api(idle, idle))
|
||||
await downlinks.close()
|
||||
await expect(downlinks.close()).rejects.toThrow('The server is not running')
|
||||
})
|
||||
|
||||
it('waits for source cleanup before teardown resolves', async () => {
|
||||
let cleanupStarted!: () => void
|
||||
const started = new Promise<void>((resolve) => { cleanupStarted = resolve })
|
||||
let releaseCleanup!: () => void
|
||||
const cleanupGate = new Promise<void>((resolve) => { releaseCleanup = resolve })
|
||||
let cleaned = false
|
||||
const downlinks = new WebSocketDownlinks(api(
|
||||
async function * (signal) {
|
||||
try {
|
||||
await untilAbort(signal)
|
||||
} finally {
|
||||
cleanupStarted()
|
||||
await cleanupGate
|
||||
cleaned = true
|
||||
}
|
||||
},
|
||||
idle,
|
||||
))
|
||||
const host = await serve(downlinks)
|
||||
const socket = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`)
|
||||
await once(socket, 'open')
|
||||
let closed = false
|
||||
const closing = host.close().then(() => { closed = true })
|
||||
try {
|
||||
await started
|
||||
expect(closed).toBe(false)
|
||||
releaseCleanup()
|
||||
await closing
|
||||
expect(cleaned).toBe(true)
|
||||
} finally {
|
||||
releaseCleanup()
|
||||
await closing
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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/hmr/README.md
|
||||
README.md: 2b2f63c25cbf3a46babef78a4dfb52f859156887
|
||||
README.zh.md: 58fbad900d9ab86a9d28979f691f24de29e9b6f4
|
||||
README.md: f91a6c6f685c88a1ea19312985ad3e933222192a
|
||||
README.zh.md: 1d20a22d211c13d62089fb5618f40636ab7ae60a
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Hot reload for fetch-arrival client plugins. A static-arrival entry composed only into `--dev` graphs (`dsh web --dev`); production graphs omit the row, so the shell-bundled code stays inert.
|
||||
Hot reload for script-loaded client plugins. A static-arrival entry composed only into `--dev` graphs (`dsh web --dev`); production graphs omit the row, so the shell-bundled code stays inert.
|
||||
|
||||
The browser half subscribes to the system SSE channel (`GET /plugins/events`) and reloads one plugin per `rebuilt` frame, serialized through a queue (the bundle handoff slot is single). The sequence per frame — `prefetch` (fetch the new bundle before touching anything), `invalidate`, `registry.delete` (before the fiber: a bare fiber dispose trips the vendored Loader's self-dispose branch, which would mark the entry disabled), drain the old fiber, delete `entry.fiber`, remove owned `<style data-plugin>` tags, `entry.refresh()` re-imports and remounts, `fiber.await()` rethrows startup failures loud. Dependents reload through cordis itself: a fiber's activation epoch strings its service providers' uids, so replacing a provider's fiber cascades every dependent with zero client-side graph analysis. The node half detects rebuilds with one interval that stat-polls each graph bundle from a synchronous baseline, immediately re-hashes after adding a row, retains missing rows as dirty, and broadcasts only real rev changes; any tsdown watch process producing the bundle therefore triggers HMR with no builder→host channel.
|
||||
The browser half subscribes to the system SSE channel (`GET /plugins/events`) and reloads one plugin per `rebuilt` frame through a serialized queue. The sequence per frame — `invalidate`, `prefetch` (load and register the new bundle while the old fiber still serves), `registry.delete` (before the fiber: a bare fiber dispose trips the vendored Loader's self-dispose branch, which would mark the entry disabled), drain the old fiber, delete `entry.fiber`, remove owned `<style data-plugin>` tags, `entry.refresh()` re-imports and remounts, `fiber.await()` rethrows startup failures loud. Dependents reload through cordis itself: a fiber's activation epoch strings its service providers' uids, so replacing a provider's fiber cascades every dependent with zero client-side graph analysis. The node half detects rebuilds with one interval that stat-polls each graph bundle from a synchronous baseline, immediately re-hashes after adding a row, retains missing rows as dirty, and broadcasts only real rev changes; any tsdown watch process producing the bundle therefore triggers HMR with no builder→host channel.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
为通过 fetch 加载的客户端插件提供热重载。该静态加载配置项只组合进 `--dev` 图(`dsh web --dev`);生产图省略该项,因此打包进 shell 的代码保持不活动。
|
||||
为通过外部脚本加载的客户端插件提供热重载。该静态加载配置项只组合进 `--dev` 图(`dsh web --dev`);生产图省略该项,因此打包进 shell 的代码保持不活动。
|
||||
|
||||
浏览器侧订阅系统 SSE(Server-Sent Events)通道(`GET /plugins/events`),每个 `rebuilt` 帧重载一个插件,并通过队列串行执行(组合包交接 slot 只能容纳一个)。每帧的顺序是:`prefetch`(在触碰任何内容前抓取新组合包)、`invalidate`、`registry.delete`(在 fiber dispose(资源释放)之前执行:仅 dispose fiber 会触发 vendored Loader 的 self-dispose 分支,把配置项标为禁用)、排空旧 fiber、删除 `entry.fiber`、移除自身拥有的 `<style data-plugin>` 标签、通过 `entry.refresh()` 重新导入并挂载、通过 `fiber.await()` 直接重新抛出启动失败。依赖方由 Cordis 自身重载:fiber 的激活 epoch 会串联其服务提供方的 uid,因此替换提供方 fiber 会级联所有依赖方,无需客户端图分析。node 侧使用一个 interval 检测重建:从同步基线开始 stat-poll 每个图组合包;新增一行后立即重新计算 hash;缺失行保持 dirty;只广播真实 rev 变更。因此,任何生成组合包的 tsdown watch 进程都能触发 HMR(热模块替换),无需 builder→host 通道。
|
||||
浏览器侧订阅系统 SSE(Server-Sent Events)通道(`GET /plugins/events`),每个 `rebuilt` 帧重载一个插件,并通过队列串行执行。每帧的顺序是:`invalidate`、`prefetch`(旧 fiber 仍在服务时加载并注册新组合包)、`registry.delete`(在 fiber dispose(资源释放)之前执行:仅 dispose fiber 会触发 vendored Loader 的 self-dispose 分支,把配置项标为禁用)、排空旧 fiber、删除 `entry.fiber`、移除自身拥有的 `<style data-plugin>` 标签、通过 `entry.refresh()` 重新导入并挂载、通过 `fiber.await()` 直接重新抛出启动失败。依赖方由 Cordis 自身重载:fiber 的激活 epoch 会串联其服务提供方的 uid,因此替换提供方 fiber 会级联所有依赖方,无需客户端图分析。node 侧使用一个 interval 检测重建:从同步基线开始 stat-poll 每个图组合包;新增一行后立即重新计算 hash;缺失行保持 dirty;只广播真实 rev 变更。因此,任何生成组合包的 tsdown watch 进程都能触发 HMR(热模块替换),无需 builder→host 通道。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-hmr",
|
||||
"description": "Dev-only hot-reload driver for fetch-arrival client entries: SSE rebuilt frames → prefetch/invalidate → fiber swap through the vendored Loader entry",
|
||||
"description": "Dev-only hot-reload driver for script-loaded client entries: SSE rebuilt frames → invalidate/prefetch → fiber swap through the vendored Loader entry",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* client-hmr, browser half: hot-reload driver for client plugin entries.
|
||||
*
|
||||
* Listens on the host's system SSE channel (`GET /plugins/events`); on a
|
||||
* `rebuilt` frame it re-fetches the entry's bundle and swaps the cordis
|
||||
* `rebuilt` frame it reloads the entry's bundle and swaps the cordis
|
||||
* fiber in place. Every graph entry is a plugin bundle under the web2 model
|
||||
* — `immediately` rows differ only in stage-one prefetch (a boot
|
||||
* optimization), so all rostered plugin packages share these reload semantics;
|
||||
@@ -14,7 +14,7 @@
|
||||
* cascades into its UI dependents with no HMR-side bookkeeping.
|
||||
*
|
||||
* Reload order (lazy CJS table): invalidate (drop the stale factory and
|
||||
* materialized record) → prefetch (fetch + execute + register the fresh
|
||||
* materialized record) → prefetch (load and register the fresh
|
||||
* factory) → registry-first teardown → drain old fiber unload → remove
|
||||
* owned `<style data-plugin>` tags → `entry.refresh()` materializes the new
|
||||
* factory. Invalidate MUST precede prefetch: a live factory makes prefetch
|
||||
@@ -110,7 +110,7 @@ export function apply(ctx: Context): void {
|
||||
}
|
||||
// Invalidate first (drop stale factory + record — a live factory makes
|
||||
// prefetch a no-op and re-registration a loud duplicate), then run the
|
||||
// async half while the old fiber still serves: fetch + execute registers
|
||||
// async half while the old fiber still serves: script loading registers
|
||||
// the fresh factory with zero side effects (lazy CJS — module bodies run
|
||||
// at materialization, not execution).
|
||||
modLoader.invalidate(id)
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/modules/README.md
|
||||
README.md: 99565b349d782c58752ac3e73ce7c0be527f78a8
|
||||
README.zh.md: a8ed0a4949ccefce53933b4f2fb8f51f5291684f
|
||||
README.md: 7d661c806955d0fac021dd6620994aab83c0f773
|
||||
README.zh.md: a1da42a552dbe8770fcb78bf458c01a0057ce8dd
|
||||
|
||||
@@ -6,9 +6,9 @@ Client module system: the browser peer of Node's internal ESM loader, built as a
|
||||
|
||||
Lazy CJS model (web2): executing a plugin bundle only REGISTERS its factory (`window.__ModuleLoader__.load({id, factory})`); every module body side effect — CSS injection included — lives in the factory closure and runs at materialization (`factory(require)` → export surface, memoized in `loadCache`), not at script execution. A factory that requires another registered-but-unmaterialized module materializes it recursively, so load order needs no external sequencing; require cycles throw (factory-form CJS cannot deliver partial exports). `<id>/client` and the bare id name the same surface (a plugin bundle IS its package's client half).
|
||||
|
||||
Resolution branch order (`import(specifier)`): platform seed word → shell instance; memoized record → surface; shell-own static registry (`registerStatic`, app-shell) → module; registered factory → materialize; graph row (`window.__DSH_BOOT__`) → fetch + execute + materialize; anything else throws — the runtime mirror of the build-time bundle purity gate. The synchronous `require` handed to factories walks the same order minus the fetch branch and records observed edges into the module record. `prefetch` is the stage-one arrival hook (fetch + execute, registration only; concurrent calls share one in-flight task); `invalidate` drops the factory and the materialized record so the next prefetch/import refetches (the HMR hook).
|
||||
Resolution branch order (`import(specifier)`): platform seed word → shell instance; memoized record → surface; shell-own static registry (`registerStatic`, app-shell) → module; registered factory → materialize; graph row (`window.__DSH_BOOT__`) → load its external classic script + materialize; anything else throws — the runtime mirror of the build-time bundle purity gate. The synchronous `require` handed to factories walks the same order minus the asynchronous load branch and records observed edges into the module record. `prefetch` is the stage-one arrival hook (script load and factory registration only; concurrent calls share one in-flight task); `invalidate` drops the factory and materialized record so the next prefetch/import reloads the script (the HMR hook).
|
||||
|
||||
The Node half scans enabled Loader entries for web `dshClient` packages, resolves each `exports["./client"]`, hashes the built bundle into the boot graph, and serves it under `/plugins`. Source launch maps host imports to TypeScript source but still consumes this built client export; missing files share one build instruction followed by a package/path list, while unrelated filesystem errors remain separate failures.
|
||||
The Node half scans enabled Loader entries for web `dshClient` packages, resolves each `exports["./client"]`, hashes the built bundle into the boot graph, and serves it with its source map under `/plugins`. Source launch maps host imports to TypeScript source but still consumes this built client export; missing files share one build instruction followed by a package/path list, while unrelated filesystem errors remain separate failures.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
|
||||
惰性 CJS 模型(web2):执行插件组合包只会注册其 factory(`window.__ModuleLoader__.load({id, factory})`);每个模块主体的副作用(包括 CSS 注入)都位于 factory 闭包中,在物化时运行(`factory(require)` → 导出表层,并在 `loadCache` 中记忆化),不会在脚本执行时运行。如果 factory 依赖另一个已注册但尚未物化的模块,系统会递归物化它,因此加载顺序无需外部编排;require 循环会抛出异常(factory 形式的 CJS 无法提供部分导出)。`<id>/client` 与裸 id 指向同一表层(一个插件组合包就是其包的客户端侧)。
|
||||
|
||||
解析分支顺序(`import(specifier)`):平台种子词 → 外壳实例;记忆化记录 → 表层;外壳自身的静态注册表(`registerStatic`,app-shell)→ 模块;已注册 factory → 物化;模块图记录(`window.__DSH_BOOT__`)→ 抓取 + 执行 + 物化;其他情况一律抛出异常。这是构建时组合包纯度门禁的运行时镜像。交给 factory 的同步 `require` 采用相同顺序,但不含抓取分支,并把观察到的边记录到模块记录中。`prefetch` 是第一阶段加载钩子(抓取 + 执行,只注册;并发调用共享一个进行中的任务);`invalidate` 会丢弃 factory 与物化记录,使下一次 prefetch/import 重新抓取;它是 HMR(热模块替换)钩子。
|
||||
解析分支顺序(`import(specifier)`):平台种子词 → 外壳实例;记忆化记录 → 表层;外壳自身的静态注册表(`registerStatic`,app-shell)→ 模块;已注册 factory → 物化;模块图记录(`window.__DSH_BOOT__`)→ 加载外部 classic script + 物化;其他情况一律抛出异常。这是构建时组合包纯度门禁的运行时镜像。交给 factory 的同步 `require` 采用相同顺序,但不含异步加载分支,并把观察到的边记录到模块记录中。`prefetch` 是第一阶段到达钩子(只加载脚本并注册 factory;并发调用共享一个进行中的任务);`invalidate` 会丢弃 factory 与物化记录,使下一次 prefetch/import 重新加载脚本;它是 HMR(热模块替换)钩子。
|
||||
|
||||
Node 侧会扫描已启用的 Loader 配置项以发现 web `dshClient` 包,解析每个 `exports["./client"]`,把构建后的组合包哈希写入启动图,并通过 `/plugins` 提供该文件。源码启动会把宿主侧导入映射到 TypeScript 源码,但仍消费客户端导出的构建产物;缺失文件共享一条构建要求,随后以 package/path list 列出各项,而无关的文件系统错误仍是独立故障。
|
||||
Node 侧会扫描已启用的 Loader 配置项以发现 web `dshClient` 包,解析每个 `exports["./client"]`,把构建后的组合包哈希写入启动图,并通过 `/plugins` 提供该文件及其 sourcemap。源码启动会把宿主侧导入映射到 TypeScript 源码,但仍消费客户端导出的构建产物;缺失文件共享一条构建要求,随后以 package/path list 列出各项,而无关的文件系统错误仍是独立故障。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
*
|
||||
* Resolution branch order (import): seed word → shell instance; memoized
|
||||
* record → surface; static registry (shell-own modules, e.g. app-shell) →
|
||||
* module; registered factory → materialize; graph row → fetch + execute +
|
||||
* materialize; anything else → throw (loud — the runtime mirror of the
|
||||
* module; registered factory → materialize; graph row → load + materialize;
|
||||
* anything else → throw (loud — the runtime mirror of the
|
||||
* build-time bundle purity gate). The synchronous `require` handed to
|
||||
* factories walks the same order minus the fetch branch: fetching is async,
|
||||
* so only already-executed bundles can be required — and cross-plugin value
|
||||
* factories walks the same order minus the load branch: loading is async,
|
||||
* so only already-registered bundles can be required — and cross-plugin value
|
||||
* imports are a build error anyway.
|
||||
*
|
||||
* This file is the browser-safe contract face (zero node imports): the
|
||||
@@ -56,7 +56,7 @@ export interface WebBootEntry {
|
||||
rev: string
|
||||
/** Package-name dependency edges, informational (preflight display / HMR diffing). */
|
||||
inject?: string[]
|
||||
/** Stage-one prefetch mark: fetch + execute (factory registration) during module-face boot. */
|
||||
/** Stage-one prefetch mark: load the script for factory registration during module-face boot. */
|
||||
immediately?: boolean
|
||||
}
|
||||
|
||||
@@ -210,18 +210,17 @@ export interface ClientModuleLoader {
|
||||
*/
|
||||
registerStatic(id: string, module: unknown): void
|
||||
/**
|
||||
* Stage-one arrival: fetch the entry's bundle and execute it, registering
|
||||
* its factory (no materialization — module side effects wait for import).
|
||||
* Stage-one arrival: load the entry's script to register its factory (no
|
||||
* materialization — module side effects wait for import).
|
||||
* No-op for static-registered ids and ids whose factory is already
|
||||
* registered; concurrent calls share one in-flight task. To force a fresh
|
||||
* fetch (HMR), {@link invalidate} first.
|
||||
* load (HMR), {@link invalidate} first.
|
||||
* @param id - graph entry name.
|
||||
*/
|
||||
prefetch(id: string): Promise<void>
|
||||
/**
|
||||
* Full reset of one module: drop its registered factory, its materialized
|
||||
* record, and any consumed bundle text, so the next prefetch/import
|
||||
* refetches and re-executes (the HMR invalidation hook).
|
||||
* Full reset of one module: drop its registered factory and materialized
|
||||
* record so the next prefetch/import reloads it (the HMR invalidation hook).
|
||||
* @param id - entry name to invalidate.
|
||||
*/
|
||||
invalidate(id: string): void
|
||||
@@ -233,11 +232,6 @@ export interface ClientModuleSystemOptions {
|
||||
modules: BootModuleRow[]
|
||||
/** Module-table seed: platform-singleton specifier → shell instance. */
|
||||
staticModules: Record<string, unknown>
|
||||
/** Bundle fetch seam (parallelizable half). Defaults to same-origin fetch().text(). */
|
||||
fetchBundle?: (url: string) => Promise<string>
|
||||
/**
|
||||
* Bundle execution seam (synchronously performs the load() registration).
|
||||
* Defaults to a <script> element carrying the code.
|
||||
*/
|
||||
executeBundle?: (code: string, url: string) => void
|
||||
/** Bundle-load seam. Defaults to a same-origin classic `<script src>` element. */
|
||||
loadBundle?: (url: string) => Promise<void>
|
||||
}
|
||||
|
||||
@@ -2,38 +2,28 @@
|
||||
* ClientModuleSystem — the implementation behind the {@link ClientModuleLoader}
|
||||
* seam. The conceptual contract (lazy CJS model, resolution branch order) is
|
||||
* documented on the public interfaces in `./manifest.ts`; this file owns the
|
||||
* state tables and the fetch/execute/materialize machinery.
|
||||
* state tables and the load/materialize machinery.
|
||||
*/
|
||||
import type {
|
||||
BootModuleRow, ClientModuleLoader, ClientModuleRecord,
|
||||
ClientModuleSystemOptions, ClientPluginHandoff, DshWindow,
|
||||
} from './manifest.ts'
|
||||
|
||||
/** A registered-but-unmaterialized bundle: the factory plus its source URL (diagnostics). */
|
||||
interface RegisteredFactory {
|
||||
factory: ClientPluginHandoff['factory']
|
||||
url: string
|
||||
}
|
||||
|
||||
/** Default bundle fetch seam: same-origin fetch().text(). */
|
||||
const defaultFetchBundle = async (url: string): Promise<string> => {
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) throw new Error(`client-modules: bundle fetch ${url} answered ${String(res.status)}`)
|
||||
return res.text()
|
||||
}
|
||||
|
||||
/** Default bundle execution seam: a <script> element carrying the code. */
|
||||
const defaultExecuteBundle = (code: string, url: string): void => {
|
||||
/** Default bundle-load seam: same-origin external classic script. */
|
||||
const defaultLoadBundle = (url: string): Promise<void> => new Promise((resolve, reject) => {
|
||||
const el = document.createElement('script')
|
||||
// Inline execution (not src) so the fetch half stays parallelizable; the
|
||||
// sourceURL comment keeps devtools stack frames attributed to the bundle.
|
||||
el.textContent = `${code}\n//# sourceURL=${url}`
|
||||
document.head.appendChild(el)
|
||||
// Execution is synchronous for inline scripts: the factory is registered by
|
||||
// now, so the node (and its source text) has no further job. Removing it
|
||||
// keeps repeated HMR rebuilds from accumulating dead script nodes.
|
||||
el.remove()
|
||||
}
|
||||
el.async = true
|
||||
el.src = url
|
||||
el.addEventListener('load', () => {
|
||||
el.remove()
|
||||
resolve()
|
||||
}, { once: true })
|
||||
el.addEventListener('error', () => {
|
||||
el.remove()
|
||||
reject(new Error(`client-modules: bundle script ${url} failed to load`))
|
||||
}, { once: true })
|
||||
document.head.append(el)
|
||||
})
|
||||
|
||||
/**
|
||||
* A plugin bundle IS its package's client half: `<id>/client` (the exports
|
||||
@@ -72,31 +62,21 @@ export class ClientModuleSystem implements ClientModuleLoader {
|
||||
|
||||
private readonly seed: Map<string, unknown>
|
||||
private readonly statics = new Map<string, unknown>()
|
||||
private readonly factories = new Map<string, RegisteredFactory>()
|
||||
/** In-flight prefetch (fetch + execute) per id; concurrent callers share it. */
|
||||
private readonly factories = new Map<string, ClientPluginHandoff['factory']>()
|
||||
/** In-flight prefetch (script load) per id; concurrent callers share it. */
|
||||
private readonly pendingArrival = new Map<string, Promise<void>>()
|
||||
/** Materialization re-entrancy guard: factory-form CJS cannot deliver partial exports, so a cycle is fatal. */
|
||||
private readonly materializing = new Set<string>()
|
||||
private readonly graphRows = new Map<string, BootModuleRow>()
|
||||
// Execution URL of the bundle currently being executed (bound into the
|
||||
// factory registration so diagnostics can name the source).
|
||||
private executingUrl = ''
|
||||
// Graph id of the row currently being executed ('' outside arrive):
|
||||
// the load sink cross-checks the handoff id against it so a mis-stamped
|
||||
// bundle cannot register under another entry's identity.
|
||||
private executingId = ''
|
||||
|
||||
private readonly fetchBundle: (url: string) => Promise<string>
|
||||
private readonly executeBundle: (code: string, url: string) => void
|
||||
private readonly loadBundle: (url: string) => Promise<void>
|
||||
|
||||
/**
|
||||
* Build the module system over the parsed boot rows.
|
||||
* @param options - module rows, module-table staticModules, fetch/execute seams.
|
||||
* @param options - module rows, module-table staticModules, and bundle-load seam.
|
||||
*/
|
||||
constructor(options: ClientModuleSystemOptions) {
|
||||
this.seed = new Map(Object.entries(options.staticModules))
|
||||
this.fetchBundle = options.fetchBundle ?? defaultFetchBundle
|
||||
this.executeBundle = options.executeBundle ?? defaultExecuteBundle
|
||||
this.loadBundle = options.loadBundle ?? defaultLoadBundle
|
||||
|
||||
for (const row of options.modules) {
|
||||
if (this.graphRows.has(row.id)) throw new Error(`client-modules: duplicate graph entry "${row.id}"`)
|
||||
@@ -110,37 +90,22 @@ export class ClientModuleSystem implements ClientModuleLoader {
|
||||
// Registration is keyed by the handoff id; a duplicate means a bundle
|
||||
// executed twice without an invalidate — always a bug, always loud.
|
||||
if (this.factories.has(handoff.id)) throw new Error(`client-modules: duplicate factory registration for "${handoff.id}" (bundle executed twice without invalidate?)`)
|
||||
// A fetched row's bundle must register the id its row names — a
|
||||
// mis-stamped bundle registering under another entry's identity
|
||||
// would let that entry silently materialize foreign exports.
|
||||
if (this.executingId !== '' && handoff.id !== this.executingId) {
|
||||
throw new Error(`client-modules: bundle ${this.executingUrl} registered "${handoff.id}" while arriving for "${this.executingId}" (mis-stamped bundle id)`)
|
||||
}
|
||||
this.factories.set(handoff.id, { factory: handoff.factory, url: this.executingUrl })
|
||||
this.factories.set(handoff.id, handoff.factory)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch + execute one graph row so its factory is registered (idempotent per in-flight arrival). */
|
||||
/** Load one graph row so its factory is registered (idempotent per in-flight arrival). */
|
||||
private arrive(row: BootModuleRow): Promise<void> {
|
||||
const { id, url } = row
|
||||
const pending = this.pendingArrival.get(id)
|
||||
if (pending !== undefined) return pending
|
||||
if (this.factories.has(id)) return Promise.resolve()
|
||||
const task = (async (): Promise<void> => {
|
||||
const code = await this.fetchBundle(url)
|
||||
this.executingUrl = url
|
||||
this.executingId = id
|
||||
try {
|
||||
this.executeBundle(code, url)
|
||||
} finally {
|
||||
this.executingUrl = ''
|
||||
this.executingId = ''
|
||||
}
|
||||
const task = this.loadBundle(url).then(() => {
|
||||
if (!this.factories.has(id)) {
|
||||
throw new Error(`client-modules: bundle ${url} executed without registering "${id}" via __ModuleLoader__.load`)
|
||||
throw new Error(`client-modules: bundle ${url} loaded without registering "${id}" via __ModuleLoader__.load`)
|
||||
}
|
||||
})().finally(() => { this.pendingArrival.delete(id) })
|
||||
}).finally(() => { this.pendingArrival.delete(id) })
|
||||
this.pendingArrival.set(id, task)
|
||||
return task
|
||||
}
|
||||
@@ -158,7 +123,7 @@ export class ClientModuleSystem implements ClientModuleLoader {
|
||||
this.materializing.add(id)
|
||||
try {
|
||||
const edges = new Set<string>()
|
||||
const surface = registered.factory(this.makeRequire(edges))
|
||||
const surface = registered(this.makeRequire(edges))
|
||||
const record: ClientModuleRecord = { id, surface, styles: claimStyles(id), edges }
|
||||
this.loadCache.set(id, record)
|
||||
return record
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* Node half of the client module system (dshClient dual-face package): scans
|
||||
* the host Loader's entries for `dshClient` packages, composes the
|
||||
* `window.__DSH_BOOT__` entry graph (wire single source: {@link WebBootEntry}
|
||||
* in `./client/manifest.ts`), serves `/plugins/<id>/client.js`, taps the
|
||||
* index render to inject the boot manifest, and provides the
|
||||
* in `./client/manifest.ts`), serves `/plugins/<id>/client.js` and its source
|
||||
* map, taps the index render to inject the boot manifest, and provides the
|
||||
* `clientModuleHost` service (the HMR node half's registration/notification
|
||||
* face).
|
||||
*
|
||||
@@ -424,9 +424,15 @@ export class ClientModuleHostService extends Service {
|
||||
const pathname = decodeURIComponent(new URL(req.url ?? '/', 'http://x').pathname)
|
||||
// The id may contain a scope slash. Anything else under /plugins (including
|
||||
// /plugins/events when the HMR row is absent) is an unknown resource.
|
||||
const path = pathname.startsWith('/plugins/') && pathname.endsWith('/client.js')
|
||||
? this.clientPath(pathname.slice('/plugins/'.length, -'/client.js'.length))
|
||||
const prefix = '/plugins/'
|
||||
const mapSuffix = '/client.js.map'
|
||||
const bundleSuffix = '/client.js'
|
||||
const isSourceMap = pathname.startsWith(prefix) && pathname.endsWith(mapSuffix)
|
||||
const suffix = isSourceMap ? mapSuffix : bundleSuffix
|
||||
const clientPath = pathname.startsWith(prefix) && pathname.endsWith(suffix)
|
||||
? this.clientPath(pathname.slice(prefix.length, -suffix.length))
|
||||
: undefined
|
||||
const path = clientPath === undefined ? undefined : `${clientPath}${isSourceMap ? '.map' : ''}`
|
||||
if (path === undefined) {
|
||||
res.writeHead(404)
|
||||
res.end()
|
||||
@@ -434,7 +440,10 @@ export class ClientModuleHostService extends Service {
|
||||
}
|
||||
try {
|
||||
const body = await readFile(path)
|
||||
res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-cache' })
|
||||
res.writeHead(200, {
|
||||
'content-type': isSourceMap ? 'application/json; charset=utf-8' : 'text/javascript; charset=utf-8',
|
||||
'cache-control': 'no-cache',
|
||||
})
|
||||
res.end(body)
|
||||
} catch {
|
||||
// Registered but unreadable (bundle not built yet): loud 404 beats a silent SPA-fallback HTML page.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* registers the factory), materialization on first import/require with
|
||||
* memoization and recursive self-sequencing, the resolution branch order,
|
||||
* shared in-flight arrival, invalidate-refetch (HMR), style claiming, the
|
||||
* default transport seams, and the loud failure modes (duplicate
|
||||
* default transport seam, and the loud failure modes (duplicate
|
||||
* registration, cycles, table misses, double boot).
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -20,7 +20,6 @@ type Factory = ClientPluginHandoff['factory']
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
delete win.__ModuleLoader__
|
||||
delete (document as unknown as Record<string, unknown>).__realmBridge
|
||||
for (const el of document.querySelectorAll('style, script')) el.remove()
|
||||
})
|
||||
|
||||
@@ -33,9 +32,9 @@ interface Bench {
|
||||
}
|
||||
|
||||
/**
|
||||
* Loader over scripted bundles: fetch resolves to the row url (optionally
|
||||
* gated on a release callback); execute registers the scripted factory
|
||||
* through the window sink (`null` scripts a bundle that never calls load).
|
||||
* Loader over scripted bundles: load records the row URL, optionally waits on
|
||||
* a release callback, then registers the scripted factory through the window
|
||||
* sink (`null` scripts a bundle that never calls load).
|
||||
*/
|
||||
function bench(
|
||||
entries: BootModuleRow[],
|
||||
@@ -47,15 +46,12 @@ function bench(
|
||||
const loader = new ClientModuleSystem({
|
||||
modules: entries,
|
||||
staticModules: opts.seed ?? {},
|
||||
fetchBundle: (url) => {
|
||||
loadBundle: async (url) => {
|
||||
fetched.push(url)
|
||||
if (opts.gated?.includes(url) === true) {
|
||||
return new Promise((resolve) => { gates.set(url, () => { resolve(url) }) })
|
||||
await new Promise<void>((resolve) => { gates.set(url, resolve) })
|
||||
}
|
||||
return Promise.resolve(url)
|
||||
},
|
||||
executeBundle: (code) => {
|
||||
const id = /\/plugins\/(.+)\/client\.js/.exec(code)?.[1]
|
||||
const id = /\/plugins\/(.+)\/client\.js/.exec(url)?.[1]
|
||||
const factory = id === undefined ? undefined : bundles[id]
|
||||
if (factory == null || id === undefined) return
|
||||
win.__ModuleLoader__?.load({ id, factory })
|
||||
@@ -65,7 +61,7 @@ function bench(
|
||||
}
|
||||
|
||||
describe('lazy CJS arrival', () => {
|
||||
it('prefetch fetches and executes but does not run the factory', async () => {
|
||||
it('prefetch loads and registers but does not run the factory', async () => {
|
||||
const ran: string[] = []
|
||||
const b = bench([row('a')], { a: () => { ran.push('a'); return {} } })
|
||||
await b.loader.prefetch('a')
|
||||
@@ -85,7 +81,7 @@ describe('lazy CJS arrival', () => {
|
||||
expect(b.loader.loadCache.get('a')?.id).toBe('a')
|
||||
})
|
||||
|
||||
it('import without prefetch fetches, executes, and materializes in one call', async () => {
|
||||
it('import without prefetch loads, registers, and materializes in one call', async () => {
|
||||
const b = bench([row('a')], { a: () => ({ marker: 'direct' }) })
|
||||
const surface = await b.loader.import('a', '', {})
|
||||
expect((surface as { marker: string }).marker).toBe('direct')
|
||||
@@ -228,7 +224,7 @@ describe('failure modes', () => {
|
||||
})
|
||||
|
||||
describe('HMR reset', () => {
|
||||
it('invalidate drops the factory and record so the module refetches and re-registers', async () => {
|
||||
it('invalidate drops the factory and record so the module reloads and re-registers', async () => {
|
||||
let generation = 0
|
||||
const b = bench([row('a')], { a: () => ({ generation: ++generation }) })
|
||||
const first = await b.loader.import('a', '', {})
|
||||
@@ -275,27 +271,35 @@ describe('style claiming', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('default transport seams', () => {
|
||||
it('fetches same-origin and executes through an inline script tag', async () => {
|
||||
// In a browser the loader's globalThis IS the page window; vitest's jsdom
|
||||
// evaluates <script> in a separate realm that shares only the document,
|
||||
// so the fixture bundle restores the sink from a document bridge before
|
||||
// using the normal calling convention.
|
||||
const code = 'window.__ModuleLoader__ = document.__realmBridge;\n'
|
||||
+ 'window.__ModuleLoader__.load({ id: "dee", factory: function () { return { marker: "via-script" } } })'
|
||||
vi.stubGlobal('fetch', async () => ({ ok: true, text: async () => code }))
|
||||
describe('default transport seam', () => {
|
||||
it('loads through an external classic script and removes the settled node', async () => {
|
||||
const append = vi.spyOn(document.head, 'append').mockImplementation((...nodes) => {
|
||||
const script = nodes[0]
|
||||
if (!(script instanceof HTMLScriptElement)) throw new Error('expected script node')
|
||||
expect(script.async).toBe(true)
|
||||
expect(script.getAttribute('src')).toBe('/plugins/dee/client.js?rev=0')
|
||||
queueMicrotask(() => {
|
||||
win.__ModuleLoader__?.load({ id: 'dee', factory: () => ({ marker: 'via-script' }) })
|
||||
script.dispatchEvent(new Event('load'))
|
||||
})
|
||||
})
|
||||
const loader: ClientModuleLoader = new ClientModuleSystem({ modules: [row('dee')], staticModules: {} })
|
||||
;(document as unknown as Record<string, unknown>).__realmBridge = win.__ModuleLoader__
|
||||
const surface = await loader.import('dee', '', {})
|
||||
expect((surface as { marker: string }).marker).toBe('via-script')
|
||||
// The script node is removed right after its synchronous execution —
|
||||
// repeated HMR rebuilds must not accumulate dead script nodes.
|
||||
expect(append).toHaveBeenCalledOnce()
|
||||
expect([...document.querySelectorAll('script')]).toEqual([])
|
||||
})
|
||||
|
||||
it('a non-ok bundle response is loud with the status', async () => {
|
||||
vi.stubGlobal('fetch', async () => ({ ok: false, status: 404 }))
|
||||
it('a script load failure is loud and removes the node', async () => {
|
||||
vi.spyOn(document.head, 'append').mockImplementation((...nodes) => {
|
||||
const script = nodes[0]
|
||||
if (!(script instanceof HTMLScriptElement)) throw new Error('expected script node')
|
||||
queueMicrotask(() => { script.dispatchEvent(new Event('error')) })
|
||||
})
|
||||
const loader = new ClientModuleSystem({ modules: [row('dee')], staticModules: {} })
|
||||
await expect(loader.prefetch('dee')).rejects.toThrow('answered 404')
|
||||
await expect(loader.prefetch('dee')).rejects.toThrow(
|
||||
'bundle script /plugins/dee/client.js?rev=0 failed to load',
|
||||
)
|
||||
expect([...document.querySelectorAll('script')]).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
/** Node-half composition diagnostics for package metadata and built client bundles. */
|
||||
|
||||
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { HttpServerService } from '@deepseek-ai/dsh-host-webserver'
|
||||
import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { ClientModuleHostService } from '../src/index.ts'
|
||||
|
||||
let root: string | undefined
|
||||
@@ -33,8 +34,8 @@ function writePackage(packageName: string): string {
|
||||
return clientPath
|
||||
}
|
||||
|
||||
/** Construct the node-half service over the enabled fixture entries. */
|
||||
function construct(packageNames: string[]): ClientModuleHostService {
|
||||
/** Construct the node-half service and capture its plugin-bundle route. */
|
||||
function constructWithRoute(packageNames: string[]): { service: ClientModuleHostService; route: WebRoute } {
|
||||
const ctx = new Context()
|
||||
ctx.baseUrl = pathToFileURL(root!).href + '/'
|
||||
ctx.provide('loader', {
|
||||
@@ -44,13 +45,24 @@ function construct(packageNames: string[]): ClientModuleHostService {
|
||||
}
|
||||
},
|
||||
})
|
||||
let route: WebRoute | undefined
|
||||
const httpServer: Pick<HttpServerService, 'port' | 'register' | 'tapIndex'> = {
|
||||
port: 0,
|
||||
register: () => () => {},
|
||||
register: (candidate) => {
|
||||
if (candidate.path === '/plugins') route = candidate
|
||||
return () => {}
|
||||
},
|
||||
tapIndex: () => () => {},
|
||||
}
|
||||
ctx.provide('httpServer', httpServer as HttpServerService)
|
||||
return new ClientModuleHostService(ctx)
|
||||
const service = new ClientModuleHostService(ctx)
|
||||
if (route === undefined) throw new Error('client bundle route was not registered')
|
||||
return { service, route }
|
||||
}
|
||||
|
||||
/** Construct the node-half service over the enabled fixture entries. */
|
||||
function construct(packageNames: string[]): ClientModuleHostService {
|
||||
return constructWithRoute(packageNames).service
|
||||
}
|
||||
|
||||
describe('client bundle activation', () => {
|
||||
@@ -84,4 +96,40 @@ describe('client bundle activation', () => {
|
||||
expect(String(thrown)).toContain('EISDIR')
|
||||
expect(String(thrown)).not.toContain('pnpm run build')
|
||||
})
|
||||
|
||||
it('serves the source map beside a registered client bundle', async () => {
|
||||
const packageName = '@fixture/source-map'
|
||||
const clientPath = writePackage(packageName)
|
||||
mkdirSync(dirname(clientPath), { recursive: true })
|
||||
writeFileSync(clientPath, 'module.exports = {}\n')
|
||||
const map = '{"version":3,"sources":["src/client/index.tsx"]}\n'
|
||||
writeFileSync(`${clientPath}.map`, map)
|
||||
const { route } = constructWithRoute([packageName])
|
||||
let status = 0
|
||||
let headers: Record<string, string> | undefined
|
||||
let body = ''
|
||||
const response = {
|
||||
writeHead(nextStatus: number, nextHeaders?: Record<string, string>) {
|
||||
status = nextStatus
|
||||
headers = nextHeaders
|
||||
return response
|
||||
},
|
||||
end(chunk?: Uint8Array) {
|
||||
body = chunk === undefined ? '' : Buffer.from(chunk).toString('utf8')
|
||||
return response
|
||||
},
|
||||
} as unknown as ServerResponse
|
||||
|
||||
await route.handler({
|
||||
method: 'GET',
|
||||
url: `/plugins/${packageName}/client.js.map`,
|
||||
} as IncomingMessage, response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(headers).toEqual({
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
'cache-control': 'no-cache',
|
||||
})
|
||||
expect(body).toBe(map)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
} from '../contract/session-history.ts'
|
||||
import { createHistoryInspection } from '../sessions/history.ts'
|
||||
import { Notifier } from '../sessions/notifier.ts'
|
||||
import { PartialAccumulator } from '../sessions/partial.ts'
|
||||
import { isVisibleAssistantChunk, PartialAccumulator } from '../sessions/partial.ts'
|
||||
|
||||
const HISTORY_PAGE_MESSAGES = 50
|
||||
|
||||
@@ -431,11 +431,3 @@ export class SessionHistorySource implements SessionHistoryFace {
|
||||
return this.inspectionCache.value
|
||||
}
|
||||
}
|
||||
|
||||
function isVisibleAssistantChunk(type: string): boolean {
|
||||
return type === 'block-start'
|
||||
|| type === 'text-delta'
|
||||
|| type === 'reasoning-delta'
|
||||
|| type === 'tool-call-delta'
|
||||
|| type === 'block-end'
|
||||
}
|
||||
|
||||
@@ -331,6 +331,8 @@ export interface ConversationSnapshot {
|
||||
sessionId: SessionId
|
||||
/** Human transcript plus retry notices and interrupted-turn terminal nodes in event order. */
|
||||
nodes: readonly ConversationNode[]
|
||||
/** Exact in-window `turn/start` time and optional matching `turn/end` time. */
|
||||
turnTimings: ReadonlyMap<number, { readonly startTime: number; readonly endTime?: number }>
|
||||
/** In-window completed turn number -> its `turn/end` event seq. */
|
||||
turnEnds: ReadonlyMap<number, number>
|
||||
partial: PartialAssistant | null
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Notifier: subscription + microtask-batched notification primitive shared by Session and
|
||||
// SessionManager. Semantics: N markDirty calls collapse into one microtask flush;
|
||||
// Notifier: subscription + batched notification primitive shared by Session and
|
||||
// SessionManager. Semantics: N markDirty calls collapse into one microtask flush, while
|
||||
// N markFrameDirty calls collapse into one animation-frame flush;
|
||||
// the flush rebuilds the snapshot cache BEFORE notifying (useSyncExternalStore requires a stable
|
||||
// getSnapshot reference). With no listeners the rebuild is skipped and only the dirty bit is set
|
||||
// (keeps frame storms cheap); the next getSnapshot rebuilds lazily.
|
||||
@@ -9,12 +10,13 @@
|
||||
// swallow the notification — push subscribers (object-layer watchers) would
|
||||
// otherwise starve whenever any reader pulls first.
|
||||
|
||||
/** Subscription + microtask-batched notification primitive (shared by Session and SessionManager). */
|
||||
/** Subscription + batched notification primitive (shared by Session and SessionManager). */
|
||||
export class Notifier {
|
||||
private listeners = new Set<() => void>()
|
||||
private dirty = false
|
||||
private notifyPending = false
|
||||
private scheduled = false
|
||||
private scheduled: 'none' | 'microtask' | 'frame' = 'none'
|
||||
private scheduleGeneration = 0
|
||||
|
||||
/** @param rebuild - snapshot rebuild function injected by the owner (writes the owner's snapshotCache). */
|
||||
constructor(private readonly rebuild: () => void) {}
|
||||
@@ -35,19 +37,16 @@ export class Notifier {
|
||||
markDirty(): void {
|
||||
this.dirty = true
|
||||
this.notifyPending = true
|
||||
if (this.scheduled) return
|
||||
this.scheduled = true
|
||||
queueMicrotask(() => {
|
||||
this.scheduled = false
|
||||
if (!this.notifyPending) return
|
||||
if (this.listeners.size === 0) return // lazy: no subscribers; dirty (if still set) rebuilds on next getSnapshot
|
||||
this.notifyPending = false
|
||||
if (this.dirty) {
|
||||
this.dirty = false
|
||||
this.rebuild()
|
||||
}
|
||||
for (const listener of this.listeners) listener()
|
||||
})
|
||||
if (this.scheduled === 'microtask') return
|
||||
this.schedule('microtask')
|
||||
}
|
||||
|
||||
/** Stream-change entry: mark dirty and publish the cumulative state at most once per frame. */
|
||||
markFrameDirty(): void {
|
||||
this.dirty = true
|
||||
this.notifyPending = true
|
||||
if (this.scheduled !== 'none') return
|
||||
this.schedule(typeof globalThis.requestAnimationFrame === 'function' ? 'frame' : 'microtask')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,11 +56,8 @@ export class Notifier {
|
||||
notifyNow(): void {
|
||||
this.dirty = true
|
||||
this.notifyPending = true
|
||||
if (this.listeners.size === 0) return // lazy: same as markDirty, next getSnapshot rebuilds
|
||||
this.notifyPending = false
|
||||
this.dirty = false
|
||||
this.rebuild()
|
||||
for (const listener of this.listeners) listener()
|
||||
this.invalidateSchedule()
|
||||
this.flush()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,4 +69,35 @@ export class Notifier {
|
||||
this.dirty = false
|
||||
this.rebuild()
|
||||
}
|
||||
|
||||
private schedule(kind: 'microtask' | 'frame'): void {
|
||||
const generation = ++this.scheduleGeneration
|
||||
this.scheduled = kind
|
||||
const publish = () => {
|
||||
if (generation !== this.scheduleGeneration) return
|
||||
this.scheduled = 'none'
|
||||
this.flush()
|
||||
}
|
||||
if (kind === 'frame') {
|
||||
globalThis.requestAnimationFrame(publish)
|
||||
} else {
|
||||
queueMicrotask(publish)
|
||||
}
|
||||
}
|
||||
|
||||
private invalidateSchedule(): void {
|
||||
this.scheduleGeneration++
|
||||
this.scheduled = 'none'
|
||||
}
|
||||
|
||||
private flush(): void {
|
||||
if (!this.notifyPending) return
|
||||
if (this.listeners.size === 0) return // lazy: dirty (if still set) rebuilds on next getSnapshot
|
||||
this.notifyPending = false
|
||||
if (this.dirty) {
|
||||
this.dirty = false
|
||||
this.rebuild()
|
||||
}
|
||||
for (const listener of this.listeners) listener()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,19 @@ import type { StreamChunk } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { AssistantBlock, PartialAssistant } from './conversation.ts'
|
||||
import { toAssistantBlock } from './conversation.ts'
|
||||
|
||||
/**
|
||||
* Whether a stream chunk changes the partial assistant projection shown by the UI.
|
||||
* @param type - Stream chunk discriminant.
|
||||
* @returns Whether publishing the accumulated partial can change the visible snapshot.
|
||||
*/
|
||||
export function isVisibleAssistantChunk(type: string): boolean {
|
||||
return type === 'block-start'
|
||||
|| type === 'text-delta'
|
||||
|| type === 'reasoning-delta'
|
||||
|| type === 'tool-call-delta'
|
||||
|| type === 'block-end'
|
||||
}
|
||||
|
||||
/** assistant/chunk accumulator: folds StreamChunks into AssistantBlock[] with block-level immutability. */
|
||||
export class PartialAccumulator {
|
||||
// Sparse on purpose: block-start may arrive out of order, leaving holes until compaction.
|
||||
|
||||
@@ -21,7 +21,7 @@ import { PendingWait } from './pending.ts'
|
||||
import { TranscriptAdapter } from './transcript-adapter.ts'
|
||||
import { displayFailureMessage } from './failure-display.ts'
|
||||
import { Notifier } from './notifier.ts'
|
||||
import { PartialAccumulator } from './partial.ts'
|
||||
import { isVisibleAssistantChunk, PartialAccumulator } from './partial.ts'
|
||||
import { ProjectionValueStore } from './projection-store.ts'
|
||||
import type { ProjectionsBaseline } from './projection-store.ts'
|
||||
|
||||
@@ -113,6 +113,11 @@ export class Session implements SessionFace {
|
||||
private pendingCache: { rev: number; value: PendingInteraction[] } | null = null
|
||||
private derivedRev = 0
|
||||
private nodesCache: { projected: readonly ConversationNode[]; derivedRev: number; value: readonly ConversationNode[] } | null = null
|
||||
/** Exact turn timing retained from the raw window so presentation never
|
||||
* infers elapsed time from transcript content. */
|
||||
private turnTimings = new Map<number, { startTime: number; endTime?: number }>()
|
||||
private turnTimingsRev = 0
|
||||
private turnTimingsCache: { rev: number; value: ConversationSnapshot['turnTimings'] } | null = null
|
||||
/** Completed turn boundaries retained from the raw window so presentation
|
||||
* actions never infer a safe fork point from transcript content alone. */
|
||||
private turnEnds = new Map<number, number>()
|
||||
@@ -687,6 +692,10 @@ export class Session implements SessionFace {
|
||||
return
|
||||
}
|
||||
this.appendLive(event, view)
|
||||
if (event.type === 'assistant/chunk') {
|
||||
if (isVisibleAssistantChunk(event.data.chunk.type)) this.notifier.markFrameDirty()
|
||||
return
|
||||
}
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
@@ -795,6 +804,8 @@ export class Session implements SessionFace {
|
||||
}
|
||||
switch (event.type) {
|
||||
case 'turn/start': {
|
||||
this.turnTimings.set(event.data.turn, { startTime: event.time })
|
||||
this.turnTimingsRev++
|
||||
if (event.data.trigger.kind === 'retry') this.settleScheduledRetry('started')
|
||||
return
|
||||
}
|
||||
@@ -826,6 +837,11 @@ export class Session implements SessionFace {
|
||||
return
|
||||
}
|
||||
case 'turn/end': {
|
||||
const timing = this.turnTimings.get(event.data.turn)
|
||||
if (timing !== undefined) {
|
||||
this.turnTimings.set(event.data.turn, { ...timing, endTime: event.time })
|
||||
this.turnTimingsRev++
|
||||
}
|
||||
this.turnEnds.set(event.data.turn, event.seq)
|
||||
this.turnEndsRev++
|
||||
if (event.data.reason.kind === 'aborted' || event.data.reason.kind === 'disposed') {
|
||||
@@ -918,6 +934,8 @@ export class Session implements SessionFace {
|
||||
this.callsRev++
|
||||
this.derivedNodes = []
|
||||
this.derivedRev++
|
||||
this.turnTimings = new Map()
|
||||
this.turnTimingsRev++
|
||||
this.turnEnds = new Map()
|
||||
this.turnEndsRev++
|
||||
this.codeDispatches = new Map()
|
||||
@@ -951,6 +969,9 @@ export class Session implements SessionFace {
|
||||
if (this.callsCache === null || this.callsCache.rev !== this.callsRev) {
|
||||
this.callsCache = { rev: this.callsRev, value: [...this.openCalls.values()] }
|
||||
}
|
||||
if (this.turnTimingsCache === null || this.turnTimingsCache.rev !== this.turnTimingsRev) {
|
||||
this.turnTimingsCache = { rev: this.turnTimingsRev, value: new Map(this.turnTimings) }
|
||||
}
|
||||
if (this.turnEndsCache === null || this.turnEndsCache.rev !== this.turnEndsRev) {
|
||||
this.turnEndsCache = { rev: this.turnEndsRev, value: new Map(this.turnEnds) }
|
||||
}
|
||||
@@ -967,6 +988,7 @@ export class Session implements SessionFace {
|
||||
return {
|
||||
sessionId: this.sessionId,
|
||||
nodes,
|
||||
turnTimings: this.turnTimingsCache.value,
|
||||
turnEnds: this.turnEndsCache.value,
|
||||
partial,
|
||||
runningCalls: this.callsCache.value,
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
/**
|
||||
* Notifier: microtask batching, rebuild-before-notify ordering, no-listener
|
||||
* laziness, synchronous notifyNow, and unsubscribe.
|
||||
* Notifier: microtask/frame batching, rebuild-before-notify ordering,
|
||||
* no-listener laziness, synchronous notifyNow, and unsubscribe.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Notifier } from '../src/client/sessions/notifier.ts'
|
||||
|
||||
const microtask = (): Promise<void> => new Promise((resolve) => { queueMicrotask(resolve) })
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('Notifier', () => {
|
||||
it('collapses N markDirty calls into one flush, rebuilding before notifying', async () => {
|
||||
const order: string[] = []
|
||||
@@ -60,6 +64,57 @@ describe('Notifier', () => {
|
||||
expect(rebuilds).toBe(1)
|
||||
})
|
||||
|
||||
it('collapses frame-dirty changes into one cumulative frame publication', () => {
|
||||
const frames: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
frames.push(callback)
|
||||
return frames.length
|
||||
})
|
||||
const order: string[] = []
|
||||
const notifier = new Notifier(() => order.push('rebuild'))
|
||||
notifier.subscribe(() => order.push('notify'))
|
||||
|
||||
notifier.markFrameDirty()
|
||||
notifier.markFrameDirty()
|
||||
notifier.markFrameDirty()
|
||||
|
||||
expect(order).toEqual([])
|
||||
expect(frames).toHaveLength(1)
|
||||
frames.shift()!(0)
|
||||
expect(order).toEqual(['rebuild', 'notify'])
|
||||
})
|
||||
|
||||
it('lets a structural microtask publication supersede a pending frame', async () => {
|
||||
const frames: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
frames.push(callback)
|
||||
return frames.length
|
||||
})
|
||||
let notifications = 0
|
||||
const notifier = new Notifier(() => undefined)
|
||||
notifier.subscribe(() => { notifications++ })
|
||||
|
||||
notifier.markFrameDirty()
|
||||
notifier.markDirty()
|
||||
await microtask()
|
||||
expect(notifications).toBe(1)
|
||||
|
||||
frames.shift()!(0)
|
||||
expect(notifications).toBe(1)
|
||||
})
|
||||
|
||||
it('falls back to microtask batching when animation frames are unavailable', async () => {
|
||||
let notifications = 0
|
||||
const notifier = new Notifier(() => undefined)
|
||||
notifier.subscribe(() => { notifications++ })
|
||||
|
||||
notifier.markFrameDirty()
|
||||
notifier.markFrameDirty()
|
||||
expect(notifications).toBe(0)
|
||||
await microtask()
|
||||
expect(notifications).toBe(1)
|
||||
})
|
||||
|
||||
it('unsubscribed listeners stop receiving notifications', async () => {
|
||||
let calls = 0
|
||||
const notifier = new Notifier(() => undefined)
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* enough.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
@@ -20,6 +20,10 @@ const at = (seq: number, e: Record<string, unknown>): SessionEvent =>
|
||||
const SID = 'fk-s1' as SessionId
|
||||
const PARENT = 'fk-parent' as SessionId
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session: Session } {
|
||||
return { api, session: new Session(SID, api) }
|
||||
}
|
||||
@@ -42,6 +46,11 @@ describe('open', () => {
|
||||
expect(snapshot.openState).toBe('open')
|
||||
expect(snapshot.hasMore).toBe(true)
|
||||
expect(snapshot.nodes.map(n => n.kind)).toEqual(['user', 'assistant'])
|
||||
expect(snapshot.turnTimings.get(3)).toEqual({
|
||||
startTime: 1_700_000_000_010,
|
||||
endTime: 1_700_000_000_015,
|
||||
})
|
||||
expect(snapshot.turnEnds.get(3)).toBe(15)
|
||||
})
|
||||
|
||||
it('is idempotent: concurrent opens share one history call, reopening when open is a no-op', async () => {
|
||||
@@ -163,6 +172,40 @@ describe('live event path', () => {
|
||||
expect((last as { interrupted?: true }).interrupted).toBeUndefined()
|
||||
})
|
||||
|
||||
it('publishes cumulative chunks once per frame and lets finalization supersede the pending frame', async () => {
|
||||
const frames: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
frames.push(callback)
|
||||
return frames.length
|
||||
})
|
||||
const { session } = await opened()
|
||||
const published: Array<string | null> = []
|
||||
session.subscribe(() => {
|
||||
const block = session.getSnapshot().partial?.blocks[0]
|
||||
published.push(block?.kind === 'text' ? block.text : null)
|
||||
})
|
||||
const feed = (event: SessionEvent) => {
|
||||
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event })
|
||||
}
|
||||
|
||||
feed(ev.chunkStart(6, 1))
|
||||
feed(ev.chunkText(7, 1, '累'))
|
||||
feed(ev.chunkText(8, 1, '计'))
|
||||
expect(published).toEqual([])
|
||||
expect(frames).toHaveLength(1)
|
||||
|
||||
frames.shift()!(0)
|
||||
expect(published).toEqual(['累计'])
|
||||
|
||||
feed(ev.chunkText(9, 1, '完成'))
|
||||
feed(ev.assistant(10, 1, '累计完成'))
|
||||
await Promise.resolve()
|
||||
expect(published).toEqual(['累计', null])
|
||||
|
||||
frames.shift()!(0)
|
||||
expect(published).toEqual(['累计', null])
|
||||
})
|
||||
|
||||
it('retracts the failed step partial on retry and keeps a replayable notice before the recovered response', async () => {
|
||||
const { session } = await opened()
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
@@ -215,11 +258,22 @@ describe('live event path', () => {
|
||||
expect(snapshot.nodes.some(node => node.kind === 'turn-error')).toBe(false)
|
||||
expect(snapshot.nodes.at(-2)).toMatchObject({ kind: 'model-retry', retryState: 'started' })
|
||||
expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'assistant', blocks: [{ kind: 'text', text: '完整回复' }] })
|
||||
const retryStart = retryTurn.find(event =>
|
||||
event.type === 'turn/start' && event.data.trigger.kind === 'retry')
|
||||
if (retryStart?.type !== 'turn/start') throw new Error('test fixture must include a retry turn/start')
|
||||
const retryEnd = retryTurn.find(event =>
|
||||
event.type === 'turn/end' && event.data.turn === retryStart.data.turn)
|
||||
if (retryEnd?.type !== 'turn/end') throw new Error('test fixture must complete the retry turn')
|
||||
expect(snapshot.turnTimings.get(retryStart.data.turn)).toEqual({
|
||||
startTime: retryStart.time,
|
||||
endTime: retryEnd.time,
|
||||
})
|
||||
|
||||
const replay = makeSession()
|
||||
replay.api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...retryTurn])
|
||||
await replay.session.open()
|
||||
expect(replay.session.getSnapshot().nodes).toEqual(snapshot.nodes)
|
||||
expect(replay.session.getSnapshot().turnTimings).toEqual(snapshot.turnTimings)
|
||||
expect(replay.session.getSnapshot().partial).toBeNull()
|
||||
})
|
||||
|
||||
@@ -1216,6 +1270,7 @@ describe('reference stability (the memo contract)', () => {
|
||||
expect(after).not.toBe(before)
|
||||
expect(after.runningCalls).toBe(before.runningCalls)
|
||||
expect(after.pending).toBe(before.pending)
|
||||
expect(after.turnTimings).toBe(before.turnTimings)
|
||||
expect(after.turnEnds).toBe(before.turnEnds)
|
||||
// And a mutation on the tracked domain swaps that array.
|
||||
feed(ev.toolResult(11, 1, 'c1', 'ECHO'))
|
||||
|
||||
@@ -46,6 +46,7 @@ export function conversationSnapshot(sessionId: SessionId): ConversationSnapshot
|
||||
return {
|
||||
sessionId,
|
||||
nodes: [],
|
||||
turnTimings: new Map(),
|
||||
turnEnds: new Map(),
|
||||
partial: null,
|
||||
runningCalls: [],
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
* The virtual loader registers each real stylesheet as a watch dependency.
|
||||
*/
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { basename, dirname, resolve as resolvePath } from 'node:path'
|
||||
import { basename, dirname, relative, resolve as resolvePath, sep } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { UserConfig } from 'tsdown'
|
||||
import { transform } from 'lightningcss'
|
||||
import { PLATFORM_MODULES } from './web/src/platform.ts'
|
||||
@@ -45,6 +46,16 @@ const RUNTIME_STORE_EXEMPTION = '@deepseek-ai/dsh-client-runtime/client'
|
||||
/** Externals resolved from the loader module table: the platform seed entries plus the documented runtime exemption. */
|
||||
export const CLIENT_EXTERNALS: readonly string[] = [...PLATFORM_MODULES, RUNTIME_STORE_EXEMPTION]
|
||||
|
||||
const REPOSITORY_ROOT = fileURLToPath(new URL('../..', import.meta.url))
|
||||
|
||||
/** Rebase a physical lib-relative source onto the browser's repository-shaped URL tree. */
|
||||
function browserSourcePath(source: string, sourcemapPath: string): string {
|
||||
if (!source.startsWith('.')) return source
|
||||
const physicalSource = resolvePath(dirname(sourcemapPath), source)
|
||||
const repositoryPath = relative(REPOSITORY_ROOT, physicalSource).split(sep).join('/')
|
||||
return repositoryPath.startsWith('packages/') ? `../../../${repositoryPath}` : source
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the tsdown config for one UI plugin package: the node-half lib build
|
||||
* plus the browser client bundle. A package-level tsdown.config.ts REPLACES
|
||||
@@ -78,6 +89,9 @@ export function clientBundle(id: string, libEntry: readonly string[]): UserConfi
|
||||
platform: 'browser',
|
||||
// Types ship from lib/types (tsc); dts here would wrap the banner/footer into .d.cts and break parsing.
|
||||
dts: false,
|
||||
// Plugin code is fetched outside Vite's module graph, so its own bundle
|
||||
// must carry the TS/TSX mapping consumed by browser profiling tools.
|
||||
sourcemap: true,
|
||||
clean: false,
|
||||
external: [...CLIENT_EXTERNALS],
|
||||
// Browser bundles inline node-idiom deps (zustand/immer read
|
||||
@@ -156,6 +170,11 @@ export function clientBundle(id: string, libEntry: readonly string[]): UserConfi
|
||||
}],
|
||||
outputOptions: {
|
||||
entryFileNames: 'client.js',
|
||||
// The map is served from /plugins/<scoped-package>/client.js.map. The
|
||||
// browser resolves its local sources back into the repository-shaped
|
||||
// /packages/<group>/<package>/src tree; sourcesContent keeps them usable
|
||||
// without exposing that tree as an HTTP route.
|
||||
sourcemapPathTransform: browserSourcePath,
|
||||
banner: `window.__ModuleLoader__.load({ id: ${JSON.stringify(id)}, factory: (require) => {`,
|
||||
footer: `return module.exports; } });`,
|
||||
intro: 'var module = { exports: {} }; var exports = module.exports;',
|
||||
|
||||
@@ -12,7 +12,10 @@
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 220px;
|
||||
min-width: min(220px, 100%);
|
||||
/* Never wider than the composer card (the overlay anchor's width): long
|
||||
rows truncate instead of pushing the card past the composer's edge. */
|
||||
max-width: 100%;
|
||||
/* Height cap: the 320px design maximum, clamped at runtime to the space
|
||||
* above the composer (inline max-height set in PopupSelectView.tsx). */
|
||||
max-height: 320px;
|
||||
@@ -51,7 +54,8 @@
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: 1;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -61,6 +65,8 @@
|
||||
font-size: 12px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.check {
|
||||
|
||||
@@ -27,6 +27,9 @@ export interface AssistantMarkdownProps {
|
||||
/** Unix epoch ms for the IconActions clock; omitted while streaming or when
|
||||
* the parent withholds chrome (mid-turn content assistants). */
|
||||
time?: number | undefined
|
||||
/** Turn wall time in ms for the IconActions run-time label; omitted when the
|
||||
* turn's triggering input is outside the loaded window. */
|
||||
runMs?: number | undefined
|
||||
/** Event sequence used as the fork boundary; omitted while streaming. */
|
||||
seq?: number | undefined
|
||||
/** Fork the session through this finalized message's completed turn when eligible. */
|
||||
@@ -79,7 +82,7 @@ function ThinkRow({ text, running, t }: { text: string; running: boolean; t: Ass
|
||||
}
|
||||
|
||||
export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||
blocks, streaming, interrupted, time, seq, onFork, forkUnavailable, t,
|
||||
blocks, streaming, interrupted, time, runMs, seq, onFork, forkUnavailable, t,
|
||||
}: AssistantMarkdownProps) {
|
||||
// Stable per locale revision (t identity changes on switch): a fresh object
|
||||
// per render would rebuild MarkdownText's component table every chunk.
|
||||
@@ -95,7 +98,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||
// Footer only under settled content text; Think-only / streaming omit it.
|
||||
const showActions = !streaming && time !== undefined && hasContentText(blocks)
|
||||
return (
|
||||
<div className={css.root} data-streaming={streaming || undefined}>
|
||||
<div className={css.root} data-streaming={streaming || undefined} data-time-hover-root>
|
||||
<div className={css.body}>
|
||||
{blocks.map((block, i) => {
|
||||
switch (block.kind) {
|
||||
@@ -121,6 +124,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||
<MessageIconActions
|
||||
text={copyText(blocks)}
|
||||
time={time}
|
||||
runMs={runMs}
|
||||
clock="end"
|
||||
onBranch={onFork === undefined || seq === undefined ? undefined : () => { onFork(seq) }}
|
||||
branchUnavailable={forkUnavailable}
|
||||
|
||||
@@ -16,7 +16,9 @@
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 16px 24px;
|
||||
/* 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);
|
||||
}
|
||||
|
||||
:global([data-conversation-scroll]) .root {
|
||||
@@ -31,10 +33,11 @@
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
/* Message column: 736px fixed width, centered on the same axis as the
|
||||
input box; the scroller itself stays full-bleed. */
|
||||
/* Message column: shared chat width (ConversationRoot --dsh-chat-content-width),
|
||||
centered on the same axis as the input box (which caps at chat + 16px); the
|
||||
scroller itself stays full-bleed. */
|
||||
.column {
|
||||
max-width: 736px;
|
||||
max-width: var(--dsh-chat-content-width);
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
@@ -99,6 +102,15 @@
|
||||
animation: dsh-turn-status-shimmer 1.8s linear infinite;
|
||||
}
|
||||
|
||||
.turnStatusClock {
|
||||
margin-left: 8px;
|
||||
font: var(--dsw-font-xs-13);
|
||||
font-weight: 400;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
-webkit-text-fill-color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
@keyframes dsh-turn-status-shimmer {
|
||||
to {
|
||||
background-position: 0 0;
|
||||
@@ -157,7 +169,7 @@
|
||||
height: 0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-right: max(0px, calc((100% - 736px) / 2));
|
||||
padding-right: max(0px, calc((100% - var(--dsh-chat-content-width)) / 2));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -30,11 +30,12 @@ import type {
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import { assistantActionsSeqs, deriveChatFlow, messageBranchSeqs, type ChatFlowItem } from './chat-flow.ts'
|
||||
import { assistantActionsSeqs, deriveChatFlow, messageBranchSeqs, runningTurnStartTime, type ChatFlowItem } from './chat-flow.ts'
|
||||
import { AssistantMarkdown } from './AssistantMarkdown.tsx'
|
||||
import { GenericCommandCard } from './GenericCommandCard.tsx'
|
||||
import { GenericToolCard } from './GenericToolCard.tsx'
|
||||
import { MessageItem, PendingSteeringBubble } from './MessageItem.tsx'
|
||||
import { formatRunDuration } from './message-chrome.ts'
|
||||
import css from './ChatView.module.css'
|
||||
|
||||
const FOLLOW_THRESHOLD = 24
|
||||
@@ -282,10 +283,37 @@ const CommandRow = memo(function CommandRow({ renderSlot, node, t }: {
|
||||
})
|
||||
|
||||
/** Turn-level model activity label retained across first-token, tool, and streaming phases. */
|
||||
function TurnStatus() {
|
||||
function TurnStatus({ startTime, t }: {
|
||||
/** The running turn's logged `turn/start` time; null falls back to mount
|
||||
* time when that boundary is outside the window. */
|
||||
startTime: number | null
|
||||
/** The owning view's locale seat. */
|
||||
t: ChatViewSlotProps['t']
|
||||
}) {
|
||||
const [mountedAt] = useState(() => Date.now())
|
||||
// Anchored to turn/start so a mid-turn reload keeps the real
|
||||
// elapsed time and the final footer's Ran-for label matches this clock.
|
||||
const anchor = startTime ?? mountedAt
|
||||
const [elapsedMs, setElapsedMs] = useState(() => Math.max(0, Date.now() - anchor))
|
||||
useEffect(() => {
|
||||
const tick = (): void => {
|
||||
setElapsedMs(Math.max(0, Date.now() - anchor))
|
||||
}
|
||||
tick()
|
||||
const id = setInterval(tick, 1000)
|
||||
return () => { clearInterval(id) }
|
||||
}, [anchor])
|
||||
// Short turns keep the plain label; the clock only appears once the turn
|
||||
// has clearly been running for a while.
|
||||
const showClock = elapsedMs >= 15_000
|
||||
return (
|
||||
<div className={css.turnStatus} role="status" aria-live="polite">
|
||||
Deep diving...
|
||||
{showClock && (
|
||||
<span className={css.turnStatusClock} aria-hidden>
|
||||
{formatRunDuration(elapsedMs, t)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -309,6 +337,7 @@ export function ChatView({
|
||||
useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt, t,
|
||||
}: ChatViewSlotProps) {
|
||||
const nodes = useSession(s => s.nodes)
|
||||
const turnTimings = useSession(s => s.turnTimings)
|
||||
const turnEnds = useSession(s => s.turnEnds)
|
||||
const inbox = useSession(s => s.queue)
|
||||
// Workspace root off the session list row: path summaries display relative to it.
|
||||
@@ -332,6 +361,7 @@ export function ChatView({
|
||||
// text (before tools) omits `time` so AssistantMarkdown stays chrome-free.
|
||||
const actionSeqs = useMemo(() => assistantActionsSeqs(nodes), [nodes])
|
||||
const branchSeqs = useMemo(() => messageBranchSeqs(nodes, turnEnds), [nodes, turnEnds])
|
||||
const runningTurnStart = useMemo(() => runningTurnStartTime(turnTimings), [turnTimings])
|
||||
|
||||
const listRef = useRef<HTMLDivElement | null>(null)
|
||||
const columnRef = useRef<HTMLDivElement | null>(null)
|
||||
@@ -568,12 +598,16 @@ export function ChatView({
|
||||
}
|
||||
const node: ConversationNode = item.node
|
||||
if (node.kind === 'assistant') {
|
||||
const timing = actionSeqs.has(node.seq) ? turnTimings.get(node.turn) : undefined
|
||||
return (
|
||||
<AssistantMarkdown
|
||||
blocks={node.blocks}
|
||||
streaming={false}
|
||||
interrupted={node.interrupted}
|
||||
time={actionSeqs.has(node.seq) ? node.time : undefined}
|
||||
runMs={timing?.endTime === undefined
|
||||
? undefined
|
||||
: Math.max(0, timing.endTime - timing.startTime)}
|
||||
seq={node.seq}
|
||||
onFork={forkAt}
|
||||
forkUnavailable={!branchSeqs.has(node.seq)}
|
||||
@@ -651,7 +685,7 @@ export function ChatView({
|
||||
double-render the same wait. */}
|
||||
{/* Turn-level loading signal: rides the whole running turn (first-token
|
||||
wait, tool execution, streaming) so it never flickers per step. */}
|
||||
{running && <TurnStatus />}
|
||||
{running && <TurnStatus startTime={runningTurnStart} t={t} />}
|
||||
{pendingSteering.map(item => (
|
||||
<PendingSteeringBubble key={item.id} content={item.content} t={t} />
|
||||
))}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/* Shared message IconActions row (user + assistant). Parent modules own
|
||||
layout offsets via the composed className. Always visible when mounted. */
|
||||
layout offsets via the composed className. Icons stay visible when mounted;
|
||||
the time label is hover-revealed inside a data-time-hover-root scope. */
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
@@ -25,6 +26,26 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Separator between the clock and the run-time label (time · Ran for 15s). */
|
||||
.runTimeDot {
|
||||
margin: 0 10px;
|
||||
}
|
||||
|
||||
/* Message containers opt in with data-time-hover-root: the time label fades
|
||||
in on message hover (or keyboard focus within). Opacity keeps the layout
|
||||
stable, and devices without hover keep the label always visible. */
|
||||
@media (hover: hover) {
|
||||
[data-time-hover-root] :is(.timeStart, .timeEnd) {
|
||||
opacity: 0;
|
||||
transition: opacity 80ms ease;
|
||||
}
|
||||
|
||||
[data-time-hover-root]:hover :is(.timeStart, .timeEnd),
|
||||
[data-time-hover-root]:focus-within :is(.timeStart, .timeEnd) {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
// Shared IconActions chrome for user, steering, and assistant messages: copy
|
||||
// live, optional branch wiring, and an optional date-aware clock.
|
||||
|
||||
import { useCallback, useId } from 'react'
|
||||
import { useCallback, useEffect, useId, useRef, useState } from 'react'
|
||||
import {
|
||||
IconBranchOutline16, IconCopyOutline16, Tooltip,
|
||||
IconBranchOutline16, IconCheckOutline16, IconCopyOutline16, Tooltip, writeClipboard,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import { formatMessageClock, writeClipboard } from './message-chrome.ts'
|
||||
import { formatMessageClock, formatRunDuration } from './message-chrome.ts'
|
||||
import { useCalendarDay } from './use-calendar-day.ts'
|
||||
import css from './MessageIconActions.module.css'
|
||||
|
||||
@@ -15,6 +15,8 @@ export interface MessageIconActionsProps {
|
||||
text: string
|
||||
/** Unix epoch ms for the clock label; omitted for transient messages. */
|
||||
time?: number | undefined
|
||||
/** Turn wall time in ms, appended to the clock as `· Ran for 15s`; omitted when the turn's start is unknown. */
|
||||
runMs?: number | undefined
|
||||
/** Clock before icons (user) or after (assistant). */
|
||||
clock: 'start' | 'end'
|
||||
/** Fork the session at this message; omission hides the branch action. */
|
||||
@@ -35,24 +37,53 @@ export interface MessageIconActionsProps {
|
||||
* @returns The actions row element.
|
||||
*/
|
||||
export function MessageIconActions({
|
||||
text, time, clock, onBranch, branchUnavailable = false, showBranch = true, className, t,
|
||||
text, time, runMs, clock, onBranch, branchUnavailable = false, showBranch = true, className, t,
|
||||
}: MessageIconActionsProps) {
|
||||
const day = useCalendarDay()
|
||||
const reasonId = useId()
|
||||
// Same success chrome as CodeBlock: a short check swap after the write,
|
||||
// gated so re-clicks during the window neither re-copy nor stack timers.
|
||||
const [copied, setCopied] = useState(false)
|
||||
const copyPending = useRef(false)
|
||||
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const copyEpoch = useRef(0)
|
||||
useEffect(() => () => {
|
||||
copyEpoch.current += 1
|
||||
copyPending.current = false
|
||||
if (copyTimer.current !== null) clearTimeout(copyTimer.current)
|
||||
}, [])
|
||||
const onCopy = useCallback(() => {
|
||||
void writeClipboard(text)
|
||||
}, [text])
|
||||
if (copied || copyPending.current) return
|
||||
const epoch = copyEpoch.current
|
||||
copyPending.current = true
|
||||
void writeClipboard(text).then((ok) => {
|
||||
if (epoch !== copyEpoch.current) return
|
||||
copyPending.current = false
|
||||
if (!ok) return
|
||||
setCopied(true)
|
||||
copyTimer.current = window.setTimeout(() => {
|
||||
copyTimer.current = null
|
||||
setCopied(false)
|
||||
}, 1000)
|
||||
})
|
||||
}, [copied, text])
|
||||
const clockEl = time === undefined ? null : (
|
||||
<span className={clock === 'start' ? css.timeStart : css.timeEnd}>
|
||||
{formatMessageClock(time, t, day)}
|
||||
{runMs !== undefined && (
|
||||
<>
|
||||
<span className={css.runTimeDot} aria-hidden>·</span>
|
||||
{t('message.ranFor', { duration: formatRunDuration(runMs, t) })}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
return (
|
||||
<div className={className === undefined ? css.actions : `${css.actions} ${className}`}>
|
||||
{clock === 'start' ? clockEl : null}
|
||||
<Tooltip label={t('copy')} side="bottom">
|
||||
<button type="button" className={css.action} aria-label={t('copy')} onClick={onCopy}>
|
||||
<IconCopyOutline16 />
|
||||
<Tooltip label={copied ? t('copied') : t('copy')} side="bottom">
|
||||
<button type="button" className={css.action} aria-label={copied ? t('copied') : t('copy')} onClick={onCopy}>
|
||||
{copied ? <IconCheckOutline16 /> : <IconCopyOutline16 />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{showBranch && onBranch !== undefined && (
|
||||
|
||||
@@ -183,7 +183,7 @@ function UserStyleBubble({
|
||||
const { text, rest } = contentText(content)
|
||||
const truncated = (total: number): string => t('json.truncated', { total })
|
||||
return (
|
||||
<div className={css.userRow} data-pending-steering={pending || undefined}>
|
||||
<div className={css.userRow} data-pending-steering={pending || undefined} data-time-hover-root>
|
||||
<div className={css.bubble}>
|
||||
{projectUserText(text)}
|
||||
{rest.map((block, i) => <JsonBlock key={i} label={t('message.extraBlock')} payload={block} truncatedLabel={truncated} />)}
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
/* Session stats row: 12/20 tertiary text under the flow, aligned to the
|
||||
736px message column axis. */
|
||||
shared message column axis (--dsh-chat-content-width). */
|
||||
|
||||
.root {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
max-width: 736px;
|
||||
/* Block, not flex: text-overflow only elides a block's inline content, so
|
||||
an overlong line ends in … instead of a mid-glyph clip. */
|
||||
display: block;
|
||||
text-align: center;
|
||||
max-width: var(--dsh-chat-content-width);
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
box-sizing: border-box;
|
||||
padding: 4px 24px 0px;
|
||||
padding: 4px calc(var(--dsh-composer-side-clearance) + 16px) 0px;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sep {
|
||||
color: var(--dsw-alias-separator-primary);
|
||||
margin: 0 10px; /* carries the former flex gap */
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ export const StatsLine = memo(function StatsLine({ useSession, useProjection }:
|
||||
<div className={css.root}>
|
||||
{groups.map((group, i) => (
|
||||
<Fragment key={group}>
|
||||
{i > 0 && <span className={css.sep} aria-hidden>|</span>}
|
||||
{i > 0 && <><span className={css.sep} aria-hidden>|</span>{' '}</>}
|
||||
<span>{group}</span>
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
// independent); an error row's collapsed summary is the failure's first line in
|
||||
// the error color.
|
||||
|
||||
import { useLayoutEffect, useRef, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
|
||||
import { useEffect, useRef, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
CodeBlock, DiffBlock, ReadBlock, SearchBlock, StateDot, TerminalBlock, WebBlock,
|
||||
@@ -33,6 +33,7 @@ import { CHAT_SEARCH_MAX_LINES, type SearchCardModel } from '../contract/search-
|
||||
import { terminalBlockLabels, type TerminalCardModel } from '../contract/terminal-card-model.ts'
|
||||
import type { ToolRowState, ToolRowVariant } from '../contract/tool-call-model.ts'
|
||||
import { DisclosureRow } from './DisclosureRow.tsx'
|
||||
import { useThrottledVisualUpdate } from './use-throttled-visual-update.ts'
|
||||
import css from './ToolRow.module.css'
|
||||
|
||||
export interface ToolRowProps {
|
||||
@@ -176,13 +177,17 @@ export function ToolRow({
|
||||
const fileLink = filePath !== undefined && onOpenFile !== undefined && failureLine === null
|
||||
const isThink = variant === 'think'
|
||||
const followSummaryEnd = isThink && state === 'running' && !open
|
||||
useLayoutEffect(() => {
|
||||
const scheduleSummaryScroll = useThrottledVisualUpdate(() => {
|
||||
const summaryElement = summaryRef.current
|
||||
if (summaryElement === null) return
|
||||
summaryElement.scrollLeft = followSummaryEnd
|
||||
? summaryElement.scrollWidth - summaryElement.clientWidth
|
||||
: 0
|
||||
}, [followSummaryEnd, summaryText])
|
||||
})
|
||||
useEffect(() => {
|
||||
if (!isThink) return
|
||||
scheduleSummaryScroll()
|
||||
}, [followSummaryEnd, isThink, scheduleSummaryScroll, summaryText])
|
||||
const toggleExpand = () => {
|
||||
setExpanded(v => !v)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* flow share their gates.
|
||||
*/
|
||||
import type {
|
||||
AssistantBlock, ConversationNode, ToolResultNode,
|
||||
AssistantBlock, ConversationNode, ConversationSnapshot, ToolResultNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** One renderable flow item; key is the React key and the parent's identity unit. */
|
||||
@@ -47,6 +47,21 @@ export function assistantActionsSeqs(nodes: readonly ConversationNode[]): Readon
|
||||
return new Set(lastByTurn.values())
|
||||
}
|
||||
|
||||
/**
|
||||
* Exact start time of the latest in-window turn without a matching end time.
|
||||
* @param turnTimings - In-window turn timings in event order.
|
||||
* @returns Unix epoch ms, or null when the running turn started outside the window.
|
||||
*/
|
||||
export function runningTurnStartTime(
|
||||
turnTimings: ConversationSnapshot['turnTimings'],
|
||||
): number | null {
|
||||
let latest: number | null = null
|
||||
for (const timing of turnTimings.values()) {
|
||||
if (timing.endTime === undefined) latest = timing.startTime
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
/**
|
||||
* Seq set of message rows that may fork: the last transcript node of a
|
||||
* completed turn, when that node owns message chrome. A later tool, reasoning,
|
||||
|
||||
@@ -1,50 +1,12 @@
|
||||
// Shared chrome helpers for user/assistant IconActions rows: clipboard write
|
||||
// and the compact date+clock label from a session-event epoch.
|
||||
// Shared time-label helpers for user/assistant IconActions rows.
|
||||
|
||||
import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
|
||||
/** The date-template share of the conversation dictionary the clock consumes. */
|
||||
export type ClockTranslate = Translate<'clock.md' | 'clock.ymd'>
|
||||
|
||||
/**
|
||||
* Best-effort clipboard write; rejections stay swallowed (no success chrome).
|
||||
* @param text - Plain text to place on the clipboard.
|
||||
*/
|
||||
export async function writeClipboard(text: string): Promise<void> {
|
||||
// lib.dom types clipboard non-optional, but insecure contexts omit it —
|
||||
// that runtime gap is exactly what this guard detects.
|
||||
/* oxlint-disable-next-line typescript/no-unnecessary-condition */
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
} catch {
|
||||
// Denied permissions / iframe policy.
|
||||
}
|
||||
return
|
||||
}
|
||||
// execCommand('copy') is the only clipboard fallback where the async API
|
||||
// is missing (insecure contexts); deprecated but deliberately retained.
|
||||
/* oxlint-disable typescript/no-deprecated */
|
||||
const exec = typeof document.execCommand === 'function'
|
||||
? document.execCommand.bind(document)
|
||||
: undefined
|
||||
if (exec === undefined) return
|
||||
const el = document.createElement('textarea')
|
||||
el.value = text
|
||||
el.setAttribute('readonly', '')
|
||||
el.style.position = 'fixed'
|
||||
el.style.left = '-9999px'
|
||||
document.body.appendChild(el)
|
||||
el.select()
|
||||
try {
|
||||
exec('copy')
|
||||
} catch {
|
||||
// Clipboard unavailable; the button stays idle.
|
||||
}
|
||||
/* oxlint-enable typescript/no-deprecated */
|
||||
el.remove()
|
||||
}
|
||||
|
||||
/** The elapsed-duration share of the conversation dictionary. */
|
||||
export type RunDurationTranslate = Translate<'duration.seconds' | 'duration.minutes'>
|
||||
function pad2(n: number): string {
|
||||
return String(n).padStart(2, '0')
|
||||
}
|
||||
@@ -71,6 +33,21 @@ export function msUntilNextLocalMidnight(ms: number): number {
|
||||
return Math.max(next.getTime() - ms, 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Localized elapsed-time label shared by running and settled turn chrome.
|
||||
* @param ms - Elapsed duration in milliseconds (negatives clamp to zero).
|
||||
* @param t - Translate seat supplying the duration templates.
|
||||
* @returns Display string in whole seconds.
|
||||
*/
|
||||
export function formatRunDuration(ms: number, t: RunDurationTranslate): string {
|
||||
const total = Math.max(0, Math.floor(ms / 1000))
|
||||
const minutes = Math.floor(total / 60)
|
||||
const seconds = total % 60
|
||||
return minutes > 0
|
||||
? t('duration.minutes', { minutes, seconds: String(seconds).padStart(2, '0') })
|
||||
: t('duration.seconds', { seconds })
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact local timestamp for message IconActions. Same calendar day →
|
||||
* `HH:mm`; earlier this year → the `clock.md` date template + clock; other
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/** Frame-throttled scheduling for non-essential visual alignment. */
|
||||
|
||||
import { useCallback, useLayoutEffect, useRef } from 'react'
|
||||
|
||||
const DEFAULT_INTERVAL_FRAMES = 3
|
||||
|
||||
/**
|
||||
* Return a stable scheduler that coalesces visual updates over a frame interval.
|
||||
* Repeated calls retain the latest callback, and unmount cancels pending work.
|
||||
* @param update - DOM alignment to run after the throttle interval.
|
||||
* @param intervalFrames - Frames to wait before applying the latest alignment.
|
||||
* @returns a stable function that schedules the latest update.
|
||||
*/
|
||||
export function useThrottledVisualUpdate(
|
||||
update: () => void,
|
||||
intervalFrames = DEFAULT_INTERVAL_FRAMES,
|
||||
): () => void {
|
||||
const updateRef = useRef(update)
|
||||
updateRef.current = update
|
||||
const pendingFrameRef = useRef<number | null>(null)
|
||||
|
||||
useLayoutEffect(() => () => {
|
||||
if (pendingFrameRef.current === null) return
|
||||
cancelAnimationFrame(pendingFrameRef.current)
|
||||
pendingFrameRef.current = null
|
||||
}, [])
|
||||
|
||||
return useCallback(() => {
|
||||
if (pendingFrameRef.current !== null) return
|
||||
let remainingFrames = intervalFrames
|
||||
const advance = (): void => {
|
||||
remainingFrames -= 1
|
||||
if (remainingFrames > 0) {
|
||||
pendingFrameRef.current = requestAnimationFrame(advance)
|
||||
return
|
||||
}
|
||||
pendingFrameRef.current = null
|
||||
updateRef.current()
|
||||
}
|
||||
pendingFrameRef.current = requestAnimationFrame(advance)
|
||||
}, [intervalFrames])
|
||||
}
|
||||
@@ -42,8 +42,10 @@ export const zh = {
|
||||
'details.input': '输入',
|
||||
'details.output': '输出',
|
||||
'details.running': '运行中…',
|
||||
'todo.title': '任务清单',
|
||||
'todo.progress': '{done}/{total} 项任务 · {active} 项进行中',
|
||||
'todo.title': '任务',
|
||||
'todo.progress.done': '{done} 已完成',
|
||||
'todo.progress.active': '{active} 进行中',
|
||||
'todo.progress.pending': '{pending} 待处理',
|
||||
'todo.rowTitle': '更新任务清单',
|
||||
'todo.completed': '{done}/{total} 已完成',
|
||||
'chat.loadingHistory': '载入历史…',
|
||||
@@ -68,6 +70,9 @@ export const zh = {
|
||||
'message.retry.delay': '重试延迟:',
|
||||
'message.retry.failure': '失败原因:',
|
||||
'message.turnError': '本轮运行失败',
|
||||
'message.ranFor': '用时 {duration}',
|
||||
'duration.seconds': '{seconds}秒',
|
||||
'duration.minutes': '{minutes}分{seconds}秒',
|
||||
'command.running': '执行中…',
|
||||
'command.failed': '命令失败',
|
||||
'command.done': '已完成',
|
||||
@@ -151,7 +156,9 @@ export const en = {
|
||||
'details.output': 'Output',
|
||||
'details.running': 'Running…',
|
||||
'todo.title': 'To-dos',
|
||||
'todo.progress': '{done}/{total} tasks · {active} in progress',
|
||||
'todo.progress.done': '{done} completed',
|
||||
'todo.progress.active': '{active} in progress',
|
||||
'todo.progress.pending': '{pending} pending',
|
||||
'todo.rowTitle': 'Update to-do list',
|
||||
'todo.completed': '{done}/{total} completed',
|
||||
'chat.loadingHistory': 'Loading history…',
|
||||
@@ -176,6 +183,9 @@ export const en = {
|
||||
'message.retry.delay': 'Retry delay: ',
|
||||
'message.retry.failure': 'Failure reason: ',
|
||||
'message.turnError': 'This turn failed',
|
||||
'message.ranFor': 'Ran for {duration}',
|
||||
'duration.seconds': '{seconds}s',
|
||||
'duration.minutes': '{minutes}m {seconds}s',
|
||||
'command.running': 'Running…',
|
||||
'command.failed': 'Command failed',
|
||||
'command.done': 'Completed',
|
||||
|
||||
@@ -16,20 +16,21 @@
|
||||
var(--dsh-composer-dock-inset) -
|
||||
var(--dsh-composer-dock-inset)
|
||||
);
|
||||
/* Flex gap still applies after this item; subtract it together with the
|
||||
design's overlap so the later composer paints over the queue edge. */
|
||||
margin: 0 auto calc(
|
||||
0px - var(--dsh-composer-stack-gap) - var(--dsh-queue-composer-overlap)
|
||||
);
|
||||
padding: 2px 12px;
|
||||
/* Cancel the stack gap after this item and tuck 3px under the input card
|
||||
(square bottom), reading as one attached surface. */
|
||||
margin: 0 auto calc(0px - var(--dsh-composer-stack-gap) - 3px);
|
||||
/* Horizontal padding completes the shared dock inset (this wrapper only
|
||||
subtracts two insets from its width); no vertical padding, so the visual
|
||||
gap above the panel stays the uniform stack gap. */
|
||||
padding: 0 var(--dsh-composer-dock-inset);
|
||||
}
|
||||
|
||||
.panel {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
padding-top: 2px;
|
||||
border-radius: 14px 14px 0 0;
|
||||
padding: 2px 0;
|
||||
border-radius: 12px 12px 0 0;
|
||||
background: var(--dsw-specific-tip);
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
||||
@@ -39,6 +40,7 @@
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 1px solid var(--dsw-alias-border-l1);
|
||||
/* The input card's own top border closes the shape below. */
|
||||
border-bottom: none;
|
||||
border-radius: inherit;
|
||||
content: '';
|
||||
@@ -52,7 +54,9 @@
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
padding: 4px 16px 4px 12px;
|
||||
/* Right inset 12px puts the chevron on the same vertical line as the Todo
|
||||
header's chevron (12px body padding there). */
|
||||
padding: 4px 12px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
@@ -70,11 +74,18 @@
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.lead {
|
||||
display: grid;
|
||||
flex: none;
|
||||
place-items: center;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.count {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
font-family: Inter, var(--dsw-font-family);
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ import { useEffect, useId, useMemo, useState } from 'react'
|
||||
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
IconCheckOutline16, IconChevronDownOutline14, IconChevronUpOutline14,
|
||||
IconCloseOutline16, IconEditOutline16, IconSendOutline16, IconTrashOutline16,
|
||||
IconCheckOutline16, IconChevronDownOutline14, IconChevronUpOutline14, IconCloseOutline16,
|
||||
IconEditOutline16, IconQueueOutline14, IconSendOutline14, IconTrashOutline16, Tooltip,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { QueueAction, QueueItemId } from '../contract/queue.ts'
|
||||
import { NS } from '../locales.ts'
|
||||
@@ -87,6 +87,7 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps
|
||||
disabled={interactionActive}
|
||||
onClick={() => { setCollapsed(value => !value) }}
|
||||
>
|
||||
<span className={css.lead} aria-hidden><IconQueueOutline14 /></span>
|
||||
<span className={css.count}>{t('queue.count', { n: queue.length })}</span>
|
||||
<span className={css.chevron} aria-hidden>
|
||||
{expanded ? <IconChevronDownOutline14 /> : <IconChevronUpOutline14 />}
|
||||
@@ -96,6 +97,8 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps
|
||||
<ul id={listId} className={css.list} hidden={!listVisible}>
|
||||
{listVisible && queue.map(row => (
|
||||
<li key={row.id} className={css.row}>
|
||||
{/* Single-item strip has no count header, so the row itself carries the queue glyph. */}
|
||||
{queue.length === 1 && <span className={css.lead} aria-hidden><IconQueueOutline14 /></span>}
|
||||
{editing?.id === row.id
|
||||
? (
|
||||
<input
|
||||
@@ -121,74 +124,83 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps
|
||||
{editing?.id === row.id
|
||||
? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={css.action}
|
||||
aria-label={t('queue.save')}
|
||||
title={t('queue.save')}
|
||||
disabled={busy !== null || editing.text.trim() === ''}
|
||||
onClick={() => { void saveEdit() }}
|
||||
>
|
||||
<IconCheckOutline16 size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.action}
|
||||
aria-label={t('queue.cancelEdit')}
|
||||
title={t('queue.cancelEdit')}
|
||||
disabled={busy !== null}
|
||||
onClick={() => { setEditing(null) }}
|
||||
>
|
||||
<IconCloseOutline16 size={14} />
|
||||
</button>
|
||||
<Tooltip label={t('queue.save')} side="bottom" delayMs={500}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.action}
|
||||
aria-label={t('queue.save')}
|
||||
disabled={busy !== null || editing.text.trim() === ''}
|
||||
onClick={() => { void saveEdit() }}
|
||||
>
|
||||
<IconCheckOutline16 size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('queue.cancelEdit')} side="bottom" delayMs={500}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.action}
|
||||
aria-label={t('queue.cancelEdit')}
|
||||
disabled={busy !== null}
|
||||
onClick={() => { setEditing(null) }}
|
||||
>
|
||||
<IconCloseOutline16 size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={css.action}
|
||||
aria-label={t('queue.edit')}
|
||||
title={row.text === null ? t('queue.edit.unsupported') : t('queue.edit')}
|
||||
disabled={busy !== null || row.text === null}
|
||||
onClick={() => {
|
||||
if (row.text !== null) setEditing({ id: row.id, text: row.text })
|
||||
}}
|
||||
>
|
||||
<IconEditOutline16 size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.action}
|
||||
aria-label={t('queue.remove')}
|
||||
title={t('queue.remove')}
|
||||
disabled={busy !== null}
|
||||
onClick={() => {
|
||||
void applyAction(
|
||||
row.id,
|
||||
{ kind: 'remove' },
|
||||
t('queue.removeFailed'),
|
||||
)
|
||||
}}
|
||||
>
|
||||
<IconTrashOutline16 size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.action}
|
||||
aria-label={t('queue.steer')}
|
||||
title={running ? t('queue.steer') : t('queue.steer.unavailable')}
|
||||
disabled={busy !== null || !running}
|
||||
onClick={() => {
|
||||
void applyAction(
|
||||
row.id,
|
||||
{ kind: 'steer' },
|
||||
t('queue.steerFailed'),
|
||||
)
|
||||
}}
|
||||
>
|
||||
<IconSendOutline16 size={14} />
|
||||
</button>
|
||||
<Tooltip label={t('queue.edit')} side="bottom" delayMs={500} disabled={row.text === null}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.action}
|
||||
aria-label={t('queue.edit')}
|
||||
// Disabled buttons fire no hover events, so the
|
||||
// unsupported hint stays a native title.
|
||||
title={row.text === null ? t('queue.edit.unsupported') : undefined}
|
||||
disabled={busy !== null || row.text === null}
|
||||
onClick={() => {
|
||||
if (row.text !== null) setEditing({ id: row.id, text: row.text })
|
||||
}}
|
||||
>
|
||||
<IconEditOutline16 size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('queue.remove')} side="bottom" delayMs={500}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.action}
|
||||
aria-label={t('queue.remove')}
|
||||
disabled={busy !== null}
|
||||
onClick={() => {
|
||||
void applyAction(
|
||||
row.id,
|
||||
{ kind: 'remove' },
|
||||
t('queue.removeFailed'),
|
||||
)
|
||||
}}
|
||||
>
|
||||
<IconTrashOutline16 size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('queue.steer')} side="bottom" delayMs={500} disabled={!running}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.action}
|
||||
aria-label={t('queue.steer')}
|
||||
title={running ? undefined : t('queue.steer.unavailable')}
|
||||
disabled={busy !== null || !running}
|
||||
onClick={() => {
|
||||
void applyAction(
|
||||
row.id,
|
||||
{ kind: 'steer' },
|
||||
t('queue.steerFailed'),
|
||||
)
|
||||
}}
|
||||
>
|
||||
<IconSendOutline14 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</div>}
|
||||
|
||||
@@ -8,13 +8,15 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 8px 32px 12px;
|
||||
/* Sides = clearance + 16px so the card lands on the shared content width
|
||||
(input card - 32) at every viewport. */
|
||||
padding: 8px calc(var(--dsh-composer-side-clearance) + 16px) 12px;
|
||||
}
|
||||
|
||||
.card {
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
max-width: 776px;
|
||||
max-width: var(--dsh-chat-content-width);
|
||||
border: 1px solid var(--dsw-alias-state-warn-secondary);
|
||||
border-radius: 20px;
|
||||
background: var(--dsw-specific-input-major);
|
||||
@@ -84,7 +86,9 @@
|
||||
/* Card-level row, not body content. Its padding reproduces the metrics the row
|
||||
had inside the body: 14px above (the flex gap of 6 plus the row's 8px top
|
||||
margin, neither of which reaches it out here) and the body's former 14px
|
||||
bottom pad below, so the resting card is unchanged. */
|
||||
bottom pad below, so the resting card is unchanged. Buttons are the shared
|
||||
outline/primary capsules (Button atom, matching QuestionComposer's footer);
|
||||
only the reject's danger hover is local. */
|
||||
.actionRow {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
@@ -92,36 +96,6 @@
|
||||
padding: 14px 16px 14px;
|
||||
}
|
||||
|
||||
.allow,
|
||||
.reject {
|
||||
padding: 6px 16px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.allow:disabled,
|
||||
.reject:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Primary action: filled ink (draft's rightmost emphasis, minus the dropped
|
||||
always-allow button). */
|
||||
.allow {
|
||||
border: none;
|
||||
background: var(--dsw-alias-label-primary);
|
||||
color: var(--dsw-alias-label-primary-foreground);
|
||||
}
|
||||
|
||||
/* Secondary: quiet outline. */
|
||||
.reject {
|
||||
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.reject:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover-danger);
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
// grant storage.
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Button } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { RunningToolCall } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { PendingApproval, type ApprovalComposerProps } from '../contract/slots.ts'
|
||||
import css from './ApprovalPanel.module.css'
|
||||
@@ -69,12 +70,12 @@ function ApprovalFlow({ pending, command, t }: {
|
||||
{command !== undefined && <div className={css.command}>{command}</div>}
|
||||
</div>
|
||||
<div className={css.actionRow}>
|
||||
<button type="button" className={css.reject} disabled={answered} onClick={() => { answer('rejected') }}>
|
||||
<Button variant="outline" className={css.reject} disabled={answered} onClick={() => { answer('rejected') }}>
|
||||
{t('approval.reject')}
|
||||
</button>
|
||||
<button type="button" className={css.allow} disabled={answered} onClick={() => { answer('allowed-once') }}>
|
||||
</Button>
|
||||
<Button variant="primary" disabled={answered} onClick={() => { answer('allowed-once') }}>
|
||||
{t('approval.allowOnce')}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,20 @@
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
background: var(--dsw-alias-bg-base);
|
||||
|
||||
/* Shared width axis for the whole column: one content width W
|
||||
(--dsh-chat-content-width) for the transcript, the dock cards
|
||||
(todo/goal/queue: card minus four insets, 4 x 8 = 32), and the takeover
|
||||
cards (question/approval/plan review); the input card alone is W + 32px.
|
||||
The relation also holds when a narrow viewport shrinks everything: the
|
||||
chat scroller and the takeover frames pad clearance + 16px per side while
|
||||
the input card clears the bare clearance, so the input card stays exactly
|
||||
content + 32px at every width. Declared on the root because the
|
||||
transcript and the composer seat are sibling subtrees. */
|
||||
--dsh-chat-content-width: 748px;
|
||||
--dsh-composer-card-max-width: calc(var(--dsh-chat-content-width) + 32px);
|
||||
--dsh-composer-side-clearance: 16px;
|
||||
--dsh-composer-dock-inset: 8px;
|
||||
}
|
||||
|
||||
.header {
|
||||
@@ -134,16 +148,11 @@
|
||||
}
|
||||
|
||||
/* Composer context stack (Figma 9:937): standalone dock cards share one
|
||||
rhythm; the terminal queue strip additionally tucks under the input card. */
|
||||
rhythm above the input card. */
|
||||
.composerStack {
|
||||
/* Horizontal geometry (card width, clearance, dock inset) rides the shared
|
||||
.root variables above so takeover siblings match the stack. */
|
||||
--dsh-composer-stack-gap: 6px;
|
||||
--dsh-queue-composer-overlap: 5px;
|
||||
|
||||
/* InputBar and dock registrants derive their horizontal geometry from the
|
||||
same card width, outer clearance, and dock inset. */
|
||||
--dsh-composer-card-max-width: 800px;
|
||||
--dsh-composer-side-clearance: 32px;
|
||||
--dsh-composer-dock-inset: 12px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -239,7 +248,9 @@
|
||||
gap: 12px;
|
||||
/* Foot inside the centered box floats the stack a bit above true center. */
|
||||
padding-bottom: 32px;
|
||||
width: min(776px, calc(100% - 48px));
|
||||
/* Card cap + both clearances: the hero input card lands at exactly the same
|
||||
width as the docked composer at every viewport. */
|
||||
width: min(calc(var(--dsh-composer-card-max-width) + 2 * var(--dsh-composer-side-clearance)), 100%);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@@ -263,7 +274,9 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
padding-left: 8px;
|
||||
/* figma drew px 8; nudged +12 so the chip's folder glyph lines up closer to
|
||||
the card's inner controls below. */
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
/* Hero: the composer sits inside the session scroll body; center there so
|
||||
|
||||
@@ -11,23 +11,28 @@
|
||||
/* Floating capsule input (figma Input_Bottom 75:8208): card floats above the
|
||||
viewport bottom inside the centered message column; textarea on top, action
|
||||
row below, one primary circle button bottom-right. Input width rides the
|
||||
column (800 is a cap, not a fixed size — layout rule: the box shrinks with
|
||||
the center column keeping its padding). Hero variant = the same card
|
||||
centered in the empty state; the transition between the two is a position
|
||||
move of one component. */
|
||||
column (--dsh-composer-card-max-width = chat content + 32px, 16px per side,
|
||||
is a cap, not a fixed size — layout rule: the box shrinks with the center
|
||||
column keeping its clearance). Hero variant = the same card centered in the
|
||||
empty state; the transition between the two is a position move of one
|
||||
component. */
|
||||
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
/* figma Input_Bottom: pad L32/R32/B8; the bottom gradient mask is owned by
|
||||
the chat scroller. No top pad: the composer stack's gap owns the space
|
||||
above; error/status strips still carry their own margin. */
|
||||
/* Side pads ride the shared clearance (figma Input_Bottom drew L32/R32/B8;
|
||||
the sides narrow with the shared width axis); the bottom gradient mask
|
||||
is owned by the chat scroller. No top pad: the composer stack's gap owns
|
||||
the space above; error/status strips still carry their own margin. */
|
||||
padding: 0 var(--dsh-composer-side-clearance) 8px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: 0;
|
||||
/* No bottom pad in the centered hero, but the side clearance must survive:
|
||||
the hero wrapper is full-width on narrow viewports, so this padding is
|
||||
the only thing keeping the card off the edges there. */
|
||||
padding: 0 var(--dsh-composer-side-clearance);
|
||||
}
|
||||
|
||||
.error,
|
||||
@@ -83,16 +88,16 @@
|
||||
the input border is one notch weaker than buttons) — exactly the
|
||||
l2-darkmode-thin pair. Fill: the input surface token (elevated in dark). */
|
||||
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
|
||||
border-radius: 20px;
|
||||
border-radius: 22px;
|
||||
background: var(--dsw-specific-input-major);
|
||||
box-shadow: var(--dsw-shadow-lv2);
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
/* Elevated surface in dark, same as the menus: the textarea inside scrolls
|
||||
once the composer hits its height cap, so the thumb takes the l2 pair.
|
||||
Declared on the card because the elevation belongs to the surface, and the
|
||||
custom properties inherit down to the textarea that actually scrolls (see
|
||||
ui-theme styles/scrollbar.css for the rebinding contract). */
|
||||
/* Elevated surface in dark, same as the menus: the draft scrollport inside
|
||||
scrolls once the composer hits its height cap, so the thumb takes the l2
|
||||
pair. Declared on the card because the elevation belongs to the surface,
|
||||
and the custom properties inherit down to the box that actually scrolls
|
||||
(see ui-theme styles/scrollbar.css for the rebinding contract). */
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
||||
}
|
||||
@@ -112,8 +117,21 @@
|
||||
height: 0;
|
||||
}
|
||||
|
||||
/* Mirror-div auto-grow wrapper: the hidden mirror is in normal flow and sets the height
|
||||
(min 2 lines / max 14 lines); the textarea rides it absolutely. Mirror and textarea
|
||||
/* The draft's scrollport, and the ONLY scrolling box in the composer: the
|
||||
caret is the textarea's and every visible glyph is the backdrop's, so the two
|
||||
layers stay together only by riding one offset the browser applies to both at
|
||||
once. Scrolling one box and assigning the offset to the other cannot hold —
|
||||
a wheel gesture is composited off the main thread, so the assignment lands
|
||||
frames late and the words visibly trail the caret. The 14-line cap lives here
|
||||
because this is the box the cap describes. */
|
||||
.scroll {
|
||||
max-height: var(--dsh-composer-text-max-height);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Mirror-div auto-grow stack: the hidden mirror is in normal flow and sets the FULL draft
|
||||
height (min 2 lines in hero); backdrop and textarea ride it absolutely, so both layers are
|
||||
as tall as the draft and the scrollport above shows a window onto them. Mirror and textarea
|
||||
MUST share font, line-height, padding and wrapping rules or heights diverge. */
|
||||
.grow {
|
||||
position: relative;
|
||||
@@ -165,7 +183,11 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
resize: none;
|
||||
overflow-y: auto;
|
||||
/* Never a scroller of its own: it is as tall as the draft, so it has no
|
||||
scrollable overflow to hold an offset that could differ from the glyphs'.
|
||||
The browser still reveals the caret — the scroll-into-view walks up to
|
||||
.scroll and moves both layers together. */
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
@@ -189,22 +211,24 @@
|
||||
share the stack, so placeholder advances agree by construction. */
|
||||
font-family: 'DshChipCell', 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
|
||||
down for a caret that sits after a newline. That parse needs a length, so a
|
||||
theme resolving this to `normal` would make the reveal a silent no-op. */
|
||||
line-height: inherit;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
/* These three MUST wrap at one width, because InputBar mirrors a single
|
||||
scroll offset between .input and .backdrop and a layer that wraps onto
|
||||
more lines is taller, has a larger scroll maximum, and clamps the mirrored
|
||||
offset below the caret. Only .input scrolls, so only .input can lose
|
||||
content width to a scrollbar that consumes layout space.
|
||||
`scrollbar-gutter: stable` here does NOT buy that guarantee and was
|
||||
removed after measuring: WebKit applies it to overflow-y:auto but not to
|
||||
the overflow:hidden layers, so it left .input at 768 against 776 — the
|
||||
same gap it was meant to close — while costing chromium 8px of text width
|
||||
unconditionally. The gap it would have closed is measured and recorded in
|
||||
the Agent Note (2026-07-31-composer-glyph-layer-tracks-the-textarea);
|
||||
closing it needs one geometry every engine agrees on, not this property. */
|
||||
/* These three MUST wrap at one width: the mirror decides the box height
|
||||
the other two are laid out in, and a glyph layer that breaks lines
|
||||
elsewhere than the textarea puts the words under the wrong caret. They do
|
||||
so by construction now that all three sit INSIDE .scroll — a scrollbar
|
||||
that consumes layout space narrows the scrollport, which is their shared
|
||||
containing block, so it costs all three the same width on every engine.
|
||||
Scrolling the textarea itself is what used to break this, and no property
|
||||
fixed it: WebKit reserved gutter space for the overflow-y:auto textarea
|
||||
and not for the overflow:hidden layers beside it, leaving them 8px apart
|
||||
(768 against 776) — worth 2 to 5 wrapped lines on a long draft. */
|
||||
}
|
||||
|
||||
/* figma 34:10434: #ADB2B8 light / #81858C dark — the caption pair exactly. */
|
||||
@@ -222,10 +246,6 @@
|
||||
.mirror {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
/* 14-line cap, shared with the composer takeovers (declared on
|
||||
ConversationRoot .composerSeat). */
|
||||
max-height: var(--dsh-composer-text-max-height);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Hero (centered empty-state) keeps the 2-line floor (figma min-h 52 = ~2 × 24
|
||||
@@ -241,8 +261,16 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 0 10px 10px 10px;
|
||||
/* 2px moved from the bottom pad to the top: the whole control row sits 2px
|
||||
lower in the card (it read too high against the textarea) while the card
|
||||
height and the controls' own centering stay untouched. */
|
||||
padding: 2px 8px 6px;
|
||||
min-width: 0;
|
||||
/* Size container so the chips inside can collapse to icon-only when the
|
||||
card runs out of row width (PermissionSelect @container rule). Anonymous
|
||||
on purpose: CSS modules hash container-name per module, so a name declared
|
||||
here can never match a query in another module's sheet. */
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.tools,
|
||||
@@ -253,13 +281,15 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* figma 75:8208: 16 between + and the mode chips; 4 between Plan / Read-only. */
|
||||
/* figma 75:8208 drew 16 between + and the mode chips and 4 between Plan /
|
||||
Read-only; the chip gap widened to 12 so the pill chips read as separate
|
||||
controls. */
|
||||
.tools {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.modes {
|
||||
gap: 4px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.trailing {
|
||||
@@ -339,6 +369,10 @@
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
transition: background-color 100ms ease;
|
||||
/* Opts out of the row's 2px downward shift (.row top pad): the send circle
|
||||
keeps its original seat while the smaller chips sit lower. Transform, not
|
||||
margin, so flex centering math is untouched. */
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.primary:hover:not(:disabled) {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconPlusOutline16, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
// Type-only: the `plan` projection key merge (the TodoDock posture — the
|
||||
// composer reads a host-computed value; the domain owns the key).
|
||||
import type {} from '@deepseek-ai/dsh-plan-mode/client'
|
||||
@@ -63,7 +63,8 @@ export function InputBar({
|
||||
const draft = input?.draft ?? ''
|
||||
const empty = draft.trim() === ''
|
||||
const inputRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
const backdropRef = useRef<HTMLDivElement | null>(null)
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null)
|
||||
const mirrorRef = useRef<HTMLDivElement | null>(null)
|
||||
// IME guard: composition Enter picks a candidate, it must not send. The ref outlives renders;
|
||||
// clearing is deferred one tick because Safari delivers the closing keydown AFTER compositionend.
|
||||
const composingRef = useRef(false)
|
||||
@@ -88,29 +89,101 @@ export function InputBar({
|
||||
const locked = disabled
|
||||
const machineBusy = input?.phase === 'adjudicating' || input?.phase === 'submitting'
|
||||
|
||||
// Unlock (mount / session switch) returns focus to the box.
|
||||
useEffect(() => {
|
||||
if (!locked) inputRef.current?.focus()
|
||||
}, [locked, sessionId])
|
||||
// Scroll the draft scrollport the minimum that brings `caret` into view — the
|
||||
// browser's own behavior for typing, performed for the paths where it does
|
||||
// not act.
|
||||
//
|
||||
// The mirror is the caret's ruler: it renders the same draft at the same
|
||||
// metrics and the same wrap width in the same stack (that is what makes it
|
||||
// the height authority), so a Range collapsed at the caret's index reports
|
||||
// where the caret is without a caret API.
|
||||
const revealCaret = (caret: number): void => {
|
||||
const scrollEl = scrollRef.current
|
||||
const mirrorEl = mirrorRef.current
|
||||
const text = mirrorEl?.firstChild
|
||||
if (scrollEl === null || mirrorEl === null || !(text instanceof Text)) return
|
||||
// A box that cannot scroll has nothing to reveal: the draft fits, so every
|
||||
// caret is already in view and the assignment below would clamp to itself.
|
||||
if (scrollEl.scrollHeight <= scrollEl.clientHeight) return
|
||||
const at = Math.min(caret, text.data.length)
|
||||
// A caret straight after a newline sits on a line with nothing on it to
|
||||
// measure — the shape a trailing-newline draft ends in — and the engines
|
||||
// disagree there: chromium returns NO client rects at all (an all-zero box,
|
||||
// which would scroll the wrong way), firefox reports the line above, WebKit
|
||||
// the right one. Measure the newline itself instead, which is the line the
|
||||
// caret just left, and step one line down; that they all agree on.
|
||||
const afterNewline = at > 0 && text.data[at - 1] === '\n'
|
||||
const range = document.createRange()
|
||||
range.setStart(text, afterNewline ? at - 1 : at)
|
||||
if (afterNewline) range.setEnd(text, at)
|
||||
else range.collapse(true)
|
||||
const line = afterNewline ? Number.parseFloat(getComputedStyle(mirrorEl).lineHeight) : 0
|
||||
const rect = range.getBoundingClientRect()
|
||||
const box = scrollEl.getBoundingClientRect()
|
||||
if (rect.bottom + line > box.bottom) scrollEl.scrollTop += rect.bottom + line - box.bottom
|
||||
else if (rect.top + line < box.top) scrollEl.scrollTop -= box.top - rect.top - line
|
||||
}
|
||||
|
||||
// Two DOM listeners on the textarea, one lifetime (it is never unmounted —
|
||||
// the inert state renders the same element disabled).
|
||||
//
|
||||
// wheel — active conversation scrollport: chain the gesture. While the
|
||||
// textarea (capped at 14 lines with overflow-y:auto) can still move in this
|
||||
// direction, keep the native scroll; only at its own edge forward delta to
|
||||
// the host so a short draft never traps the gesture and a long draft stays
|
||||
// scrollable. Hero mounts have no host and keep native wheel scrolling.
|
||||
//
|
||||
// scroll — the backdrop paints every visible glyph (the textarea's own text
|
||||
// is transparent) but is clipped, not scrolled, so it does not follow the
|
||||
// textarea on its own: without this mirror a draft past the cap moves the
|
||||
// caret while the words stay frozen in place. Every way the box moves ends
|
||||
// in a `scroll` event, edits included (the caret is scrolled into view), and
|
||||
// the layers share an extent, so a draft that shrinks past the offset clamps
|
||||
// both to the same maximum — one listener covers the coupling.
|
||||
// Reveal the focus end of the current selection. Today's entry paths leave a
|
||||
// collapsed selection, but honoring direction keeps a future range-preserving
|
||||
// path from revealing its anchor instead of its focus.
|
||||
const revealSelectionFocus = (el: HTMLTextAreaElement): void => {
|
||||
// selectionStart/End are number|null in lib.dom; the type-aware lint program narrows them.
|
||||
const caret = el.selectionDirection === 'backward' ? el.selectionStart : el.selectionEnd
|
||||
// oxlint-disable-next-line typescript/no-unnecessary-condition
|
||||
revealCaret(caret ?? el.value.length)
|
||||
}
|
||||
|
||||
// Unlock (mount / session switch) returns focus to the box, and owns the
|
||||
// reveal that comes with it. `preventScroll` because this focus is ours, not
|
||||
// a gesture: the textarea is as tall as the draft, so the browser's reveal
|
||||
// would walk up to the conversation scrollport and move the transcript under
|
||||
// a user who only switched session. That leaves the caret to us — the DOM is
|
||||
// reused across sessions, so switching to a longer draft keeps the previous
|
||||
// offset while the value swap puts the caret at the new draft's end, which is
|
||||
// off screen (measured on all three engines: offset 0 with the caret 940px
|
||||
// down). Suppress the walk, then reveal in our own box.
|
||||
useEffect(() => {
|
||||
const el = inputRef.current
|
||||
if (locked || el === null) return
|
||||
el.focus({ preventScroll: true })
|
||||
revealSelectionFocus(el)
|
||||
}, [locked, sessionId])
|
||||
|
||||
// A persisted draft arrives AFTER the unlock effect: ConversationSession
|
||||
// adopts it in its own mount effect, and a parent's mount effect runs after
|
||||
// its children's. Reveal when the draft becomes non-empty so a restored long
|
||||
// draft does not stay at its head with the caret at its end. This effect does
|
||||
// not focus: send-clear, failed-send restore, and first-character transitions
|
||||
// must not steal focus from another control the user moved to.
|
||||
useEffect(() => {
|
||||
const el = inputRef.current
|
||||
if (locked || draft === '' || el === null) return
|
||||
revealSelectionFocus(el)
|
||||
}, [draft !== ''])
|
||||
|
||||
// Caret restore after an edit the composer performs itself. The machine owns
|
||||
// the draft and the undo log, so paste and cut suppress the native edit and
|
||||
// write the value through the machine — and a
|
||||
// programmatic selection change reveals nothing: measured in chromium and
|
||||
// WebKit, pasting a long block leaves the view where it was while the caret
|
||||
// sits at the end of the draft. Native typing gets its reveal from the
|
||||
// browser; these two have to ask for it, so they share one restore.
|
||||
const restoreCaret = (el: HTMLTextAreaElement, caret: number): void => {
|
||||
requestAnimationFrame(() => {
|
||||
el.setSelectionRange(caret, caret)
|
||||
revealCaret(caret)
|
||||
})
|
||||
}
|
||||
|
||||
// Wheel chaining on the draft scrollport, one lifetime (it is never
|
||||
// unmounted — the inert state renders the same element disabled). While the
|
||||
// capped box can still move in this direction, keep the native scroll; only
|
||||
// at its own edge forward the delta to the active conversation scrollport, so
|
||||
// a short draft never traps the gesture and a long draft stays scrollable.
|
||||
// Hero mounts have no host and keep native wheel scrolling.
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current
|
||||
if (el === null) return
|
||||
const onWheel = (e: WheelEvent): void => {
|
||||
const host = el.closest('[data-conversation-scroll]')
|
||||
@@ -121,16 +194,8 @@ export function InputBar({
|
||||
e.preventDefault()
|
||||
host.scrollTop += e.deltaY
|
||||
}
|
||||
const onScroll = (): void => {
|
||||
const backdropEl = backdropRef.current
|
||||
if (backdropEl !== null) backdropEl.scrollTop = el.scrollTop
|
||||
}
|
||||
el.addEventListener('wheel', onWheel, { passive: false })
|
||||
el.addEventListener('scroll', onScroll, { passive: true })
|
||||
return () => {
|
||||
el.removeEventListener('wheel', onWheel)
|
||||
el.removeEventListener('scroll', onScroll)
|
||||
}
|
||||
return () => { el.removeEventListener('wheel', onWheel) }
|
||||
}, [])
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
|
||||
@@ -234,7 +299,7 @@ export function InputBar({
|
||||
e.clipboardData.setData('text/plain', text)
|
||||
if (cut && !machineBusy && !locked) {
|
||||
keyboard.setDraft(draft.slice(0, start) + draft.slice(end), { start, end, insertedLength: 0 })
|
||||
requestAnimationFrame(() => { el.setSelectionRange(start, start) })
|
||||
restoreCaret(el, start)
|
||||
}
|
||||
void slice
|
||||
}
|
||||
@@ -253,7 +318,7 @@ export function InputBar({
|
||||
// land (paste-upgrade). The DOM layer only starts the transaction.
|
||||
keyboard.pasteBegin(text, sel)
|
||||
const caret = sel.start + text.length
|
||||
requestAnimationFrame(() => { el.setSelectionRange(caret, caret) })
|
||||
restoreCaret(el, caret)
|
||||
keyboard.track(keyboard.snapshot.draft, caret)
|
||||
}
|
||||
|
||||
@@ -264,10 +329,13 @@ export function InputBar({
|
||||
void e
|
||||
}
|
||||
|
||||
// Button presses steal focus from the textarea; suppress at mousedown so typing continues seamlessly.
|
||||
// Button presses steal focus from the textarea; suppress at mousedown so
|
||||
// typing continues seamlessly. `preventScroll` for the same reason as the
|
||||
// unlock effect, and with no reveal of its own: the caret has not moved, and
|
||||
// the next keystroke gets the browser's native one.
|
||||
const keepFocus = (e: MouseEvent<HTMLButtonElement>): void => {
|
||||
e.preventDefault()
|
||||
inputRef.current?.focus()
|
||||
inputRef.current?.focus({ preventScroll: true })
|
||||
}
|
||||
|
||||
const onToggleCommandMenu = (): void => {
|
||||
@@ -369,22 +437,6 @@ export function InputBar({
|
||||
const displayHint = translated !== hintKey ? translated : deco.hint
|
||||
backdrop.push(<span key="hint" className={css.hint} data-decoration="hint">{displayHint}</span>)
|
||||
}
|
||||
// Trailing-line sentinel, the same one the mirror div carries and for the
|
||||
// same reason: a textarea reserves a line box for the caret after a final
|
||||
// newline, while `white-space: pre-wrap` collapses a text node's trailing
|
||||
// newline and generates none. Without it a draft ending in a newline makes
|
||||
// the backdrop exactly one line SHORTER than the textarea, so mirroring the
|
||||
// offset at the very bottom clamps and the glyphs sit a line behind the
|
||||
// caret. The extra newline is absorbed by that same collapse when the draft
|
||||
// does not end in one, so it costs no height in the ordinary case.
|
||||
//
|
||||
// The mirror only fails one way — a backdrop SHORTER than the textarea
|
||||
// clamps the assignment, while a taller one takes every offset exactly and
|
||||
// hides the surplus below the clip. That is why the ghost hint needs no
|
||||
// handling of its own: it can only add content after the draft and before
|
||||
// this sentinel, never remove a line box, so it moves the pair to equal or
|
||||
// to the safe side.
|
||||
backdrop.push('\n')
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -402,48 +454,55 @@ export function InputBar({
|
||||
<div className={css.card} data-composer-card>
|
||||
{overlay !== undefined && <div className={css.overlayAnchor}>{overlay}</div>}
|
||||
{accessory !== undefined && <div className={css.accessory}>{accessory}</div>}
|
||||
{/* Mirror-div auto-grow: the hidden mirror renders draft+'\n' and stretches the wrapper
|
||||
(min/max capped in CSS); the absolutely-positioned textarea rides its height. Counting
|
||||
rows by '\n' cannot see soft wraps. */}
|
||||
<div className={css.grow}>
|
||||
<div ref={backdropRef} aria-hidden className={css.backdrop} data-input-backdrop>{backdrop}</div>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
className={css.input}
|
||||
value={draft}
|
||||
disabled={locked}
|
||||
readOnly={machineBusy}
|
||||
data-phase={input?.phase ?? 'inert'}
|
||||
placeholder={placeholder ?? (disabled
|
||||
? t('placeholder.unavailable')
|
||||
: planActive ? t('placeholder.plan') : t('placeholder.default'))}
|
||||
rows={2}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
onSelect={onSelect}
|
||||
onCopy={(e) => { onCopyOrCut(e, false) }}
|
||||
onCut={(e) => { onCopyOrCut(e, true) }}
|
||||
onPaste={onPaste}
|
||||
onCompositionStart={onCompositionStart}
|
||||
onCompositionEnd={onCompositionEnd}
|
||||
/>
|
||||
<div aria-hidden className={css.mirror}>{`${draft}\n`}</div>
|
||||
{/* One scrollport, two text layers. The hidden mirror renders draft+'\n' and stretches the
|
||||
stack to the draft's FULL height (counting rows by '\n' cannot see soft wraps); the
|
||||
absolutely-positioned backdrop and textarea ride that height, and .scroll — capped at 14
|
||||
lines in CSS — is the only thing that scrolls. The caret belongs to the textarea and the
|
||||
glyphs to the backdrop, so they can only stay together by moving together: one scroll
|
||||
offset the browser applies to both layers at once, never a JS mirror between two boxes,
|
||||
which a compositor-driven gesture outruns and leaves the words trailing the caret. */}
|
||||
<div ref={scrollRef} className={css.scroll} data-input-scroll>
|
||||
<div className={css.grow}>
|
||||
<div aria-hidden className={css.backdrop} data-input-backdrop>{backdrop}</div>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
className={css.input}
|
||||
value={draft}
|
||||
disabled={locked}
|
||||
readOnly={machineBusy}
|
||||
data-phase={input?.phase ?? 'inert'}
|
||||
placeholder={placeholder ?? (disabled
|
||||
? t('placeholder.unavailable')
|
||||
: planActive ? t('placeholder.plan') : t('placeholder.default'))}
|
||||
rows={2}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
onSelect={onSelect}
|
||||
onCopy={(e) => { onCopyOrCut(e, false) }}
|
||||
onCut={(e) => { onCopyOrCut(e, true) }}
|
||||
onPaste={onPaste}
|
||||
onCompositionStart={onCompositionStart}
|
||||
onCompositionEnd={onCompositionEnd}
|
||||
/>
|
||||
<div ref={mirrorRef} aria-hidden className={css.mirror} data-input-mirror>{`${draft}\n`}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={css.row}>
|
||||
<div className={css.tools}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.add}
|
||||
aria-label={t('input.commands')}
|
||||
title={t('input.commands')}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={commandMenuOpen}
|
||||
disabled={locked || toggleCommandMenu === undefined}
|
||||
onMouseDown={keepFocus}
|
||||
onClick={onToggleCommandMenu}
|
||||
>
|
||||
<IconPlusOutline16 size={14} />
|
||||
</button>
|
||||
<Tooltip label={t('input.commands')} side="top" delayMs={500}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.add}
|
||||
aria-label={t('input.commands')}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={commandMenuOpen}
|
||||
disabled={locked || toggleCommandMenu === undefined}
|
||||
onMouseDown={keepFocus}
|
||||
onClick={onToggleCommandMenu}
|
||||
>
|
||||
<IconPlusOutline16 size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<div className={css.modes}>
|
||||
{accessSelect}
|
||||
{renderSlot('conversation.input.plan', { locked })}
|
||||
@@ -454,25 +513,26 @@ export function InputBar({
|
||||
{rightItems}
|
||||
{renderSlot('conversation.input.model', { locked })}
|
||||
{/* {machineBusy && <span className={css.pending} data-input-pending aria-label="处理中" />} */}
|
||||
<button
|
||||
type="button"
|
||||
className={css.primary}
|
||||
aria-label={primaryLabel}
|
||||
title={primaryLabel}
|
||||
disabled={stopping ? stop === undefined : empty || disabled || machineBusy}
|
||||
onMouseDown={keepFocus}
|
||||
onClick={onPrimary}
|
||||
>
|
||||
{stopping ? (
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden>
|
||||
<rect x="3" y="3" width="10" height="10" rx="3" fill="currentColor" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden>
|
||||
<path d="M8.3125 0.980183C8.66767 1.0531 8.97902 1.20418 9.2627 1.43233C9.48724 1.61297 9.73029 1.85793 9.97949 2.10714L14.707 6.83468L13.293 8.24874L9 3.95577V15.0417H7V3.95577L2.70703 8.24874L1.29297 6.83468L6.02051 2.10714C6.26971 1.85793 6.51277 1.61297 6.7373 1.43233C6.97662 1.23986 7.28445 1.04402 7.6875 0.980183C7.8973 0.947006 8.1031 0.95516 8.3125 0.980183Z" fill="currentColor" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<Tooltip label={primaryLabel} side="top" delayMs={500}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.primary}
|
||||
aria-label={primaryLabel}
|
||||
disabled={stopping ? stop === undefined : empty || disabled || machineBusy}
|
||||
onMouseDown={keepFocus}
|
||||
onClick={onPrimary}
|
||||
>
|
||||
{stopping ? (
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden>
|
||||
<rect x="3" y="3" width="10" height="10" rx="3" fill="currentColor" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden>
|
||||
<path d="M8.3125 0.980183C8.66767 1.0531 8.97902 1.20418 9.2627 1.43233C9.48724 1.61297 9.73029 1.85793 9.97949 2.10714L14.707 6.83468L13.293 8.24874L9 3.95577V15.0417H7V3.95577L2.70703 8.24874L1.29297 6.83468L6.02051 2.10714C6.26971 1.85793 6.51277 1.61297 6.7373 1.43233C6.97662 1.23986 7.28445 1.04402 7.6875 0.980183C7.8973 0.947006 8.1031 0.95516 8.3125 0.980183Z" fill="currentColor" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
height: 28px;
|
||||
padding: 0 4px 0 8px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
/* Rounded chip chrome, matching the sibling model trigger. */
|
||||
border-radius: 24px;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
@@ -30,6 +31,18 @@
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.triggerIcon {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* The shared 16px glyphs render one step smaller on the exposed trigger;
|
||||
the dropdown rows keep the full 16px. */
|
||||
.triggerIcon svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.triggerLabel {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
@@ -40,4 +53,21 @@
|
||||
.chevron {
|
||||
flex: 0 0 auto;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
transition: transform 120ms ease;
|
||||
}
|
||||
|
||||
/* Narrow composer: the trigger collapses to icon + chevron so the row keeps
|
||||
fitting. Only triggers that actually carry a mode glyph drop their label —
|
||||
a host-configured mode without one keeps its text as the sole identifier.
|
||||
The 460px cut is the point where the row (attach + modes + model + send)
|
||||
starts squeezing labels; the container is the composer row (InputBar .row —
|
||||
anonymous query because CSS modules hash container-names per module). */
|
||||
@container (max-width: 460px) {
|
||||
.trigger:has(.triggerIcon) .triggerLabel {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.chevronOpen {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,50 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import type { PermissionSelect as PermissionSelectValue } from '@deepseek-ai/dsh-permission/client'
|
||||
import { Menu, RiskConfirmation } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconChevronDownOutline14, Menu, RiskConfirmation } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { MenuEntry } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ComposerBarProps } from '../contract/slots.ts'
|
||||
import css from './PermissionSelect.module.css'
|
||||
|
||||
const FULL_ACCESS = 'danger-full-access'
|
||||
|
||||
/* Shield glyphs (design set 1556): check = read-only, pencil = workspace
|
||||
write, exclamation = full access. currentColor so the trigger and menu
|
||||
rows tint them with their own text color. */
|
||||
|
||||
const shieldOutline = 'M8.20554 0.899994L14.7901 3.36857V7.01026C14.7901 12 11.0466 14.2103 8.20554 15.3C5.36446 14.2103 1.62012 12 1.62012 7.01026V3.36857L8.20554 0.899994Z'
|
||||
|
||||
const permissionGlyphs = {
|
||||
'read-only': (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden>
|
||||
<path d={shieldOutline} stroke="currentColor" strokeWidth="1.31831" strokeLinejoin="round" />
|
||||
<path d="M12.1654 5.7552L8.9447 9.41475C8.73044 9.65816 8.53628 9.8804 8.35774 10.0423C8.1713 10.2114 7.94235 10.3717 7.64016 10.4254C7.48207 10.4535 7.32 10.4552 7.16151 10.4294C6.85843 10.3801 6.62728 10.2223 6.43836 10.0559C6.25752 9.89653 6.06037 9.67732 5.84264 9.43705L4.72925 8.20897L5.63557 7.38707L6.74897 8.61594C6.98603 8.87755 7.12974 9.03533 7.24673 9.13839C7.31033 9.19443 7.34485 9.21476 7.35823 9.22122C7.38068 9.22484 7.40352 9.22515 7.42593 9.22122C7.40522 9.22502 7.42893 9.23294 7.53583 9.136C7.65132 9.03126 7.79316 8.87139 8.02643 8.60638L11.2479 4.94763L12.1654 5.7552Z" fill="currentColor" />
|
||||
</svg>
|
||||
),
|
||||
'workspace-write': (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden>
|
||||
<path d="M8.08887 0.251709C8.20479 0.23085 8.32486 0.241168 8.43652 0.282959L15.0215 2.75171C15.2787 2.84819 15.4492 3.09414 15.4492 3.3689V7.0105C15.4492 7.10986 15.4441 7.2081 15.4414 7.30542C15.0285 7.07175 14.5905 6.87695 14.1309 6.73022V3.82495L8.20508 1.60327L2.2793 3.82495V7.0105C2.27936 9.7171 3.4745 11.5379 5.02734 12.7947C5.01025 12.9942 5 13.1962 5 13.4001C5.00001 13.7617 5.02722 14.1169 5.08008 14.4636C2.91555 13.0393 0.961014 10.752 0.960938 7.0105V3.3689C0.960938 3.09417 1.13146 2.84821 1.38867 2.75171L7.97461 0.282959L8.08887 0.251709Z" fill="currentColor" />
|
||||
<path d="M11.3525 5.64688V6.85688H5V5.64688H11.3525Z" fill="currentColor" />
|
||||
<path d="M9.5824 8.29376V9.50376H5V8.29376H9.5824Z" fill="currentColor" />
|
||||
<path d="M14.6647 15.6852H10.0338C10.3878 15.3751 10.7567 15.0517 11.0772 14.7706C11.2531 14.6164 11.4144 14.4746 11.5511 14.3547H14.6647V15.6852Z" fill="currentColor" />
|
||||
<path d="M8.14852 14.1308L7.33925 15.4976C7.22458 15.6912 7.42245 15.9194 7.63037 15.8333L9.09785 15.2254L15.0399 10.0719L14.0905 8.97733L8.14852 14.1308Z" fill="currentColor" />
|
||||
</svg>
|
||||
),
|
||||
[FULL_ACCESS]: (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden>
|
||||
<path d={shieldOutline} stroke="currentColor" strokeWidth="1.31831" strokeLinejoin="round" />
|
||||
<path d="M9.10094 4.5V8.75939H7.59888V4.5H9.10094Z" fill="currentColor" />
|
||||
<path d="M9.10094 9.8114V11.5H7.59888V9.8114H9.10094Z" fill="currentColor" />
|
||||
</svg>
|
||||
),
|
||||
} as Record<string, ReactNode>
|
||||
|
||||
/** Glyph for a permission option value; host-configured names outside the design set get none. */
|
||||
function permissionGlyph(value: string): ReactNode | undefined {
|
||||
return permissionGlyphs[value]
|
||||
}
|
||||
|
||||
/**
|
||||
* Display transform: kebab-case machine names render as title-case labels
|
||||
* (`workspace-write` → `Workspace Write`); non-kebab host-configured names
|
||||
@@ -52,7 +90,10 @@ export function PermissionSelect({ value, locked, command, t }: PermissionSelect
|
||||
|
||||
const items: MenuEntry[] = value.options
|
||||
.filter(o => o.value !== 'custom')
|
||||
.map(option => ({ id: option.value, label: optionLabel(option) }))
|
||||
.map((option) => {
|
||||
const icon = permissionGlyph(option.value)
|
||||
return { id: option.value, label: optionLabel(option), ...icon === undefined ? {} : { icon } }
|
||||
})
|
||||
|
||||
const submit = (id: string): void => {
|
||||
setPick(id)
|
||||
@@ -102,10 +143,14 @@ export function PermissionSelect({ value, locked, command, t }: PermissionSelect
|
||||
disabled={locked || busy}
|
||||
onClick={() => { setOpen(!open) }}
|
||||
>
|
||||
{permissionGlyph(currentValue) !== undefined && (
|
||||
<span className={css.triggerIcon} aria-hidden>{permissionGlyph(currentValue)}</span>
|
||||
)}
|
||||
<span className={css.triggerLabel}>{current === undefined ? displayName(currentValue) : optionLabel(current)}</span>
|
||||
<svg className={css.chevron} viewBox="0 0 12 12" width="12" height="12" aria-hidden>
|
||||
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</svg>
|
||||
{/* Same glyph + open rotation as the sibling ModelSelect trigger. */}
|
||||
<span className={clsx(css.chevron, open && css.chevronOpen)} aria-hidden>
|
||||
<IconChevronDownOutline14 />
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* Todo strip in the composer context stack (Figma 1236:32276): tip surface,
|
||||
14px radius, status icons + secondary item labels. Its visible card aligns
|
||||
with the GoalBar and the Queue panel inside their shared dock column. */
|
||||
status icons + secondary item labels. Its visible card aligns with the
|
||||
GoalBar and the Queue panel inside their shared dock column: 12px radius,
|
||||
36px collapsed row, 12px side padding, 14px tertiary leading glyph. */
|
||||
|
||||
.root {
|
||||
box-sizing: border-box;
|
||||
@@ -24,7 +25,7 @@
|
||||
var(--dsh-composer-dock-inset)
|
||||
);
|
||||
border: 1px solid var(--dsw-alias-border-l1);
|
||||
border-radius: 14px;
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-specific-tip);
|
||||
/* Elevated surface: `--dsw-specific-tip` is the same dark rung as the menu
|
||||
surface, and `.list` scrolls inside this card, so the thumb takes the l2
|
||||
@@ -39,7 +40,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 9px 15px;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
.header {
|
||||
@@ -54,9 +55,16 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.lead {
|
||||
display: grid;
|
||||
flex: none;
|
||||
place-items: center;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: none;
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
line-height: 24px;
|
||||
font-weight: 500;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
|
||||
@@ -13,7 +13,7 @@ import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots
|
||||
// declare) and the payload type. Type-only by construction — the outlet is
|
||||
// free of host value imports, so no host Context merge enters this program.
|
||||
import type { TodoItem } from '@deepseek-ai/dsh-tool-todo/client'
|
||||
import { IconChevronDownOutline14, IconChevronUpOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconChecklistOutline14, IconChevronDownOutline14, IconChevronUpOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { NS } from '../locales.ts'
|
||||
import css from './TodoPanel.module.css'
|
||||
|
||||
@@ -78,11 +78,18 @@ function StatusGlyph({ status }: { status: TodoItem['status'] }) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Header summary: "<done>/<total> tasks · <n> in progress". */
|
||||
/** Header summary: "·"-joined per-status counts; zero-count segments are omitted as noise (a non-empty list keeps at least one). */
|
||||
function progressLabel(todos: readonly TodoItem[], t: TodoPanelProps['t']): string {
|
||||
const done = todos.filter(item => item.status === 'completed').length
|
||||
const active = todos.filter(item => item.status === 'in_progress').length
|
||||
return t('todo.progress', { done, total: todos.length, active })
|
||||
const pending = todos.length - done - active
|
||||
// En spaces (U+2002): HTML collapses runs of ASCII spaces, so widening the
|
||||
// separator breathing room needs a literal wide space.
|
||||
return [
|
||||
...done > 0 ? [t('todo.progress.done', { done })] : [],
|
||||
...active > 0 ? [t('todo.progress.active', { active })] : [],
|
||||
...pending > 0 ? [t('todo.progress.pending', { pending })] : [],
|
||||
].join('\u2002·\u2002')
|
||||
}
|
||||
|
||||
export function TodoPanel({ todos, t }: TodoPanelProps) {
|
||||
@@ -98,6 +105,7 @@ export function TodoPanel({ todos, t }: TodoPanelProps) {
|
||||
aria-expanded={!collapsed}
|
||||
onClick={() => { setCollapsed(v => !v) }}
|
||||
>
|
||||
<span className={css.lead} aria-hidden><IconChecklistOutline14 /></span>
|
||||
<span className={css.title}>{t('todo.title')}</span>
|
||||
<span className={css.progress}>{progressLabel(todos, t)}</span>
|
||||
<span className={css.chevron} aria-hidden>
|
||||
|
||||
@@ -122,7 +122,7 @@ describe('todo_write assembly (product registrations, no outlet twins)', () => {
|
||||
// (default-collapsed: the header summary shows; rows appear on expand).
|
||||
const panel = view.container.querySelector('[data-testid="todo-panel"]')
|
||||
expect(panel).not.toBeNull()
|
||||
expect(panel!.textContent).toContain('1/3 项任务 · 1 项进行中')
|
||||
expect(panel!.textContent).toContain('1 已完成\u2002·\u20021 进行中\u2002·\u20021 待处理')
|
||||
fireEvent.click(panel!.querySelector('button')!)
|
||||
expect([...panel!.querySelectorAll('li')].map(li => li.getAttribute('data-status')))
|
||||
.toEqual(['completed', 'in_progress', 'pending'])
|
||||
|
||||
@@ -102,16 +102,10 @@ describe('MessageItem arms', () => {
|
||||
expect(screen.getByRole('tooltip').textContent).toBe('仅可从已完成轮次的最后一条消息分支')
|
||||
})
|
||||
|
||||
it('user copy stays quiet when execCommand throws or is absent', () => {
|
||||
it('user copy never claims success when the host rejects the write', async () => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
})
|
||||
Object.defineProperty(document, 'execCommand', {
|
||||
configurable: true,
|
||||
value: () => {
|
||||
throw new Error('denied')
|
||||
},
|
||||
value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) },
|
||||
})
|
||||
render(
|
||||
<MessageItem t={t} node={{
|
||||
@@ -122,12 +116,91 @@ describe('MessageItem arms', () => {
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: '复制成功' })).toBeNull()
|
||||
})
|
||||
|
||||
Object.defineProperty(document, 'execCommand', {
|
||||
it('copy swaps to the check success chrome, gates re-clicks, and reverts after a second', async () => {
|
||||
vi.useFakeTimers()
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
value: { writeText },
|
||||
})
|
||||
render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'user', seq: 1, time: 1_000,
|
||||
content: [{ type: 'text', text: 'copied body' }] as never,
|
||||
source: null,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
const copy = screen.getByRole('button', { name: '复制' })
|
||||
fireEvent.click(copy)
|
||||
fireEvent.click(copy)
|
||||
expect(writeText).toHaveBeenCalledTimes(1)
|
||||
// Two microtask ticks: writeClipboard's own await, then the .then that
|
||||
// lands the success chrome.
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
const done = screen.getByRole('button', { name: '复制成功' })
|
||||
fireEvent.click(done)
|
||||
expect(writeText).toHaveBeenCalledTimes(1)
|
||||
act(() => { vi.advanceTimersByTime(1000) })
|
||||
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('clears copy feedback work when the message unmounts', async () => {
|
||||
vi.useFakeTimers()
|
||||
let finishWrite!: () => void
|
||||
const writeText = vi.fn(() => new Promise<void>((resolve) => { finishWrite = resolve }))
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
})
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'user', seq: 1, time: 1_000,
|
||||
content: [{ type: 'text', text: 'copied body' }] as never,
|
||||
source: null,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||
view.unmount()
|
||||
await act(async () => {
|
||||
finishWrite()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
|
||||
const mounted = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'user', seq: 2, time: 1_000,
|
||||
content: [{ type: 'text', text: 'copied body' }] as never,
|
||||
source: null,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText: vi.fn().mockResolvedValue(undefined) },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy()
|
||||
mounted.unmount()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('consumed steering renders copy and branch actions without a badge', () => {
|
||||
@@ -502,6 +575,6 @@ describe('small branch tails', () => {
|
||||
: undefined}
|
||||
/>,
|
||||
)
|
||||
expect(view.container.textContent).toBe('1 turns · 1 steps|Input 0 tok · Output 10 tok')
|
||||
expect(view.container.textContent).toBe('1 turns · 1 steps| Input 0 tok · Output 10 tok')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -67,7 +67,7 @@ function snapshotWith(
|
||||
runningCalls: RunningToolCall[] = [],
|
||||
): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes, turnEnds: new Map(), partial: null, runningCalls, codeDispatches,
|
||||
sessionId: SID, nodes, turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls, codeDispatches,
|
||||
pending: [], queue: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
|
||||
@@ -32,7 +32,7 @@ const assistant = (seq: number, turn: number, usage?: unknown): AssistantMessage
|
||||
|
||||
function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
}
|
||||
@@ -133,7 +133,7 @@ describe('StatsLine', () => {
|
||||
const view = render(<StatsLine {...props(source)} />)
|
||||
// No timing on the fixture: the duration group drops out whole. Tokens come
|
||||
// from the projection, so paging the window cannot change them.
|
||||
expect(view.container.textContent).toBe('1 turns · 1 steps|Cache hit 90%|Input 100 tok · Output 5 tok')
|
||||
expect(view.container.textContent).toBe('1 turns · 1 steps| Cache hit 90%| Input 100 tok · Output 5 tok')
|
||||
const empty = makeSource()
|
||||
const emptyView = render(<StatsLine {...props(empty.source, {
|
||||
tokenUsage: { uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 },
|
||||
@@ -149,7 +149,7 @@ describe('StatsLine', () => {
|
||||
contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 },
|
||||
})} />)
|
||||
expect(view.container.textContent)
|
||||
.toBe('Context 25% of 128K|Cache hit 90%|Input 100 tok · Output 5 tok')
|
||||
.toBe('Context 25% of 128K| Cache hit 90%| Input 100 tok · Output 5 tok')
|
||||
})
|
||||
|
||||
it('renders context occupancy only when the projection knows a capacity', () => {
|
||||
@@ -196,7 +196,7 @@ describe('StatsLine', () => {
|
||||
const view = render(<StatsLine {...props(source, {
|
||||
tokenUsage: { uncachedInputTokens: 0, outputTokens: 7, cacheReadTokens: 0, cacheWriteTokens: 0 },
|
||||
})} />)
|
||||
expect(view.container.textContent).toBe('1 turns · 1 steps|Input 0 tok · Output 7 tok')
|
||||
expect(view.container.textContent).toBe('1 turns · 1 steps| Input 0 tok · Output 7 tok')
|
||||
})
|
||||
|
||||
it('includes cache writes in billed input and the cache-hit denominator', () => {
|
||||
@@ -210,7 +210,7 @@ describe('StatsLine', () => {
|
||||
},
|
||||
})} />)
|
||||
expect(view.container.textContent)
|
||||
.toBe('1 turns · 1 steps|Cache hit 45%|Input 200 tok · Output 7 tok')
|
||||
.toBe('1 turns · 1 steps| Cache hit 45%| Input 200 tok · Output 7 tok')
|
||||
})
|
||||
|
||||
it('renders ZERO times during streaming chunk frames (RFC hard acceptance)', () => {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
|
||||
afterEach(cleanup)
|
||||
import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
@@ -12,6 +11,36 @@ import { ToolRow } from '../src/client/chat/ToolRow.tsx'
|
||||
import { GenericToolCard, type GenericToolCardProps } from '../src/client/chat/GenericToolCard.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
|
||||
let nextAnimationFrameId = 1
|
||||
let animationFrames = new Map<number, FrameRequestCallback>()
|
||||
|
||||
function flushAnimationFrames(count: number): void {
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const callbacks = [...animationFrames.values()]
|
||||
animationFrames.clear()
|
||||
for (const callback of callbacks) callback(index)
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
nextAnimationFrameId = 1
|
||||
animationFrames = new Map()
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
const id = nextAnimationFrameId
|
||||
nextAnimationFrameId += 1
|
||||
animationFrames.set(id, callback)
|
||||
return id
|
||||
})
|
||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => {
|
||||
animationFrames.delete(id)
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
// Mirrors the real lookup chain (conversation namespace, then common).
|
||||
const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh)
|
||||
|
||||
@@ -341,6 +370,10 @@ describe('ThinkRow', () => {
|
||||
streaming
|
||||
/>,
|
||||
)
|
||||
expect(summary.scrollLeft).toBe(0)
|
||||
flushAnimationFrames(2)
|
||||
expect(summary.scrollLeft).toBe(0)
|
||||
flushAnimationFrames(1)
|
||||
expect(summary.scrollLeft).toBe(200)
|
||||
expect(summary.getAttribute('data-follow-end')).toBe('true')
|
||||
|
||||
@@ -351,6 +384,7 @@ describe('ThinkRow', () => {
|
||||
streaming={false}
|
||||
/>,
|
||||
)
|
||||
flushAnimationFrames(3)
|
||||
expect(view.getByText('Inspect the session')).toBeTruthy()
|
||||
expect(summary.scrollLeft).toBe(0)
|
||||
expect(summary.hasAttribute('data-follow-end')).toBe(false)
|
||||
|
||||
@@ -20,7 +20,8 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts
|
||||
import { createChatStore } from '../src/client/stores.ts'
|
||||
import { ChatView } from '../src/client/chat/ChatView.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
import { assistantActionsSeqs, deriveChatFlow, flowKeys, messageBranchSeqs } from '../src/client/chat/chat-flow.ts'
|
||||
import { assistantActionsSeqs, deriveChatFlow, flowKeys, messageBranchSeqs, runningTurnStartTime } from '../src/client/chat/chat-flow.ts'
|
||||
import { formatRunDuration } from '../src/client/chat/message-chrome.ts'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
@@ -36,7 +37,7 @@ const SID = 's1' as SessionId
|
||||
|
||||
function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
}
|
||||
@@ -241,6 +242,25 @@ describe('chat-flow derivation', () => {
|
||||
expect([...seqs].sort((a, b) => a - b)).toEqual([5, 7])
|
||||
})
|
||||
|
||||
it('runningTurnStartTime selects the latest turn/start without a turn/end', () => {
|
||||
expect(runningTurnStartTime(new Map([
|
||||
[1, { startTime: 1_000, endTime: 5_000 }],
|
||||
[2, { startTime: 6_000 }],
|
||||
]))).toBe(6_000)
|
||||
expect(runningTurnStartTime(new Map([
|
||||
[1, { startTime: 1_000, endTime: 5_000 }],
|
||||
[2, { startTime: 6_000, endTime: 9_000 }],
|
||||
]))).toBeNull()
|
||||
})
|
||||
|
||||
it('formatRunDuration localizes units and floors partial seconds', () => {
|
||||
const t = makeTranslate(zh, commonZh)
|
||||
expect(formatRunDuration(0, t)).toBe('0秒')
|
||||
expect(formatRunDuration(-500, t)).toBe('0秒')
|
||||
expect(formatRunDuration(15_999, t)).toBe('15秒')
|
||||
expect(formatRunDuration(125_000, t)).toBe('2分05秒')
|
||||
})
|
||||
|
||||
it('messageBranchSeqs keeps only message rows at completed transcript tails', () => {
|
||||
const interruptedThink: AssistantMessageNode = {
|
||||
kind: 'assistant', seq: 4.1, time: 4_100, turn: 1, step: 2,
|
||||
@@ -504,6 +524,42 @@ describe('ChatView', () => {
|
||||
expect(branchButtons.map(button => button.getAttribute('aria-disabled'))).toEqual(['true', null, 'true', null])
|
||||
})
|
||||
|
||||
it('the actions-owning assistant footer shows the turn run time', () => {
|
||||
const h = makeHarness({
|
||||
nodes: [
|
||||
user(1, 'hi'), // time 1_000
|
||||
assistant(2, 'mid-turn text'),
|
||||
assistant(16, 'final answer'),
|
||||
toolResult(18, 'trailing'),
|
||||
],
|
||||
turnTimings: new Map([[1, { startTime: 1_000, endTime: 20_000 }]]),
|
||||
turnEnds: new Map([[1, 20]]),
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
// The exact turn/end includes trailing tool activity after the final text.
|
||||
expect(view.getAllByText(/用时 19秒/)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('user and assistant message containers scope the hover-revealed time chrome', () => {
|
||||
const h = makeHarness({
|
||||
nodes: [user(1, 'hi'), assistant(2, 'answer')],
|
||||
turnTimings: new Map([[1, { startTime: 1_000, endTime: 2_000 }]]),
|
||||
turnEnds: new Map([[1, 2]]),
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
// One scope per message row; the CSS reveal keys off this attribute.
|
||||
expect(view.container.querySelectorAll('[data-time-hover-root]')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('the run-time label is withheld when the turn start is outside the window', () => {
|
||||
const h = makeHarness({
|
||||
nodes: [assistant(16, 'tail without trigger')],
|
||||
turnEnds: new Map([[1, 16]]),
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
expect(view.queryByText(/用时/)).toBeNull()
|
||||
})
|
||||
|
||||
it('enables fork only on the finalized assistant at the completed transcript tail', () => {
|
||||
const h = makeHarness({
|
||||
nodes: [user(1, 'question'), assistant(2, 'answer')],
|
||||
@@ -664,6 +720,26 @@ describe('ChatView', () => {
|
||||
expect(view.getByRole('status').textContent).toBe('Deep diving...')
|
||||
})
|
||||
|
||||
it('the running clock uses turn/start, ignores steering, and stays out of the live region', () => {
|
||||
const startTime = Date.now() - 125_000
|
||||
const trigger: UserMessageNode = { ...user(1, 'go'), time: startTime + 1 }
|
||||
const h = makeHarness({
|
||||
nodes: [trigger], turnTimings: new Map([[1, { startTime }]]), running: true,
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
// Freshly mounted (as after a reload) yet already past the 15s gate.
|
||||
const status = view.getByRole('status')
|
||||
expect(status.textContent).toMatch(/^Deep diving\.\.\.2分0\d秒$/)
|
||||
expect(status.querySelector('[aria-hidden="true"]')).not.toBeNull()
|
||||
act(() => {
|
||||
h.set({ nodes: [trigger, {
|
||||
kind: 'steering', messageId: 'st' as never, seq: 2, time: Date.now(), turn: 1,
|
||||
content: [{ type: 'text', text: 'also' }], source: null,
|
||||
}] })
|
||||
})
|
||||
expect(status.textContent).toMatch(/^Deep diving\.\.\.2分0\d秒$/)
|
||||
})
|
||||
|
||||
it('dispatches each tool row through the keyed slot with the tool name as entryKey', () => {
|
||||
const h = makeHarness({ nodes: [toolResult(3, 'a')] })
|
||||
const calls: { key: string; entryKey?: string }[] = []
|
||||
|
||||
@@ -335,7 +335,7 @@ describe('DetailsPanel diff Output section', () => {
|
||||
|
||||
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,
|
||||
|
||||
@@ -24,7 +24,7 @@ const SID = 's1' as SessionId
|
||||
|
||||
function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// semantics (input stays free; primary turns stop), the machine pending lock,
|
||||
// decoration backdrop, error/notice strips, and the focus-keeping mousedown.
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -18,12 +18,24 @@ import { zh } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
// jsdom implements no Range geometry at all — `Range.prototype.getBoundingClientRect`
|
||||
// is absent — and the composer measures the caret with one when it restores the
|
||||
// selection after an edit it performed itself. Every case here runs against a
|
||||
// zero rect; the reveal case below substitutes its own and restores this one.
|
||||
const ZERO_RECT = (): DOMRect => ({ top: 0, bottom: 0 }) as DOMRect
|
||||
Range.prototype.getBoundingClientRect = ZERO_RECT
|
||||
|
||||
// Read through the descriptor so the native method is never referenced unbound;
|
||||
// the reveal case below wraps it to record what it was asked to measure.
|
||||
const NATIVE_SET_START = Object.getOwnPropertyDescriptor(Range.prototype, 'setStart')!
|
||||
.value as (this: Range, node: Node, offset: number) => void
|
||||
|
||||
const SCTX = {} as ClientContext
|
||||
const SID = 's1' as SessionId
|
||||
|
||||
function snapshotOf(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
@@ -131,7 +143,6 @@ function bench(over?: BenchOptions) {
|
||||
}
|
||||
const view = render(<InputBar {...props} />)
|
||||
const textarea = view.container.querySelector('textarea')!
|
||||
// aria-label (not role name): title carries the same label and would double-match.
|
||||
const stopping = over?.running === true && over.subagent === undefined
|
||||
const button = view.container.querySelector<HTMLButtonElement>(
|
||||
`button[aria-label="${stopping ? '停止生成' : '发送消息'}"]`,
|
||||
@@ -321,7 +332,7 @@ describe('running and lock semantics (queue cut 1)', () => {
|
||||
expect((textarea).value).toBe('typed')
|
||||
})
|
||||
|
||||
it('wheel over a non-overflowing textarea forwards to the conversation host', () => {
|
||||
it('wheel over a non-overflowing draft forwards to the conversation host', () => {
|
||||
const host = document.createElement('div')
|
||||
host.setAttribute('data-conversation-scroll', '')
|
||||
Object.defineProperty(host, 'scrollTop', { value: 40, writable: true, configurable: true })
|
||||
@@ -337,17 +348,18 @@ describe('running and lock semantics (queue cut 1)', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('wheel chains: long drafts scroll inside the textarea until each edge, then the host', () => {
|
||||
it('wheel chains: long drafts scroll inside the draft scrollport until each edge, then the host', () => {
|
||||
const host = document.createElement('div')
|
||||
host.setAttribute('data-conversation-scroll', '')
|
||||
Object.defineProperty(host, 'scrollTop', { value: 40, writable: true, configurable: true })
|
||||
const { view, textarea } = bench()
|
||||
host.appendChild(view.container)
|
||||
document.body.appendChild(host)
|
||||
Object.defineProperty(textarea, 'clientHeight', { value: 100, configurable: true })
|
||||
Object.defineProperty(textarea, 'scrollHeight', { value: 400, configurable: true })
|
||||
const scrollport = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
|
||||
Object.defineProperty(scrollport, 'clientHeight', { value: 100, configurable: true })
|
||||
Object.defineProperty(scrollport, 'scrollHeight', { value: 400, configurable: true })
|
||||
let scrollTop = 150
|
||||
Object.defineProperty(textarea, 'scrollTop', {
|
||||
Object.defineProperty(scrollport, 'scrollTop', {
|
||||
configurable: true,
|
||||
get: () => scrollTop,
|
||||
set: (value: number) => { scrollTop = value },
|
||||
@@ -371,35 +383,151 @@ describe('running and lock semantics (queue cut 1)', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('the decoration backdrop tracks the textarea offset (it paints every visible glyph)', () => {
|
||||
it('the caret layer and the glyph layer ride one scrollport', () => {
|
||||
const { view, textarea } = bench({ draft: 'line\n'.repeat(40) })
|
||||
const scroll = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
|
||||
const backdrop = view.container.querySelector<HTMLElement>('[data-input-backdrop]')!
|
||||
Object.defineProperty(backdrop, 'scrollTop', { value: 0, writable: true, configurable: true })
|
||||
Object.defineProperty(textarea, 'scrollTop', { value: 0, writable: true, configurable: true })
|
||||
// A scrolled draft: the textarea moves, the clipped backdrop must follow.
|
||||
textarea.scrollTop = 120
|
||||
fireEvent.scroll(textarea)
|
||||
expect(backdrop.scrollTop).toBe(120)
|
||||
// Every later move tracks too, including back to the top — a one-shot
|
||||
// mirror would leave the glyphs parked at the first offset it saw.
|
||||
textarea.scrollTop = 0
|
||||
fireEvent.scroll(textarea)
|
||||
expect(backdrop.scrollTop).toBe(0)
|
||||
// The caret is the textarea's and every visible glyph is the backdrop's, so
|
||||
// one box has to carry both or an offset can exist in one and not the other.
|
||||
// jsdom has no layout and loads no stylesheet — which box scrolls is the
|
||||
// browser scenario's to assert; what is checkable here is that the
|
||||
// scrollport element holds both layers.
|
||||
expect(scroll.contains(textarea)).toBe(true)
|
||||
expect(scroll.contains(backdrop)).toBe(true)
|
||||
// The glyph layer carries the draft and nothing else: with one scrollport
|
||||
// it no longer pads its own height to match a second box's scroll extent.
|
||||
expect(backdrop.textContent).toBe('line\n'.repeat(40))
|
||||
})
|
||||
|
||||
it('the backdrop carries the trailing-line sentinel that keeps its extent equal to the textarea', () => {
|
||||
// jsdom has no layout, so the HEIGHTS this protects cannot be asserted here
|
||||
// (the browser scenario owns that); what is checkable is that the backdrop's
|
||||
// text is the draft plus exactly one newline. A textarea reserves a line box
|
||||
// after a final newline and `pre-wrap` collapses one, so without the
|
||||
// sentinel a draft ending in a newline leaves the backdrop a line short and
|
||||
// the mirrored offset clamps.
|
||||
const withNewline = bench({ draft: 'alpha\nbeta\n' })
|
||||
const backdrop = withNewline.view.container.querySelector<HTMLElement>('[data-input-backdrop]')!
|
||||
expect(backdrop.textContent).toBe('alpha\nbeta\n\n')
|
||||
const withoutNewline = bench({ draft: 'alpha\nbeta' })
|
||||
const plain = withoutNewline.view.container.querySelector<HTMLElement>('[data-input-backdrop]')!
|
||||
expect(plain.textContent).toBe('alpha\nbeta\n')
|
||||
it('an edit the composer performs itself scrolls the caret back into view', async () => {
|
||||
// Paste and cut suppress the native edit, so no engine reveals the caret
|
||||
// for them. jsdom has no layout: the rects are stubbed,
|
||||
// and what is asserted is the arithmetic — minimal scroll, in both
|
||||
// directions, and nothing at all for a caret already inside the box.
|
||||
const { view, textarea } = bench({ draft: 'line\n'.repeat(40) })
|
||||
const scroll = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
|
||||
const mirror = view.container.querySelector<HTMLElement>('[data-input-mirror]')!
|
||||
expect(mirror.firstChild).toBeInstanceOf(Text)
|
||||
scroll.getBoundingClientRect = () => ({ top: 100, bottom: 436 }) as DOMRect
|
||||
// jsdom reports scrollHeight === clientHeight for every element, which is
|
||||
// the composer's own "nothing to reveal" case; a scrollable box is what
|
||||
// puts the reveal on the table at all.
|
||||
Object.defineProperty(scroll, 'clientHeight', { value: 336, configurable: true })
|
||||
Object.defineProperty(scroll, 'scrollHeight', { value: 964, configurable: true })
|
||||
Object.defineProperty(scroll, 'scrollTop', { value: 0, writable: true, configurable: true })
|
||||
onTestFinished(() => {
|
||||
Range.prototype.getBoundingClientRect = ZERO_RECT
|
||||
Range.prototype.setStart = NATIVE_SET_START
|
||||
})
|
||||
// Which layer the caret is measured against, and at which index: the stub
|
||||
// records `setStart` so a helper that measured the backdrop instead, or
|
||||
// always collapsed at 0, fails here rather than only in the browser lane.
|
||||
let measured: { node: Node; offset: number } | null = null
|
||||
Range.prototype.setStart = function setStart(node: Node, offset: number): void {
|
||||
measured = { node, offset }
|
||||
NATIVE_SET_START.call(this, node, offset)
|
||||
}
|
||||
const caretAt = (top: number): void => {
|
||||
Range.prototype.getBoundingClientRect = () => ({ top, bottom: top + 24 }) as DOMRect
|
||||
}
|
||||
const settle = async (): Promise<void> => {
|
||||
await act(async () => { await new Promise((resolve) => { requestAnimationFrame(() => { resolve(null) }) }) })
|
||||
}
|
||||
// Pasted text lands below the fold: scroll down by exactly the overshoot.
|
||||
caretAt(500)
|
||||
fireEvent.paste(textarea, { clipboardData: { getData: () => 'pasted' } })
|
||||
await settle()
|
||||
expect(scroll.scrollTop).toBe(88) // 524 - 436
|
||||
// Measured on the mirror's own text, at the index the paste left the caret
|
||||
// (an empty draft's selection start, 0, plus the pasted length).
|
||||
expect(measured!.node).toBe(mirror.firstChild)
|
||||
expect(measured!.offset).toBe('pasted'.length)
|
||||
// A caret already inside the box does not move it.
|
||||
caretAt(200)
|
||||
fireEvent.paste(textarea, { clipboardData: { getData: () => 'more' } })
|
||||
await settle()
|
||||
expect(scroll.scrollTop).toBe(88)
|
||||
// Above the fold (a cut can leave it there): scroll back up.
|
||||
caretAt(60)
|
||||
fireEvent.paste(textarea, { clipboardData: { getData: () => 'again' } })
|
||||
await settle()
|
||||
expect(scroll.scrollTop).toBe(48) // 88 - (100 - 60)
|
||||
// A caret straight after a newline has nothing on its line to measure, so
|
||||
// the newline it just left is measured instead and one line is added.
|
||||
// chromium reports no client rects at all for the collapsed position.
|
||||
mirror.style.lineHeight = '24px'
|
||||
caretAt(500)
|
||||
fireEvent.paste(textarea, { clipboardData: { getData: () => 'block\n' } })
|
||||
await settle()
|
||||
// The four pastes accumulate at the draft's head, so the caret is at the
|
||||
// end of what they inserted — and the measured index is the newline before it.
|
||||
expect(measured!.offset).toBe('pastedmoreagainblock\n'.length - 1)
|
||||
expect(scroll.scrollTop).toBe(48 + 112) // from 48, by (524 + 24) - 436
|
||||
})
|
||||
|
||||
it('a session switch refocuses without moving the transcript, and reveals the new draft caret', () => {
|
||||
// The composer DOM is reused across sessions, so the previous session's
|
||||
// offset survives while the value swap puts the caret at the new draft's
|
||||
// end. `preventScroll` keeps the browser from revealing it through the
|
||||
// conversation scrollport, which leaves the reveal to the effect itself.
|
||||
const { view, textarea, props } = bench({ draft: 'line\n'.repeat(40) })
|
||||
const scroll = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
|
||||
const mirror = view.container.querySelector<HTMLElement>('[data-input-mirror]')!
|
||||
onTestFinished(() => { Range.prototype.getBoundingClientRect = ZERO_RECT })
|
||||
scroll.getBoundingClientRect = () => ({ top: 100, bottom: 436 }) as DOMRect
|
||||
Object.defineProperty(scroll, 'clientHeight', { value: 336, configurable: true })
|
||||
Object.defineProperty(scroll, 'scrollHeight', { value: 964, configurable: true })
|
||||
Object.defineProperty(scroll, 'scrollTop', { value: 0, writable: true, configurable: true })
|
||||
Range.prototype.getBoundingClientRect = () => ({ top: 500, bottom: 524 }) as DOMRect
|
||||
// The draft ends in a newline, so the reveal takes the after-newline path
|
||||
// and needs a resolvable line-height (jsdom computes `normal`).
|
||||
mirror.style.lineHeight = '24px'
|
||||
// Which index the effect reveals at, not merely that it scrolled: a
|
||||
// revealCaret(0) would land the same offset without this.
|
||||
onTestFinished(() => { Range.prototype.setStart = NATIVE_SET_START })
|
||||
let measured: { node: Node; offset: number } | null = null
|
||||
Range.prototype.setStart = function setStart(node: Node, offset: number): void {
|
||||
measured = { node, offset }
|
||||
NATIVE_SET_START.call(this, node, offset)
|
||||
}
|
||||
const focused: (boolean | undefined)[] = []
|
||||
textarea.focus = (options?: FocusOptions) => { focused.push(options?.preventScroll) }
|
||||
textarea.setSelectionRange(textarea.value.length, textarea.value.length)
|
||||
act(() => { view.rerender(<InputBar {...props} sessionId={'s2' as SessionId} />) })
|
||||
expect(focused).toEqual([true])
|
||||
expect(scroll.scrollTop).toBe(112) // (524 + 24) - 436
|
||||
// The draft ends in a newline, so the rule measures that newline: the
|
||||
// caret's own index is the mirror text's length minus its sentinel.
|
||||
expect(measured!.node).toBe(mirror.firstChild)
|
||||
expect(measured!.offset).toBe(textarea.value.length - 1)
|
||||
})
|
||||
|
||||
it('a persisted draft adopted after mount gets its caret revealed too', () => {
|
||||
// ConversationSession seeds the stored draft in its own mount effect, which
|
||||
// runs after this component's: the first reveal measures an empty mirror,
|
||||
// so the draft's arrival has to run it again without reclaiming focus.
|
||||
const { view, textarea, shell } = bench()
|
||||
const scroll = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
|
||||
const mirror = view.container.querySelector<HTMLElement>('[data-input-mirror]')!
|
||||
// The restored draft ends in a newline, so the reveal takes the
|
||||
// after-newline path and needs a resolvable line-height (jsdom says `normal`).
|
||||
mirror.style.lineHeight = '24px'
|
||||
onTestFinished(() => { Range.prototype.getBoundingClientRect = ZERO_RECT })
|
||||
scroll.getBoundingClientRect = () => ({ top: 100, bottom: 436 }) as DOMRect
|
||||
Object.defineProperty(scroll, 'clientHeight', { value: 336, configurable: true })
|
||||
Object.defineProperty(scroll, 'scrollHeight', { value: 964, configurable: true })
|
||||
Object.defineProperty(scroll, 'scrollTop', { value: 0, writable: true, configurable: true })
|
||||
Range.prototype.getBoundingClientRect = () => ({ top: 500, bottom: 524 }) as DOMRect
|
||||
const other = document.createElement('input')
|
||||
document.body.appendChild(other)
|
||||
onTestFinished(() => { other.remove() })
|
||||
other.focus()
|
||||
expect(scroll.scrollTop).toBe(0)
|
||||
act(() => { shell.setDraft('restored\n'.repeat(40)) })
|
||||
expect(document.activeElement).toBe(other)
|
||||
// The caret the machine left at the draft's end, revealed once the draft exists.
|
||||
expect(textarea.selectionStart).toBe(textarea.value.length)
|
||||
expect(scroll.scrollTop).toBe(112) // (524 + 24) - 436
|
||||
})
|
||||
|
||||
it('disabled state shows the unavailable placeholder; custom placeholder wins', () => {
|
||||
@@ -595,6 +723,8 @@ describe('command launcher chrome and control seats', () => {
|
||||
const trigger = view.getByLabelText(/^访问模式/) as HTMLButtonElement
|
||||
// Title-case display is presentation only; the menu ids stay machine names.
|
||||
expect(trigger.textContent).toBe('Read Only')
|
||||
expect([...trigger.querySelectorAll('svg')]
|
||||
.every(icon => icon.closest('[aria-hidden="true"]') !== null)).toBe(true)
|
||||
fireEvent.click(trigger)
|
||||
const items = view.getAllByRole('menuitem')
|
||||
expect(items.map(o => o.textContent)).toEqual(['Read Only', 'Workspace Write', 'Full access'])
|
||||
|
||||
@@ -26,7 +26,7 @@ const SID = 's1' as SessionId
|
||||
/** Standard-props InputBar mount over a real shell (the composer-bar entry shape). */
|
||||
function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled?: boolean }) {
|
||||
const session = createSnapshotStore<ConversationSnapshot>({
|
||||
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: over?.running ?? false, composerPhase: 'active',
|
||||
removed: over?.disabled ?? false, openState: 'open', openError: null, hasMore: false,
|
||||
loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
|
||||
@@ -112,7 +112,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
|
||||
actx.on('slash/input-consume-token', req => shell.consumeToken(req.guard) ? true : undefined)
|
||||
const wiring = shell
|
||||
const sessionStore = createSnapshotStore<ConversationSnapshot>({
|
||||
sessionId, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
|
||||
@@ -32,7 +32,7 @@ function row(id: string, text: string | null, preview = text ?? '[image]'): Queu
|
||||
|
||||
function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue, running: true, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
}
|
||||
|
||||
@@ -283,7 +283,7 @@ describe('DetailsPanel Output section (read)', () => {
|
||||
|
||||
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,
|
||||
|
||||
@@ -397,7 +397,7 @@ describe('DetailsPanel Output section (search)', () => {
|
||||
|
||||
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,
|
||||
|
||||
@@ -68,7 +68,7 @@ const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState =>
|
||||
|
||||
function conversationSnapshot(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
|
||||
@@ -477,7 +477,7 @@ describe('DetailsPanel Output section', () => {
|
||||
|
||||
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,
|
||||
|
||||
@@ -38,15 +38,24 @@ describe('TodoPanel', () => {
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('starts collapsed with the progress summary visible', () => {
|
||||
it('starts collapsed with the per-status count summary visible', () => {
|
||||
render(<TodoPanel todos={LIST} t={t} />)
|
||||
expect(screen.getByTestId('todo-panel')).toBeTruthy()
|
||||
expect(screen.getByText('任务清单')).toBeTruthy()
|
||||
expect(screen.getByText('1/3 项任务 · 1 项进行中')).toBeTruthy()
|
||||
expect(screen.getByText('任务')).toBeTruthy()
|
||||
expect(screen.getByText('1 已完成 · 1 进行中 · 1 待处理')).toBeTruthy()
|
||||
expect(screen.getByRole('button', { expanded: false })).toBeTruthy()
|
||||
expect(screen.queryByRole('list')).toBeNull()
|
||||
})
|
||||
|
||||
it('omits the completed segment while nothing is done yet', () => {
|
||||
render(<TodoPanel todos={[
|
||||
{ content: '写组件', status: 'in_progress' },
|
||||
{ content: '补测试', status: 'pending' },
|
||||
]} t={t} />)
|
||||
expect(screen.getByText('1 进行中 · 1 待处理')).toBeTruthy()
|
||||
expect(screen.queryByText(/已完成/)).toBeNull()
|
||||
})
|
||||
|
||||
it('expands to show one row per item with its status glyph', () => {
|
||||
render(<TodoPanel todos={LIST} t={t} />)
|
||||
fireEvent.click(screen.getByRole('button', { expanded: false }))
|
||||
@@ -65,17 +74,18 @@ describe('TodoPanel', () => {
|
||||
fireEvent.click(header)
|
||||
expect(screen.queryByRole('list')).toBeNull()
|
||||
// Collapsed header is title + progress only (no in-progress content hint).
|
||||
expect(screen.getByText('1/3 项任务 · 1 项进行中')).toBeTruthy()
|
||||
expect(screen.getByText('1 已完成 · 1 进行中 · 1 待处理')).toBeTruthy()
|
||||
expect(screen.queryByText('写组件')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { expanded: false }))
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('collapsed header still shows zero in-progress when nothing is active', () => {
|
||||
it('an all-completed list collapses the summary to the done count alone', () => {
|
||||
render(<TodoPanel todos={[{ content: '都完了', status: 'completed' }]} t={t} />)
|
||||
expect(screen.getByRole('button', { expanded: false })).toBeTruthy()
|
||||
expect(screen.queryByText('都完了')).toBeNull()
|
||||
expect(screen.getByText('1/1 项任务 · 0 项进行中')).toBeTruthy()
|
||||
expect(screen.getByText('1 已完成')).toBeTruthy()
|
||||
expect(screen.queryByText(/进行中|待处理/)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -93,7 +103,7 @@ describe('TodoDock', () => {
|
||||
// Capability absent (no baseline/frame yet) renders nothing.
|
||||
expect(screen.queryByTestId('todo-panel')).toBeNull()
|
||||
act(() => { store.set({ value: LIST }) })
|
||||
expect(screen.getByText('1/3 项任务 · 1 项进行中')).toBeTruthy()
|
||||
expect(screen.getByText('1 已完成 · 1 进行中 · 1 待处理')).toBeTruthy()
|
||||
// The pre-first-write whole value (null) retires the strip (the panel owns no data).
|
||||
act(() => { store.set({ value: null }) })
|
||||
expect(screen.queryByTestId('todo-panel')).toBeNull()
|
||||
|
||||
@@ -231,7 +231,7 @@ describe('DetailsPanel web Output section', () => {
|
||||
|
||||
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/* GoalBar: the second standalone card in the composer context stack (Figma
|
||||
1236:32276). Its 752px column matches Todo and the Queue panel. */
|
||||
1236:32276). Its dock column (card cap minus four insets) matches Todo and
|
||||
the Queue panel. */
|
||||
|
||||
.dock {
|
||||
box-sizing: border-box;
|
||||
@@ -21,27 +22,29 @@
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
max-width: 752px;
|
||||
max-width: calc(var(--dsh-composer-card-max-width) - 4 * var(--dsh-composer-dock-inset));
|
||||
height: 36px;
|
||||
margin: 0 auto;
|
||||
padding: 4px 5px 4px 12px;
|
||||
border: 1px solid var(--dsw-alias-border-l1);
|
||||
border-radius: 14px;
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-specific-tip);
|
||||
}
|
||||
|
||||
.sparkle {
|
||||
.goalGlyph {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* Matches the Todo/Queue panel titles (13/24 medium, primary) so the three
|
||||
composer-stack cards read as one family. */
|
||||
.label {
|
||||
flex: none;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
line-height: 24px;
|
||||
font-weight: 500;
|
||||
color: var(--dsw-alias-label-primary-dimmed);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.objective {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* GoalBar: the goal indicator docked above the message composer (input dock
|
||||
* strip). A present goal shows a sparkle, a phase label, the truncated
|
||||
* strip). A present goal shows a goal glyph, a phase label, the truncated
|
||||
* objective, and icon actions — resume when paused, edit (inline form in the
|
||||
* same strip), and clear. Goal creation lives on the `/goal` command, not
|
||||
* here: loading (undefined), no goal (null), and complete goals render
|
||||
@@ -11,7 +11,8 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { GoalSnapshot } from '@deepseek-ai/dsh-goal/client'
|
||||
import {
|
||||
IconCheckOutline16, IconCloseOutline16, IconEditOutline16, IconPauseOutline16, IconPlayOutline16, IconSparkle16, IconTrashOutline16,
|
||||
IconCheckOutline16, IconCloseOutline16, IconEditOutline16, IconGoalOutline16,
|
||||
IconPauseOutline16, IconPlayOutline16, IconTrashOutline16, Tooltip,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { GoalActionResult, GoalBarActions } from './slots.ts'
|
||||
@@ -94,26 +95,28 @@ export function GoalBar({ goal, onEdit, onPause, onResume, onClear, t }: GoalBar
|
||||
/>
|
||||
{actionError !== null && <span className={css.error} role="alert">{actionError}</span>}
|
||||
<div className={css.actions}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconBtn}
|
||||
onClick={() => { void handleEdit() }}
|
||||
disabled={pending || draft.trim() === ''}
|
||||
title={t('action.save')}
|
||||
aria-label={t('action.save')}
|
||||
>
|
||||
<IconCheckOutline16 />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconBtn}
|
||||
onClick={() => { setEditing(false) }}
|
||||
disabled={pending}
|
||||
title={t('action.cancel')}
|
||||
aria-label={t('action.cancel')}
|
||||
>
|
||||
<IconCloseOutline16 />
|
||||
</button>
|
||||
<Tooltip label={t('action.save')} side="bottom" delayMs={500}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconBtn}
|
||||
onClick={() => { void handleEdit() }}
|
||||
disabled={pending || draft.trim() === ''}
|
||||
aria-label={t('action.save')}
|
||||
>
|
||||
<IconCheckOutline16 size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('action.cancel')} side="bottom" delayMs={500}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconBtn}
|
||||
onClick={() => { setEditing(false) }}
|
||||
disabled={pending}
|
||||
aria-label={t('action.cancel')}
|
||||
>
|
||||
<IconCloseOutline16 size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -124,34 +127,41 @@ export function GoalBar({ goal, onEdit, onPause, onResume, onClear, t }: GoalBar
|
||||
return (
|
||||
<div className={css.dock} data-goal-bar>
|
||||
<div className={css.bar} title={title}>
|
||||
<span className={css.sparkle}><IconSparkle16 /></span>
|
||||
<span className={css.goalGlyph}><IconGoalOutline16 size={14} /></span>
|
||||
<span className={css.label}>{t(PHASE_LABELS[goal.phase])}</span>
|
||||
<span className={css.objective}>{goal.objective}</span>
|
||||
{actionError !== null && <span className={css.error} role="alert">{actionError}</span>}
|
||||
<div className={css.actions}>
|
||||
{goal.phase === 'active' && (
|
||||
<button type="button" className={css.iconBtn} disabled={pending} onClick={() => { void runAction(onPause) }} title={t('action.pause')} aria-label={t('action.pause')}>
|
||||
<IconPauseOutline16 />
|
||||
</button>
|
||||
<Tooltip label={t('action.pause')} side="bottom" delayMs={500}>
|
||||
<button type="button" className={css.iconBtn} disabled={pending} onClick={() => { void runAction(onPause) }} aria-label={t('action.pause')}>
|
||||
<IconPauseOutline16 size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{goal.phase === 'paused' && (
|
||||
<button type="button" className={css.iconBtn} disabled={pending} onClick={() => { void runAction(onResume) }} title={t('action.resume')} aria-label={t('action.resume')}>
|
||||
<IconPlayOutline16 />
|
||||
</button>
|
||||
<Tooltip label={t('action.resume')} side="bottom" delayMs={500}>
|
||||
<button type="button" className={css.iconBtn} disabled={pending} onClick={() => { void runAction(onResume) }} aria-label={t('action.resume')}>
|
||||
<IconPlayOutline16 size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconBtn}
|
||||
disabled={pending}
|
||||
onClick={() => { setDraft(goal.objective); setEditing(true) }}
|
||||
title={t('action.edit')}
|
||||
aria-label={t('action.edit')}
|
||||
>
|
||||
<IconEditOutline16 />
|
||||
</button>
|
||||
<button type="button" className={css.iconBtn} disabled={pending} onClick={() => { void handleClear(goal.id) }} title={t('action.clear')} aria-label={t('action.clear')}>
|
||||
<IconTrashOutline16 />
|
||||
</button>
|
||||
<Tooltip label={t('action.edit')} side="bottom" delayMs={500}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconBtn}
|
||||
disabled={pending}
|
||||
onClick={() => { setDraft(goal.objective); setEditing(true) }}
|
||||
aria-label={t('action.edit')}
|
||||
>
|
||||
<IconEditOutline16 size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('action.clear')} side="bottom" delayMs={500}>
|
||||
<button type="button" className={css.iconBtn} disabled={pending} onClick={() => { void handleClear(goal.id) }} aria-label={t('action.clear')}>
|
||||
<IconTrashOutline16 size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -52,7 +52,7 @@ describe('GoalBar', () => {
|
||||
expect(complete.container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('active goal: sparkle, "进行中的目标", truncated objective, edit and clear actions', () => {
|
||||
it('active goal: goal glyph, "进行中的目标", truncated objective, edit and clear actions', () => {
|
||||
const actions = makeActions()
|
||||
render(<GoalBar goal={makeGoal()} {...actions} t={t} />)
|
||||
expect(screen.getByText('进行中的目标')).toBeTruthy()
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { computeColumns } from './columns.ts'
|
||||
import { computeColumns, SIDEBAR_AUTO_COLLAPSE, SIDEBAR_DEFAULT } from './columns.ts'
|
||||
import type { createLayoutStore } from './stores.ts'
|
||||
import css from './AppFrame.module.css'
|
||||
|
||||
@@ -127,7 +127,19 @@ export function AppFrame({
|
||||
}
|
||||
}, [])
|
||||
|
||||
const cols = computeColumns(viewport, panels.sidebar, detailsSession === undefined ? 0 : panels.details)
|
||||
// Narrow viewports auto-collapse the sidebar; the store mirror keeps
|
||||
// toggleSidebar's semantics right (narrow toggles flip the manual
|
||||
// re-expand override, stores.ts). Collapsed is decided here, so the
|
||||
// solver stays breakpoint-free: a narrow re-expand passes the preference
|
||||
// (or the default when the wide preference is closed) and the center
|
||||
// absorbs the squeeze.
|
||||
const narrow = viewport < SIDEBAR_AUTO_COLLAPSE
|
||||
useEffect(() => { actions.setNarrow(narrow) }, [actions, narrow])
|
||||
const sidebarCollapsed = narrow ? !panels.narrowExpanded : panels.sidebar === 0
|
||||
const sidebarPreference = sidebarCollapsed
|
||||
? 0
|
||||
: panels.sidebar === 0 ? SIDEBAR_DEFAULT : panels.sidebar
|
||||
const cols = computeColumns(viewport, sidebarPreference, detailsSession === undefined ? 0 : panels.details)
|
||||
const colsRef = useRef(cols)
|
||||
colsRef.current = cols
|
||||
|
||||
@@ -154,7 +166,7 @@ export function AppFrame({
|
||||
ref={frameRef}
|
||||
className={css.frame}
|
||||
style={{ gridTemplateColumns: `${cols.sidebar}px minmax(0, 1fr) ${cols.details}px` }}
|
||||
data-sidebar-collapsed={panels.sidebar === 0 || undefined}
|
||||
data-sidebar-collapsed={sidebarCollapsed || undefined}
|
||||
data-details-collapsed={cols.details === 0 || undefined}
|
||||
data-dragging={dragging || undefined}
|
||||
>
|
||||
@@ -162,9 +174,10 @@ export function AppFrame({
|
||||
{/* Render-site slot call with live concession output: a closed
|
||||
sidebar keeps the mounted slot at the compact-rail width, and the
|
||||
component sees its rendered state as owner params decided here
|
||||
(collapsed follows the preference, not the resolved width). */}
|
||||
(collapsed follows the resolved rail, so a derived auto-collapse
|
||||
renders the rail UI too). */}
|
||||
{renderSlot('sidebar', {
|
||||
collapsed: panels.sidebar === 0,
|
||||
collapsed: sidebarCollapsed,
|
||||
width: cols.sidebar,
|
||||
})}
|
||||
</div>
|
||||
@@ -178,7 +191,7 @@ export function AppFrame({
|
||||
<DetailsColumn>{renderSlot('details', {})}</DetailsColumn>
|
||||
</>
|
||||
{/* The collapsed rail is fixed-width: no resize handle while closed. */}
|
||||
{panels.sidebar > 0 && <DragHandle side="sidebar" left={cols.sidebar} onStart={onSidebarStart} onDrag={onSidebarDrag} onEnd={onDragEnd} />}
|
||||
{!sidebarCollapsed && <DragHandle side="sidebar" left={cols.sidebar} onStart={onSidebarStart} onDrag={onSidebarDrag} onEnd={onDragEnd} />}
|
||||
{cols.details > 0 && <DragHandle side="details" left={viewport - cols.details} onStart={onDetailsStart} onDrag={onDetailsDrag} onEnd={onDragEnd} />}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
* deficit as the last resort. Inputs are the layout store's plain width
|
||||
* preferences (0 = closed); a closed sidebar resolves to the fixed
|
||||
* SIDEBAR_COLLAPSED control rail while closed details resolve to zero width.
|
||||
* The SIDEBAR_AUTO_COLLAPSE breakpoint is consumed by AppFrame, which decides
|
||||
* the effective sidebar preference before solving; the solver itself stays
|
||||
* breakpoint-free.
|
||||
*/
|
||||
|
||||
/** Resolved widths for one frame; center may drop below CENTER_MIN only at the final fallback. */
|
||||
@@ -24,6 +27,10 @@ export const SIDEBAR_MAX = 420
|
||||
export const SIDEBAR_DEFAULT = 280
|
||||
/** Closed-sidebar rail: a 24px icon column between 16px horizontal paddings. */
|
||||
export const SIDEBAR_COLLAPSED = 56
|
||||
/** Viewport width below which the sidebar auto-collapses to the rail (deepsuite
|
||||
* LG breakpoint); a manual toggle below it re-expands over the squeezed center
|
||||
* (stores.ts narrowExpanded). */
|
||||
export const SIDEBAR_AUTO_COLLAPSE = 1024
|
||||
/** Details drag clamp floor. */
|
||||
export const DETAILS_MIN = 300
|
||||
/** Details drag clamp ceiling. */
|
||||
|
||||
@@ -13,8 +13,14 @@ import {
|
||||
SIDEBAR_DEFAULT, SIDEBAR_MAX, SIDEBAR_MIN,
|
||||
} from './columns.ts'
|
||||
|
||||
/** Layout store state: panel width preferences in px (0 = closed). */
|
||||
type LayoutState = { sidebar: number; details: number }
|
||||
/**
|
||||
* Layout store state: panel width preferences in px (0 = closed), plus the
|
||||
* narrow-viewport pair — `narrow` mirrors AppFrame's breakpoint reading
|
||||
* (viewport < SIDEBAR_AUTO_COLLAPSE) so toggleSidebar can pick semantics, and
|
||||
* `narrowExpanded` is the manual override that re-expands the auto-collapsed
|
||||
* sidebar over the squeezed center without rewriting the width preference.
|
||||
*/
|
||||
type LayoutState = { sidebar: number; details: number; narrow: boolean; narrowExpanded: boolean }
|
||||
|
||||
/**
|
||||
* Annotation twin of the actions literal below (the export needs a declared
|
||||
@@ -24,6 +30,7 @@ type LayoutActions = {
|
||||
setSidebar: (draft: LayoutState, px: number) => void
|
||||
setDetails: (draft: LayoutState, px: number) => void
|
||||
toggleSidebar: (draft: LayoutState) => void
|
||||
setNarrow: (draft: LayoutState, narrow: boolean) => void
|
||||
openDetails: (draft: LayoutState) => void
|
||||
closeDetails: (draft: LayoutState) => void
|
||||
}
|
||||
@@ -33,16 +40,30 @@ type LayoutActions = {
|
||||
* closing a panel forgets its drag width — reopening restores the contract
|
||||
* default. Actions are the complete write set: drag writes clamp
|
||||
* into the panel's contract range and never cross the open/closed line;
|
||||
* open/close transitions write 0 / the default explicitly.
|
||||
* open/close transitions write 0 / the default explicitly. Below the
|
||||
* auto-collapse breakpoint (AppFrame feeds setNarrow) the sidebar toggle
|
||||
* flips the narrowExpanded override instead of the preference.
|
||||
* @returns the store handle (spec + type + identity + factory in one).
|
||||
*/
|
||||
export function createLayoutStore(): EngineStoreHandle<LayoutState, LayoutActions> {
|
||||
const handle = defineStore({
|
||||
init: (): LayoutState => ({ sidebar: SIDEBAR_DEFAULT, details: 0 }),
|
||||
init: (): LayoutState => ({ sidebar: SIDEBAR_DEFAULT, details: 0, narrow: false, narrowExpanded: false }),
|
||||
actions: {
|
||||
setSidebar: (d, px: number) => { d.sidebar = clampWidth(px, SIDEBAR_MIN, SIDEBAR_MAX) },
|
||||
setDetails: (d, px: number) => { d.details = clampWidth(px, DETAILS_MIN, DETAILS_MAX) },
|
||||
toggleSidebar: (d) => { d.sidebar = d.sidebar === 0 ? SIDEBAR_DEFAULT : 0 },
|
||||
// Narrow toggles flip only the override: the width preference survives
|
||||
// untouched, so re-widening restores the pre-squeeze layout.
|
||||
toggleSidebar: (d) => {
|
||||
if (d.narrow) d.narrowExpanded = !d.narrowExpanded
|
||||
else d.sidebar = d.sidebar === 0 ? SIDEBAR_DEFAULT : 0
|
||||
},
|
||||
// Crossing the breakpoint in either direction drops the override: the
|
||||
// narrow default is auto-collapsed, the wide state is the preference.
|
||||
setNarrow: (d, narrow: boolean) => {
|
||||
if (d.narrow === narrow) return
|
||||
d.narrow = narrow
|
||||
d.narrowExpanded = false
|
||||
},
|
||||
openDetails: (d) => { if (d.details === 0) d.details = DETAILS_DEFAULT },
|
||||
closeDetails: (d) => { d.details = 0 },
|
||||
},
|
||||
|
||||
@@ -284,6 +284,50 @@ describe('AppFrame', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('AppFrame — narrow-viewport auto-collapse', () => {
|
||||
it('mounts collapsed below the breakpoint with no sidebar handle', () => {
|
||||
frameWidth = 980
|
||||
const { frame, slotCalls } = mountFrame()
|
||||
expect(tracks(frame)).toEqual([SIDEBAR_COLLAPSED, 0])
|
||||
expect(frame.hasAttribute('data-sidebar-collapsed')).toBe(true)
|
||||
expect(slotCalls.filter(c => c.key === 'sidebar').at(-1)!.props).toEqual({ collapsed: true, width: SIDEBAR_COLLAPSED })
|
||||
expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('narrow toggle re-expands over the squeezed center and back', () => {
|
||||
frameWidth = 980
|
||||
const { frame, instance } = mountFrame()
|
||||
act(() => { instance.actions.toggleSidebar() })
|
||||
expect(tracks(frame)).toEqual([280, 0])
|
||||
expect(frame.hasAttribute('data-sidebar-collapsed')).toBe(false)
|
||||
expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(1)
|
||||
act(() => { instance.actions.toggleSidebar() })
|
||||
expect(tracks(frame)).toEqual([SIDEBAR_COLLAPSED, 0])
|
||||
})
|
||||
|
||||
it('a wide-closed preference re-expands at the contract default while narrow', () => {
|
||||
frameWidth = 1920
|
||||
const { frame, instance } = mountFrame()
|
||||
act(() => { instance.actions.toggleSidebar() }) // close while wide: preference 0
|
||||
frameWidth = 980
|
||||
act(() => { fireResize?.(); vi.advanceTimersByTime(20) })
|
||||
act(() => { instance.actions.toggleSidebar() })
|
||||
expect(tracks(frame)).toEqual([280, 0])
|
||||
expect(instance.getSnapshot().sidebar).toBe(0) // preference untouched
|
||||
})
|
||||
|
||||
it('shrinking across the breakpoint auto-collapses; re-widening restores the drag width', () => {
|
||||
const { frame, instance } = mountFrame()
|
||||
act(() => { instance.actions.setSidebar(400) })
|
||||
frameWidth = 980
|
||||
act(() => { fireResize?.(); vi.advanceTimersByTime(20) })
|
||||
expect(tracks(frame)).toEqual([SIDEBAR_COLLAPSED, 0])
|
||||
frameWidth = 1920
|
||||
act(() => { fireResize?.(); vi.advanceTimersByTime(20) })
|
||||
expect(tracks(frame)).toEqual([400, 0])
|
||||
})
|
||||
})
|
||||
|
||||
describe('AppFrame — guard branches', () => {
|
||||
it('pointer moves without capture are ignored (no width write)', () => {
|
||||
const { frame, instance } = mountFrame()
|
||||
|
||||
@@ -17,9 +17,9 @@ const PERSIST_KEY = 'dsh.layout.panels'
|
||||
beforeEach(() => { localStorage.clear() })
|
||||
|
||||
describe('createLayoutStore', () => {
|
||||
it('initializes the sidebar at its default width and details closed', () => {
|
||||
it('initializes the sidebar at its default width, details closed, wide viewport assumed', () => {
|
||||
const { store } = createLayoutStore().create()
|
||||
expect(store.getSnapshot()).toEqual({ sidebar: SIDEBAR_DEFAULT, details: 0 })
|
||||
expect(store.getSnapshot()).toEqual({ sidebar: SIDEBAR_DEFAULT, details: 0, narrow: false, narrowExpanded: false })
|
||||
})
|
||||
|
||||
it('each create() is an independent instance (factory is not a singleton)', () => {
|
||||
@@ -50,6 +50,30 @@ describe('createLayoutStore', () => {
|
||||
expect(store.getSnapshot().sidebar).toBe(SIDEBAR_DEFAULT)
|
||||
})
|
||||
|
||||
it('narrow toggleSidebar flips only the re-expand override; the width preference survives', () => {
|
||||
const { store, actions } = createLayoutStore().create()
|
||||
actions.setSidebar(400)
|
||||
actions.setNarrow(true)
|
||||
actions.toggleSidebar()
|
||||
expect(store.getSnapshot()).toEqual({ sidebar: 400, details: 0, narrow: true, narrowExpanded: true })
|
||||
actions.toggleSidebar()
|
||||
expect(store.getSnapshot().narrowExpanded).toBe(false)
|
||||
expect(store.getSnapshot().sidebar).toBe(400)
|
||||
})
|
||||
|
||||
it('crossing the breakpoint drops the override; a same-value setNarrow keeps it', () => {
|
||||
const { store, actions } = createLayoutStore().create()
|
||||
actions.setNarrow(true)
|
||||
actions.toggleSidebar()
|
||||
expect(store.getSnapshot().narrowExpanded).toBe(true)
|
||||
actions.setNarrow(true)
|
||||
expect(store.getSnapshot().narrowExpanded).toBe(true)
|
||||
actions.setNarrow(false)
|
||||
expect(store.getSnapshot()).toMatchObject({ narrow: false, narrowExpanded: false })
|
||||
actions.setNarrow(true)
|
||||
expect(store.getSnapshot().narrowExpanded).toBe(false)
|
||||
})
|
||||
|
||||
it('openDetails uses the contract default, preserves an open width, and closeDetails zeroes', () => {
|
||||
const { store, actions } = createLayoutStore().create()
|
||||
actions.openDetails()
|
||||
@@ -72,6 +96,8 @@ describe('createLayoutStore', () => {
|
||||
expect(second.store.getSnapshot()).toEqual({
|
||||
sidebar: SIDEBAR_DEFAULT,
|
||||
details: 0,
|
||||
narrow: false,
|
||||
narrowExpanded: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,6 +13,7 @@ function fakePanels(): PanelActions {
|
||||
setSidebar: vi.fn(),
|
||||
setDetails: vi.fn(),
|
||||
toggleSidebar: vi.fn(),
|
||||
setNarrow: vi.fn(),
|
||||
openDetails: vi.fn(),
|
||||
closeDetails: vi.fn(),
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
height: 28px;
|
||||
padding: 0 4px 0 8px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
/* Rounded chip chrome, matching the sibling permission trigger. */
|
||||
border-radius: 24px;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
|
||||
@@ -26,7 +26,8 @@
|
||||
|
||||
.rows {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
/* Extra air between the title/intro block and the first provider card. */
|
||||
margin: 12px 0 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -70,6 +71,10 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.primaryButton:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-button-primary-hover);
|
||||
}
|
||||
|
||||
.secondaryButton {
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 999px;
|
||||
@@ -80,14 +85,24 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.secondaryButton:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover-solid);
|
||||
}
|
||||
|
||||
.dangerButton {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 4px 8px;
|
||||
background: none;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dangerButton:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover-danger);
|
||||
}
|
||||
|
||||
.primaryButton:disabled,
|
||||
.secondaryButton:disabled,
|
||||
.dangerButton:disabled {
|
||||
@@ -147,6 +162,10 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.linkButton:hover:not(:disabled) {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.linkButton:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
@@ -171,17 +190,23 @@
|
||||
}
|
||||
|
||||
.addButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
align-self: flex-start;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 999px;
|
||||
padding: 8px 16px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
background: var(--dsw-alias-bg-layer-3);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.addButton:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover-solid);
|
||||
}
|
||||
|
||||
.addButton:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
@@ -246,6 +271,20 @@
|
||||
color: var(--dsw-alias-label-dimmed);
|
||||
}
|
||||
|
||||
/* Select variant of .input: replaces the OS arrow (which sits flush against
|
||||
the right edge) with the shared 12px chevron inset like the composer's
|
||||
.select chips; the right pad reserves its cell. */
|
||||
.selectInput {
|
||||
appearance: none;
|
||||
padding-right: 32px;
|
||||
/* Data-URI SVGs cannot resolve CSS variables; #81858C is the caption gray
|
||||
shared by both themes. */
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M3 4.5L6 7.5L9 4.5' stroke='%2381858C' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 12px center;
|
||||
background-size: 12px 12px;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
import { useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { Button, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { Button, IconPlusOutline16, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { messageOf } from './store.ts'
|
||||
import type { ModelsSettingsState, ModelsSettingsStore, ProviderRow } from './store.ts'
|
||||
@@ -245,7 +245,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
<div className={styles['field']}>
|
||||
<span className={styles['fieldLabel']}>{t('provider')}</span>
|
||||
<select
|
||||
className={styles['input']}
|
||||
className={`${styles['input']} ${styles['selectInput']}`}
|
||||
value={addTarget.provider}
|
||||
aria-label={t('provider')}
|
||||
onChange={(event) => {
|
||||
@@ -287,7 +287,9 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
setEditing(targetOf(first))
|
||||
}}
|
||||
>
|
||||
{`+ ${t('add')}`}
|
||||
{/* Same glyph as the composer's attach button. */}
|
||||
<IconPlusOutline16 size={14} />
|
||||
{t('add')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -275,7 +275,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
<div className={styles['field']}>
|
||||
<span className={styles['fieldLabel']}>{t('effort')}</span>
|
||||
<select
|
||||
className={styles['input']}
|
||||
className={`${styles['input']} ${styles['selectInput']}`}
|
||||
value={stringAt(draft, effortField) ?? ''}
|
||||
aria-label={t('effort')}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -155,7 +155,7 @@ describe('ModelsSection', () => {
|
||||
expect(screen.getByText('openai')).toBeTruthy()
|
||||
expect(screen.queryByText('Active')).toBeNull()
|
||||
expect(screen.queryByText('Inactive')).toBeNull()
|
||||
expect(screen.getByText(`+ ${en.add}`)).toBeTruthy()
|
||||
expect(screen.getByText(en.add)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('turns the setup card into a row once the credential reports configured', async () => {
|
||||
@@ -332,7 +332,7 @@ describe('ModelsSection', () => {
|
||||
|
||||
it('adds a dormant provider with a derived reference and stores its key', async () => {
|
||||
const { mutate, set } = await mountSection()
|
||||
fireEvent.click(screen.getByText(`+ ${en.add}`))
|
||||
fireEvent.click(screen.getByText(en.add))
|
||||
const pick = await screen.findByLabelText<HTMLSelectElement>(en.provider)
|
||||
expect([...pick.options].map(option => option.value)).toEqual(['anthropic', 'broken', 'plain'])
|
||||
expect(pick.value).toBe('anthropic')
|
||||
@@ -356,7 +356,7 @@ describe('ModelsSection', () => {
|
||||
|
||||
it('switches the add card target and degrades unknown or broken targets loudly', async () => {
|
||||
await mountSection()
|
||||
fireEvent.click(screen.getByText(`+ ${en.add}`))
|
||||
fireEvent.click(screen.getByText(en.add))
|
||||
const pick = await screen.findByLabelText<HTMLSelectElement>(en.provider)
|
||||
fireEvent.change(pick, { target: { value: 'broken' } })
|
||||
await screen.findByText(/unresolvable settings path/)
|
||||
@@ -374,7 +374,7 @@ describe('ModelsSection', () => {
|
||||
const { set } = await mountSection({
|
||||
mutate: vi.fn(() => Promise.resolve(fail('llm-pi-ai: unknown pi-ai provider "bogus"'))),
|
||||
})
|
||||
fireEvent.click(screen.getByText(`+ ${en.add}`))
|
||||
fireEvent.click(screen.getByText(en.add))
|
||||
await screen.findByLabelText(en.provider)
|
||||
const keys = screen.getAllByLabelText<HTMLInputElement>(en.keyInput)
|
||||
fireEvent.change(keys[keys.length - 1] as HTMLInputElement, { target: { value: 'sk-x' } })
|
||||
@@ -554,7 +554,7 @@ describe('ModelsSection', () => {
|
||||
/>)
|
||||
expect(screen.getByText(en.readOnly)).toBeTruthy()
|
||||
expect(screen.getAllByText<HTMLButtonElement>(en.remove).every(button => button.disabled)).toBe(true)
|
||||
expect(screen.getByText<HTMLButtonElement>(`+ ${en.add}`).disabled).toBe(true)
|
||||
expect(screen.getByText<HTMLButtonElement>(en.add).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('toggles the row editor closed on a second edit click and on cancel', async () => {
|
||||
@@ -573,10 +573,10 @@ describe('ModelsSection', () => {
|
||||
|
||||
it('cancels the add card back to the add button', async () => {
|
||||
await mountSection()
|
||||
fireEvent.click(screen.getByText(`+ ${en.add}`))
|
||||
fireEvent.click(screen.getByText(en.add))
|
||||
await screen.findByLabelText(en.provider)
|
||||
fireEvent.click(screen.getAllByText(en.cancel)[1] as HTMLElement)
|
||||
await screen.findByText(`+ ${en.add}`)
|
||||
await screen.findByText(en.add)
|
||||
expect(screen.queryByLabelText(en.provider)).toBeNull()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* Visual spec mirrors deepsuite @deepseek/ui Tooltip.css (size m, no arrow),
|
||||
except padding tightened 6/12 -> 4/8 and radius 10 -> 8 by product ruling:
|
||||
tooltip-bg plate,
|
||||
except padding tightened 6/12 -> 3/7, type 14/22 -> 13/20, and radius
|
||||
10 -> 8 by product ruling: tooltip-bg plate,
|
||||
one text color across both themes (the plate stays dark in light and dark
|
||||
mode). Behavior (fixed positioning off the anchor rect) is local — the
|
||||
upstream Floating stack is intentionally not vendored. */
|
||||
@@ -8,13 +8,20 @@
|
||||
.bubble {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
padding: 4px 8px;
|
||||
/* Fixed-position shrink-to-fit measures only the space from `left` to the
|
||||
viewport edge, so anchors near the right edge would wrap early;
|
||||
max-content sizes by the label alone, capped at half the viewport. */
|
||||
width: max-content;
|
||||
max-width: 50vw;
|
||||
padding: 3px 7px;
|
||||
border-radius: 8px;
|
||||
background: var(--dsw-alias-tooltip-bg);
|
||||
color: var(--dsw-static-neutral-bluish-00);
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
white-space: pre-line;
|
||||
/* Unbreakable tokens (URLs, paths) must not push past max-width. */
|
||||
overflow-wrap: break-word;
|
||||
pointer-events: none;
|
||||
animation: tooltip-in 150ms var(--ds-ease-in-out);
|
||||
}
|
||||
@@ -27,6 +34,10 @@
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.bubble[data-side='top'] {
|
||||
transform: translate(-50%, -100%);
|
||||
}
|
||||
|
||||
@keyframes tooltip-in {
|
||||
from { opacity: 0; }
|
||||
}
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
// Hover/focus label bubble (figma tooltip pill: dark plate, white text).
|
||||
// TODO: interaction is a placeholder (no flip on viewport collision or
|
||||
// arrow) — visuals and behavior get a proper pass later.
|
||||
// TODO: interaction is a placeholder (horizontal overflow clamps, but there
|
||||
// is no vertical flip on viewport collision and no arrow) — visuals and
|
||||
// behavior get a proper pass later.
|
||||
// The anchor is the child element itself (cloneElement, no wrapper node), so
|
||||
// attaching a tooltip never changes the anchor's layout context. The bubble is
|
||||
// position:fixed and coordinates come from the anchor's rect at show time, so
|
||||
// it escapes ancestor overflow clipping (the sidebar rail clips its column)
|
||||
// without a portal.
|
||||
|
||||
import { cloneElement, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { cloneElement, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import type { FocusEventHandler, MouseEventHandler, MutableRefObject, ReactElement, Ref } from 'react'
|
||||
import css from './Tooltip.module.css'
|
||||
|
||||
/** Bubble placement relative to the anchor. */
|
||||
export type TooltipSide = 'right' | 'bottom'
|
||||
export type TooltipSide = 'right' | 'bottom' | 'top'
|
||||
|
||||
/** Props Tooltip injects into its anchor child; the child's own handlers are chained ahead of the tooltip's. */
|
||||
interface AnchorProps {
|
||||
@@ -44,6 +45,29 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false,
|
||||
else if (childRef != null) (childRef as MutableRefObject<HTMLElement | null>).current = el
|
||||
}, [childRef])
|
||||
const [pos, setPos] = useState<{ x: number; y: number } | null>(null)
|
||||
const bubble = useRef<HTMLSpanElement | null>(null)
|
||||
// Horizontal viewport clamp: fixed positioning knows nothing about edges, so
|
||||
// a centered bubble near the right edge would clip. Each measurement resets
|
||||
// the base position before applying a direct style offset, allowing a shorter
|
||||
// label or wider viewport to release a previous clamp without another render.
|
||||
useLayoutEffect(() => {
|
||||
if (pos === null) return
|
||||
const clamp = () => {
|
||||
const el = bubble.current
|
||||
/* v8 ignore next -- pos is set only while the bubble is mounted. */
|
||||
if (el === null) return
|
||||
const EDGE_MARGIN = 12
|
||||
el.style.left = `${pos.x}px`
|
||||
const r = el.getBoundingClientRect()
|
||||
let dx = 0
|
||||
if (r.right > window.innerWidth - EDGE_MARGIN) dx = window.innerWidth - EDGE_MARGIN - r.right
|
||||
if (r.left + dx < EDGE_MARGIN) dx = EDGE_MARGIN - r.left
|
||||
el.style.left = `${pos.x + dx}px`
|
||||
}
|
||||
clamp()
|
||||
window.addEventListener('resize', clamp)
|
||||
return () => { window.removeEventListener('resize', clamp) }
|
||||
}, [label, pos])
|
||||
const showTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
// Hover and focus are independent triggers: the bubble hides only after
|
||||
// BOTH clear (hovering away from a focused anchor must not drop it).
|
||||
@@ -73,7 +97,9 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false,
|
||||
const r = el.getBoundingClientRect()
|
||||
setPos(side === 'right'
|
||||
? { x: r.right + 10, y: r.top + r.height / 2 }
|
||||
: { x: r.left + r.width / 2, y: r.bottom + 8 })
|
||||
: side === 'top'
|
||||
? { x: r.left + r.width / 2, y: r.top - 8 }
|
||||
: { x: r.left + r.width / 2, y: r.bottom + 8 })
|
||||
}
|
||||
const showAfterHoverDelay = () => {
|
||||
cancelShow()
|
||||
@@ -101,7 +127,7 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false,
|
||||
onBlur: (e) => { children.props.onBlur?.(e); triggers.current.focus = false; hide() },
|
||||
})}
|
||||
{pos !== null && (
|
||||
<span className={css.bubble} data-side={side} style={{ left: pos.x, top: pos.y }} role="tooltip">
|
||||
<span ref={bubble} className={css.bubble} data-side={side} style={{ left: pos.x, top: pos.y }} role="tooltip">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user