Merge remote-tracking branch 'origin/master' into worktree/open-settings-config-file

# Conflicts:
#	packages/client/connection/README.i18n.yaml
#	packages/client/connection/README.md
#	packages/client/connection/README.zh.md
This commit is contained in:
Yichen Jiang
2026-08-05 11:05:52 +08:00
487 changed files with 10605 additions and 1736 deletions

View File

@@ -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: 33ef3431a2a0a6c8c0f68e040da190741fb482a6
README.zh.md: 2b6280dd957a809d61d12601cdca5af2eccbcdab
README.md: be521feb4826f9ad804f61532dee0aa85f39de7e
README.zh.md: bd58ce649c75143b94c1e5dea0241e1f9a934e69

View File

@@ -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`/`openDocument`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, 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`, `settings.openDocument`, `settings.update`, `settings.replace`, `settings.mutate`, `credentials.describe`, `credentials.set`, and `credentials.unset`; reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, 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

View File

@@ -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`/`openDocument`/`update`/`replace`/`mutate``credentials.describe`/`set`/`unset`读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法而这些方法在真正的认证层出现之前仍只限回环本机。平台子类WebApiClient/FixtureApiClient、ConnectionController 循环和 fixture 数据源都属于包内部apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam以及循环的 sink配置类型。真实浏览器载体以 HTTP POST 发送 unaryrespond并为 `events.mux``events.host` 各开一条只下行的 WebSocketfixture 与进程内载体继续满足同一双流抽象。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory``host.openPath`,以及整个配置面——`settings.describe``settings.openDocument``settings.update``settings.replace``settings.mutate``credentials.describe``credentials.set``credentials.unset`读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 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

View File

@@ -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,15 +34,14 @@
"@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",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
"lib/types/**/*.d.ts"
],
"peerDependencies": {
"@deepseek-ai/dsh-host-webserver": "^0.0.1",
@@ -52,6 +51,7 @@
"devDependencies": {
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/ws": "^8.18.1",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -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`

View File

@@ -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')

View File

@@ -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).

View File

@@ -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()
}
}
}

View File

@@ -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'
@@ -77,6 +78,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,
@@ -93,8 +95,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) })
}

View 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'))
}

View File

@@ -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)
})
})

View File

@@ -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 () => {

View 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
}
})
})

View File

@@ -49,8 +49,6 @@
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
"lib/types/**/*.d.ts"
]
}

View File

@@ -51,9 +51,7 @@
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
"lib/types/**/*.d.ts"
],
"scripts": {
"bundle": "tsdown",

View File

@@ -42,9 +42,7 @@
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
"lib/types/**/*.d.ts"
],
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
README.md: 89e58f967f852bb0786a5b7d73fa8e924fa282e0
README.zh.md: 960e2fceede1b500af9ee2063ec9283e2b7b271a
README.md: dd780369a1d888dde2579e436afe2ce1e6dcfdd1
README.zh.md: 5574ad6452c6a2d94fb63da7e53b98e8d074f1c9

View File

@@ -8,6 +8,8 @@ Client cordis boot and React-free object services: SlotsService wraps SlotCore a
Workspace and Session lists have independent monotone `pending``ready` baseline phases and separate refresh activity/error state. Incremental upsert/removal frames and unary mutation echoes arriving during a list request replay over its response. The first successful baseline establishes Host order; later refreshes update rows and membership without changing the relative order of identities already shown. Removed Workspace ids retain process-local tombstones so late changed frames cannot resurrect them; reconnect still takes `workspace.list` as the baseline. Workspace recency is derived only after both baselines are ready and never changes Workspace list order.
`SessionSummary.pendingInteraction` classifies the live user action blocking a Session as `approval`, `plan-review`, or `question`. `SessionManager` tracks answerable requested/resolved mux frames by their stable request identities even before a Session object is instantiated; pre-instantiation buffering retains every live request, replaces replay duplicates, and removes resolved requests so the list status always has a matching answerable `PendingWait` when the Session is opened. The first pending question takes presentation priority over concurrent approvals to match composer routing, while only a request that satisfies the plan-review composer's binary rendering constraints keeps the distinct `plan-review` status. The state is connection-generation scoped: disconnect clears it, and mux-open replay restores only requests that remain pending.
`WorkspacesService.delete(workspaceId)` removes the registration from the client projection after the successful unary response; the matching `host/workspace-removed` frame is idempotent and synchronizes other tabs. Session state and the current Session selection are independent, so accounted Sessions immediately project under Ungrouped after their Workspace disappears.
`WorkspaceListState.archivedSessionIds` mirrors the Host's registry-global archive set (a `readonly SessionId[]` in Host order, replaced only when membership changes; consumers needing O(1) lookups build a transient Set). It is full-snapshot state: the `workspace.list` baseline, the `archiveSession` unary echo, and the `host/archived-sessions-changed` frame each install the complete set. `WorkspacesService.archiveSession(sessionId)` archives over the wire; the projection sweep clears the current selection into the New Session view state whenever it lands in the archive set — one rule covering the local echo, another tab's frame, and a reconnect baseline restoring a selection archived while this client was away. A set installed while a `workspace.list` request is in flight also supersedes that stale baseline's set. Grouping surfaces hide members everywhere while the session rows stay in the list store.

View File

@@ -8,6 +8,8 @@
Workspace 和 Session 列表各自具有单调的 `pending``ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量插入或更新/移除帧与一元变更回显会在其响应之上回放。第一次成功的基线建立 Host 顺序;后续刷新更新行和成员关系,但不改变已经显示的标识之间的相对顺序。已移除的 Workspace id 会保留进程本地删除标记,避免延迟到达的 changed 帧将其复活;重连仍以 `workspace.list` 作为基线。Workspace 新近程度只在两条基线都 ready 后派生,且绝不改变 Workspace 列表顺序。
`SessionSummary.pendingInteraction` 将阻塞 Session 的实时用户操作分类为 `approval``plan-review``question``SessionManager` 依据稳定的请求标识跟踪可应答请求的 requested/resolved mux 帧,即使 `Session` 对象尚未实例化也不例外;实例化前的缓冲会保留每个仍有效的请求,替换回放产生的重复项,并移除已解决的请求,因此打开 Session 时,列表状态始终有一个对应的可应答 `PendingWait`。审批与问题并发时,第一个 pending 问题具有更高的呈现优先级,以匹配 composer 路由;只有满足 plan-review composer 二元呈现约束的请求才会保留独立的 `plan-review` 状态。该状态的作用域限定在连接代次内断连时清除mux 打开时的回放只恢复仍处于 pending 的请求。
`WorkspacesService.delete(workspaceId)` 在一元响应成功后从客户端投影中移除注册记录;对应的 `host/workspace-removed` 帧具有幂等性并负责同步其他标签页。Session 状态与当前 Session selection 相互独立,因此 Workspace 消失后,其已纳入客户端投影的 Session 会立即投影到 Ungrouped 下。
`WorkspaceListState.archivedSessionIds` 镜像 Host 的注册表级全局归档集合(一个按 Host 顺序的 `readonly SessionId[]`,仅在成员变化时才替换;需要 O(1) 查询的消费方自建临时 Set。它是全快照状态`workspace.list` 基线、`archiveSession` 一元回声和 `host/archived-sessions-changed` 帧各自安装完整集合。`WorkspacesService.archiveSession(sessionId)` 通过 wire 归档;投影层在当前 selection 落入归档集合时统一清空为 New Session 视图状态——一条规则同时覆盖本地回声、其他标签页的帧、以及重连基线恢复出一个离线期间被归档的 selection。在 `workspace.list` 请求进行中安装的集合还会取代该过期基线携带的集合。各分组视图在所有位置隐藏集合成员,而会话行本身仍留在列表 store 中。

View File

@@ -59,8 +59,6 @@
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
"lib/types/**/*.d.ts"
]
}

View File

@@ -59,7 +59,9 @@ export type {
export type { ConversationHistoryProjection } from './session-history/history-fold.ts'
export type { SessionHistoryInspection } from './sessions/history.ts'
export { PendingWait } from './sessions/pending.ts'
export type { PendingInteraction, PendingKind, PendingPayloads } from './sessions/pending.ts'
export type {
PendingInteraction, PendingInteractionStatus, PendingKind, PendingPayloads,
} from './sessions/pending.ts'
// Projection value store (session-projection RFC, push model): host-computed
// whole values per key; domains ship projection support with zero client code.
export type {

View File

@@ -4,6 +4,7 @@
import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
import type { PendingInteractionStatus } from './pending.ts'
/** Host list summary enriched with the latest mux-projected durable title. */
export interface TitledSessionSummary extends SessionSummary {
@@ -12,7 +13,7 @@ export interface TitledSessionSummary extends SessionSummary {
projectionValues?: Readonly<Partial<SessionProjectionMap>>
}
/** One flattened session-list row (summary + lineage indent depth + live pending-approval bit). */
/** One flattened session-list row with lineage depth and live pending interaction. */
export interface SessionListEntry {
sessionId: SessionId
title?: string
@@ -26,8 +27,8 @@ export interface SessionListEntry {
cwd?: string
/** Current host-computed projection values for list consumers. */
projectionValues?: Readonly<Partial<SessionProjectionMap>>
/** An approval question is pending on this session (mux-frame derived; the sidebar's amber dot). */
waitingApproval: boolean
/** User interaction currently blocking this session, derived from live mux frames. */
pendingInteraction?: PendingInteractionStatus
/** Lineage indent depth: root = 0; the UI just multiplies by the indent width. */
depth: number
}
@@ -37,10 +38,13 @@ export interface SessionListEntry {
* follows the established input order; this projection never re-sorts a
* hydrated list from mutable timestamps.
* @param summaries - the host's session.list items.
* @param waitingApproval - sessions with a pending approval question (manager-owned live fact; absent = false).
* @param pendingInteractions - current manager-owned interaction status by session.
* @returns display rows in render order.
*/
export function flattenLineage(summaries: readonly TitledSessionSummary[], waitingApproval?: ReadonlySet<SessionId>): SessionListEntry[] {
export function flattenLineage(
summaries: readonly TitledSessionSummary[],
pendingInteractions?: ReadonlyMap<SessionId, PendingInteractionStatus>,
): SessionListEntry[] {
const byId = new Map<SessionId, TitledSessionSummary>()
for (const s of summaries) byId.set(s.sessionId, s)
@@ -64,7 +68,12 @@ export function flattenLineage(summaries: readonly TitledSessionSummary[], waiti
return
}
visited.add(s.sessionId)
out.push({ ...s, waitingApproval: waitingApproval?.has(s.sessionId) ?? false, depth })
const pendingInteraction = pendingInteractions?.get(s.sessionId)
out.push({
...s,
...(pendingInteraction === undefined ? {} : { pendingInteraction }),
depth,
})
const kids = children.get(s.sessionId)
if (kids === undefined) return
for (const kid of kids) walk(kid, depth + 1)

View File

@@ -12,6 +12,7 @@ import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
import { mergeOrderedBaseline } from '../ordered-baseline.ts'
import type { SessionListEntry, TitledSessionSummary } from './lineage.ts'
import { flattenLineage } from './lineage.ts'
import type { PendingInteractionStatus } from './pending.ts'
// Type-only merge edge: the title domain's client-namespace outlet declares
// the 'title' projection key this manager projects into list rows (and any
// useProjection('title') consumer reads). Zero value imports by construction.
@@ -70,23 +71,44 @@ type SessionListMutation =
/** Local first-send flip: the sender clears blank without waiting for a host frame. */
| { kind: 'engaged'; sessionId: SessionId }
/** Per-session cap for pre-instantiation approval/question buffering (low-frequency frames; a few dozen covers any real backlog). */
const PENDING_BUFFER_CAP = 32
/** Stable identity of a frame retained until an uninstantiated Session can consume it. */
function bufferedRequestKey(envelope: RpcRequest<MuxFrame>): string | undefined {
const frame = envelope.payload
switch (frame.type) {
case 'approval/requested': return `a:${frame.approvalId}`
case 'question/requested': return `q:${envelope.rpcId}`
case 'session/queue': return 'queue'
/* v8 ignore next -- pendingBuffers contains only the three frame types above. */
default: return undefined
}
}
/** Match ui-question's binary plan-review routing at the wire boundary. */
function questionInteractionStatus(
questions: Extract<MuxFrame, { type: 'question/requested' }>['questions'],
): PendingInteractionStatus {
if (questions.length !== 1) return 'question'
const question = questions[0] as typeof questions[number]
const intent = question.intent
if (intent?.kind !== 'plan-review' || question.detail === undefined) return 'question'
if (question.multiSelect === true) return 'question'
const options = question.options ?? []
if (options.length > 2) return 'question'
return options.some(option => option.label === intent.approve) ? 'plan-review' : 'question'
}
/** Instance cluster + frame entry + the session list (see the web client architecture RFC). */
export class SessionManager {
private readonly sessions = new Map<SessionId, Session>()
/** Approval/question frame buffer for uninstantiated sessions: pending interactions never hit
* history (cannot be backfilled on open), the one frame class that must not take the
* drop-and-backfill path; replayed and cleared on instantiation. Bounded per session (these
* frames are low-frequency; overflow drops oldest) and dropped on session-removed (audit S7). */
/** Pre-instantiation buffer for answerable requests and the queued-turn snapshot, which history
* cannot reconstruct on open. Live requests remain until resolution; queue and replay duplicates
* compact by identity. Instantiation replays and clears it, while removal drops it (audit S7). */
private readonly pendingBuffers = new Map<SessionId, RpcRequest<MuxFrame>[]>()
/** Outstanding approval questions per session, keyed by approvalId (idempotent under mux-open
* replays of the same requested frame). Manager-owned rather than read off Session instances
* because the sidebar must light up for sessions never instantiated. Cleared per connection
* generation — the reopen replay re-adds still-pending questions — and on session-removed. */
private readonly waitingApprovals = new Map<SessionId, Set<string>>()
/** Outstanding answerable interactions per session, keyed by their stable request identity.
* Manager-owned rather than read off Session instances because the sidebar must light up for
* sessions never instantiated. Cleared per connection generation — the reopen replay re-adds
* still-pending requests — and on session-removed. */
private readonly pendingInteractions = new Map<SessionId, Map<string, PendingInteractionStatus>>()
/** Per-session projection value stores, retained independently of instance arrival (the
* title-snapshot precedent, generalized): push frames land here whether or not the Session
* is instantiated (list rows read the 'title' key), and an instantiated Session adopts the
@@ -567,6 +589,26 @@ export class SessionManager {
return this.listSnapshotCache
}
/** Add or refresh one stable pending-interaction identity. */
private trackPending(sessionId: SessionId, key: string, status: PendingInteractionStatus): void {
let interactions = this.pendingInteractions.get(sessionId)
if (interactions === undefined) {
interactions = new Map()
this.pendingInteractions.set(sessionId, interactions)
}
if (interactions.get(key) === status) return
interactions.set(key, status)
this.notifier.markDirty()
}
/** Settle one pending-interaction identity without disturbing sibling waits. */
private resolvePending(sessionId: SessionId, key: string): void {
const interactions = this.pendingInteractions.get(sessionId)
if (interactions === undefined || !interactions.delete(key)) return
if (interactions.size === 0) this.pendingInteractions.delete(sessionId)
this.notifier.markDirty()
}
// ---- ConnectionController sinks (wired by boot) ----
/**
@@ -592,11 +634,10 @@ export class SessionManager {
// them so last-wins cannot pin a phantom value over recomputed truth.
this.projectionStores.get(frame.sessionId)?.truncate(frame.lastSeq)
this.notifier.markDirty()
// New mux-generation baseline: buffered session/queue frames belong to
// the previous generation and the host is about to resend the live
// snapshot — drop them, or every reconnect appends a duplicate batch
// (and enough reconnects push real approval/question frames past the
// cap). Same re-baseline signal Session uses for its own mirror.
// New mux-generation baseline: discard the previous queue snapshot.
// The host omits session/queue when the live queue is empty, so retaining
// it could replay stale work when the Session is instantiated later.
// This is the same re-baseline signal Session uses for its own mirror.
const buffered = this.pendingBuffers.get(frame.sessionId)
if (buffered !== undefined) {
const kept = buffered.filter(item => item.payload.type !== 'session/queue')
@@ -606,43 +647,54 @@ export class SessionManager {
}
}
}
// List-level waiting-approval bit (the sidebar amber dot): tracked here for
// every session, instantiated or not; approvalId keys make replays idempotent.
// List-level pending-interaction status (the sidebar amber dot): tracked
// for every session, instantiated or not; stable keys make replays idempotent.
if (frame.type === 'approval/requested') {
let ids = this.waitingApprovals.get(frame.sessionId)
if (ids === undefined) this.waitingApprovals.set(frame.sessionId, ids = new Set())
if (!ids.has(frame.approvalId)) {
ids.add(frame.approvalId)
this.notifier.markDirty()
}
this.trackPending(frame.sessionId, `a:${frame.approvalId}`, 'approval')
} else if (frame.type === 'approval/resolved') {
const ids = this.waitingApprovals.get(frame.sessionId)
if (ids !== undefined && ids.delete(frame.approvalId)) {
if (ids.size === 0) this.waitingApprovals.delete(frame.sessionId)
this.notifier.markDirty()
}
this.resolvePending(frame.sessionId, `a:${frame.approvalId}`)
} else if (frame.type === 'question/requested') {
this.trackPending(
frame.sessionId,
`q:${envelope.rpcId}`,
questionInteractionStatus(frame.questions),
)
} else if (frame.type === 'question/resolved') {
this.resolvePending(frame.sessionId, `q:${frame.questionRpcId}`)
}
const session = this.sessions.get(frame.sessionId)
if (session === undefined) {
// Approval/question/queue frames never hit history: buffer for replay on
// instantiation; everything else drops (not instantiated — history fully
// backfills on open).
// Answerable requests never hit history: retain each live identity until
// instantiation, compacting replay duplicates and resolutions so list
// status cannot outlive the PendingWait the user would need to answer.
// Queue is a latest-value snapshot; everything else drops because open
// backfills it from history.
switch (frame.type) {
case 'approval/requested':
case 'approval/resolved':
case 'question/requested':
case 'question/resolved':
case 'session/queue': {
const buffer = this.pendingBuffers.get(frame.sessionId) ?? []
const prior = frame.type === 'session/queue'
? buffer.findIndex(item => item.payload.type === 'session/queue')
: -1
if (prior !== -1) buffer.splice(prior, 1)
buffer.push(envelope)
if (buffer.length > PENDING_BUFFER_CAP) buffer.splice(0, buffer.length - PENDING_BUFFER_CAP)
const key = frame.type === 'approval/requested'
? `a:${frame.approvalId}`
: frame.type === 'question/requested' ? `q:${envelope.rpcId}` : 'queue'
const prior = buffer.findIndex(item => bufferedRequestKey(item) === key)
if (prior === -1) buffer.push(envelope)
else buffer[prior] = envelope
this.pendingBuffers.set(frame.sessionId, buffer)
return
}
case 'approval/resolved':
case 'question/resolved': {
const buffer = this.pendingBuffers.get(frame.sessionId)
if (buffer === undefined) return
const key = frame.type === 'approval/resolved'
? `a:${frame.approvalId}`
: `q:${frame.questionRpcId}`
const prior = buffer.findIndex(item => bufferedRequestKey(item) === key)
if (prior !== -1) buffer.splice(prior, 1)
if (buffer.length === 0) this.pendingBuffers.delete(frame.sessionId)
return
}
default:
return
}
@@ -689,7 +741,7 @@ export class SessionManager {
this.sessions.get(frame.sessionId)?.handleRemoved()
}
this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation
this.waitingApprovals.delete(frame.sessionId) // a removed session cannot wait on anyone
this.pendingInteractions.delete(frame.sessionId) // a removed session cannot wait on anyone
if (!durableSubagent) this.projectionStores.delete(frame.sessionId)
// A pull already in flight was requested before this removal and can
// carry the pre-removal parentAvailable:true, which would resurrect
@@ -735,20 +787,19 @@ export class SessionManager {
* The moment a connection generation dies (before any next-generation frame
* can arrive — onConnected waits for the readiness handshake while replayed
* frames flow from stream open, so clearing there would race the replay):
* drop generation-scoped live state. Approvals resolved while disconnected
* send no frame, so the stale bits and the buffered answerable frames must
* not survive into the next generation — the mux-open replay re-adds every
* still-pending question with its live rpcId.
*/
* drop generation-scoped live state. Interactions resolved while disconnected
* send no frame, so stale statuses and buffered answerable frames must not
* survive into the next generation — mux-open replay re-adds every still-pending
* request with its live rpcId.
*/
handleDisconnected(): void {
if (this.waitingApprovals.size > 0) {
this.waitingApprovals.clear()
if (this.pendingInteractions.size > 0) {
this.pendingInteractions.clear()
this.notifier.markDirty()
}
for (const [sessionId, buffer] of [...this.pendingBuffers]) {
const kept = buffer.filter(item =>
item.payload.type !== 'approval/requested' && item.payload.type !== 'approval/resolved'
&& item.payload.type !== 'question/requested' && item.payload.type !== 'question/resolved')
item.payload.type !== 'approval/requested' && item.payload.type !== 'question/requested')
if (kept.length === buffer.length) continue
if (kept.length === 0) this.pendingBuffers.delete(sessionId)
else this.pendingBuffers.set(sessionId, kept)
@@ -855,7 +906,15 @@ export class SessionManager {
...(projectionValues === undefined ? {} : { projectionValues }),
}
})
const fresh = flattenLineage(merged, new Set(this.waitingApprovals.keys()))
const pendingInteractions = new Map<SessionId, PendingInteractionStatus>()
for (const [sessionId, interactions] of this.pendingInteractions) {
const statuses = [...interactions.values()]
// The composer selects the first question ahead of approval. Mirror that
// answer order so the sidebar names the interaction the user can act on.
const status = statuses.find(candidate => candidate !== 'approval') ?? statuses[0]
if (status !== undefined) pendingInteractions.set(sessionId, status)
}
const fresh = flattenLineage(merged, pendingInteractions)
const items = fresh.map((entry) => {
const prev = this.entryCache.get(entry.sessionId)
if (
@@ -863,7 +922,7 @@ export class SessionManager {
&& prev.blank === entry.blank
&& prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd
&& prev.origin === entry.origin && prev.title === entry.title && prev.depth === entry.depth
&& prev.waitingApproval === entry.waitingApproval
&& prev.pendingInteraction === entry.pendingInteraction
&& prev.projectionValues === entry.projectionValues
) return prev
this.entryCache.set(entry.sessionId, entry)

View File

@@ -15,6 +15,9 @@ export interface PendingPayloads {
/** Pending-interaction discriminant (the keys of PendingPayloads). */
export type PendingKind = keyof PendingPayloads
/** Session-list summary of the user action currently blocking progress. */
export type PendingInteractionStatus = 'approval' | 'plan-review' | 'question'
/** Kind-discriminated union of concrete waits: narrowing on `kind` types `payload`. */
export type PendingInteraction = { [K in PendingKind]: PendingWait<K> }[PendingKind]

View File

@@ -33,6 +33,7 @@ import type { ISessions } from '../contract/sessions.ts'
import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts'
import { SessionManager } from './manager.ts'
import type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './manager.ts'
import type { PendingInteractionStatus } from './pending.ts'
import { SessionProvideChannel } from './provide.ts'
import type { Session } from './session.ts'
@@ -48,8 +49,8 @@ export interface SessionSummary {
/** Coarse durable origin for navigation filtering; not a continuation capability. */
origin?: 'subagent'
running: boolean
/** An approval question is pending on this session (sidebar amber-dot state). */
waitingApproval: boolean
/** User interaction currently blocking this session (sidebar amber-dot state). */
pendingInteraction?: PendingInteractionStatus
/**
* Empty-log bit (host summary derivation mirror). New Session reuses a blank
* one targeting the same workspace. Filtering stays with the consumer: the
@@ -613,9 +614,11 @@ export class SessionsService implements ISessions {
id: entry.sessionId,
displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId),
running: entry.running,
waitingApproval: entry.waitingApproval,
blank: entry.blank,
updatedAt: entry.updatedAt,
...(entry.pendingInteraction === undefined
? {}
: { pendingInteraction: entry.pendingInteraction }),
...(entry.projectionValues === undefined
? {}
: { projectionValues: entry.projectionValues }),
@@ -643,7 +646,6 @@ export class SessionsService implements ISessions {
parentId: address.parentSessionId,
origin: 'subagent',
running: child.activity === 'running',
waitingApproval: false,
blank: false,
updatedAt: 0,
}

View File

@@ -40,6 +40,7 @@ describe('instances', () => {
const manager = new SessionManager(api)
// Uninstantiated: approval buffers, plain session/event drops.
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
manager.handleMuxEnvelope({ rpcId: 're' as never, payload: { type: 'session/event', sessionId: S1, event: plainTurn(0, 0, 'x', 'y')[0] as never } })
const session = manager.get(S1)
expect(session.getSnapshot().pending).toMatchObject([{ kind: 'approval', payload: { approvalId: 'ap1' } }])
@@ -47,16 +48,26 @@ describe('instances', () => {
expect(manager.get(S2).getSnapshot().pending).toEqual([])
})
it('caps the pending buffer at 32 keeping the newest, and drops it on session-removed', () => {
it('retains every live answerable request and compacts resolutions before instantiation', () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
// 40 distinct question frames for an uninstantiated session: only the newest 32 survive.
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
for (let i = 0; i < 40; i++) {
manager.handleMuxEnvelope({ rpcId: `q${i}` as never, payload: { type: 'question/requested', sessionId: S1, questions: [] } })
}
const pending = manager.get(S1).getSnapshot().pending
expect(pending).toHaveLength(32)
expect(pending.map(p => p.key)).toEqual(Array.from({ length: 32 }, (_, i) => `q:q${i + 8}`)) // oldest 8 dropped
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('question')
for (let i = 0; i < 40; i++) {
manager.handleMuxEnvelope({
rpcId: `r${i}` as never,
payload: { type: 'question/resolved', sessionId: S1, questionRpcId: `q${i}` as never, outcome: 'answered' },
})
}
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined()
expect(manager.get(S1).getSnapshot().pending).toEqual([])
})
it('drops buffered answerable requests on session removal', () => {
const manager = new SessionManager(new FakeApiClient())
// Removed session: buffered frames must not replay on a future instantiation.
manager.handleMuxEnvelope({ rpcId: 'qz' as never, payload: { type: 'question/requested', sessionId: S2, questions: [] } })
manager.handleHostEnvelope({ rpcId: 'hz' as never, payload: { type: 'host/session-removed', sessionId: S2 } })
@@ -862,48 +873,102 @@ describe('connected generation', () => {
})
})
describe('waiting-approval list bit', () => {
it('lights on requested, survives replay duplicates, and clears on resolved — without instantiation', () => {
describe('pending-interaction list status', () => {
it('tracks approval requests through replay and resolution without instantiation', () => {
const manager = new SessionManager(new FakeApiClient())
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false)
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined()
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval')
// Mux-open replay of the same question (same approvalId) is idempotent.
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval')
manager.handleMuxEnvelope({ rpcId: 'rx' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'ap1' as never, outcome: 'allowed-once' as never } })
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false)
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined()
})
it('clears only when the last outstanding question resolves; session-removed drops the bit', () => {
it('classifies ordinary questions and renderable plan reviews, then clears by question rpcId', () => {
const manager = new SessionManager(new FakeApiClient())
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
manager.handleMuxEnvelope({
rpcId: 'q1' as never,
payload: { type: 'question/requested', sessionId: S1, questions: [{ id: 'name', question: 'Name?' }] },
})
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('question')
manager.handleMuxEnvelope({ rpcId: 'qx' as never, payload: { type: 'question/resolved', sessionId: S1, questionRpcId: 'q1' as never, outcome: 'answered' } })
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined()
manager.handleMuxEnvelope({
rpcId: 'q2' as never,
payload: {
type: 'question/requested',
sessionId: S1,
questions: [{
id: 'plan', question: 'Approve?', detail: '# Plan',
options: [{ label: 'Approve' }, { label: 'Refuse' }],
intent: { kind: 'plan-review', approve: 'Approve' },
}],
},
})
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('plan-review')
manager.handleMuxEnvelope({ rpcId: 'qy' as never, payload: { type: 'question/resolved', sessionId: S1, questionRpcId: 'q2' as never, outcome: 'cancelled' } })
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined()
})
it.each([
['missing detail', {}],
['multi-select', { detail: '# Plan', multiSelect: true }],
['more than two options', { detail: '# Plan', options: [{ label: 'Approve' }, { label: 'Refuse' }, { label: 'Revise' }] }],
['missing approve option', { detail: '# Plan', options: [{ label: 'Refuse' }] }],
])('keeps an unrenderable %s plan intent on the ordinary question flow', (_name, over) => {
const manager = new SessionManager(new FakeApiClient())
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
manager.handleMuxEnvelope({
rpcId: 'q-plan' as never,
payload: {
type: 'question/requested', sessionId: S1,
questions: [{
id: 'plan', question: 'Approve?', options: [{ label: 'Approve' }],
intent: { kind: 'plan-review', approve: 'Approve' },
...over,
}],
},
})
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('question')
})
it('the first question outranks sibling approvals and resolving it reveals the remaining wait', () => {
const manager = new SessionManager(new FakeApiClient())
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
manager.handleMuxEnvelope({ rpcId: 'r1' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a1' as never, toolName: 'rm' } })
manager.handleMuxEnvelope({ rpcId: 'r2' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a2' as never, toolName: 'rm' } })
manager.handleMuxEnvelope({
rpcId: 'q1' as never,
payload: { type: 'question/requested', sessionId: S1, questions: [{ id: 'name', question: 'Name?' }] },
})
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('question')
manager.handleMuxEnvelope({ rpcId: 'qy' as never, payload: { type: 'question/resolved', sessionId: S1, questionRpcId: 'q1' as never, outcome: 'answered' } })
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval')
manager.handleMuxEnvelope({ rpcId: 'rx' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'a1' as never, outcome: 'rejected' as never } })
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
manager.handleMuxEnvelope({ rpcId: 'ry' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'a2' as never, outcome: 'rejected' as never } })
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false)
// Removed sessions drop their bit outright.
manager.handleMuxEnvelope({ rpcId: 'r3' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a3' as never, toolName: 'rm' } })
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined()
manager.handleMuxEnvelope({ rpcId: 'r2' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a2' as never, toolName: 'rm' } })
manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-removed', sessionId: S1 } })
expect(manager.getListSnapshot().items).toHaveLength(0)
})
it('drops stale bits at generation death — BEFORE the reopen replay re-adds still-pending questions', () => {
it('drops stale status at generation death before replay re-adds live interactions', () => {
const manager = new SessionManager(new FakeApiClient())
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval')
// Generation death clears (resolved-while-disconnected questions send no frame)…
manager.handleDisconnected()
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false)
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined()
// …and a replayed frame arriving before onConnected (stream open precedes
// the readiness handshake) survives the later handleConnected untouched.
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
manager.handleConnected()
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval')
})
it('generation death drops buffered answerable frames (a dead generation cannot be answered)', () => {

View File

@@ -33,8 +33,6 @@
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
"lib/types/**/*.d.ts"
]
}

View File

@@ -49,8 +49,6 @@
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
"lib/types/**/*.d.ts"
]
}

View File

@@ -222,7 +222,6 @@ export class TestSessions implements ISessions {
id,
displayTitle: fixture.id,
running: false,
waitingApproval: false,
blank: false,
updatedAt: this.records.size + 1,
...fixture.summary,

View File

@@ -69,7 +69,7 @@ function browserSourcePath(source: string, sourcemapPath: string): string {
* own tsdown.config.ts (a preset-side glob hides it from the mechanical check).
* @returns tsdown user configs emitting lib/*.js and lib/client.js.
*/
export function clientBundle(id: string, libEntry: readonly string[]): UserConfig[] {
export function clientBundle(id: string, libEntry: readonly string[]): [UserConfig, UserConfig] {
return [{
entry: [...libEntry],
outDir: 'lib',

View File

@@ -69,8 +69,6 @@
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
"lib/types/**/*.d.ts"
]
}

View File

@@ -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 {

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
README.md: 7d3a4b5fe07cc8858c2f2059e6f65b6e27602b4d
README.zh.md: d93af91381157bb4e8e4b6a14ad00edecd505246
README.md: 0d00eac1db5aed7feec9bb714fe3d8976cd39943
README.zh.md: 4219950a9d51eb1037051ca00d61fa0d5ffa6fd2

View File

@@ -10,7 +10,7 @@ The resident conversation shell survives no-session and session transitions. Wit
The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: <active id>`), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves.
Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The runtime manager tracks this approval wait through the `waitingApproval` list bit even for uninstantiated sessions; `ui-workspace` owns its sidebar presentation. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit `/permission <preset>` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing.
Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The runtime manager projects every approval or question wait through `SessionSummary.pendingInteraction`, including sessions never instantiated; `ui-workspace` owns its sidebar presentation. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit `/permission <preset>` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing.
The session header declares and renders the session-scoped `'conversation.session.header.actions'` list beside the title, allowing feature plugins to contribute controls without entering the skeleton. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership.

View File

@@ -32,7 +32,7 @@ Think 行默认保持折叠,并在不展开思维链的情况下暴露实时
工具行同样是 slot独立工具环`ToolViewRegistry``ctx.toolviews`outlet已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位Session scopekey 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps``callId``toolName``block``openFile``ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seamapply 在聊天注册后挂载 ConversationService因此服务存在即可保证 slot 已声明bash 示例是第三方姿态的范例。Trajectory/waterfall瀑布式事件工具视图 slot 共享此形状并随各自的渲染点落地RendersCheck 会拒绝没有任何渲染方的声明)。
审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。运行时 manager 通过 `waitingApproval` 列表位跟踪这种审批等待,未实例化的 Session 也不例外;`ui-workspace` 负责其侧边栏呈现。未决等待完全离开消息流问题ui-question与审批ApprovalPanel都经编辑器接管作答不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数key 缺席即隐藏 chipchip 打开 Menu 原语下拉,其中 kebab-case 预设名渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission <preset>`,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用取消、Escape、关闭按钮与点击遮罩都不会提交命令。
审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。运行时 manager 会将所有审批或问题等待通过 `SessionSummary.pendingInteraction` 投影出来,未实例化的 Session 也不例外;`ui-workspace` 负责其侧边栏呈现。未决等待完全离开消息流问题ui-question与审批ApprovalPanel都经编辑器接管作答不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数key 缺席即隐藏 chipchip 打开 Menu 原语下拉,其中 kebab-case 预设名渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission <preset>`,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用取消、Escape、关闭按钮与点击遮罩都不会提交命令。
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']``TodoRow` 占用 `'conversation.chat.toolview'``todo_write` key摘要该次调用「试图写入」的内容从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock``order: 0` 占用 `'conversation.input.dock'` 列表 slot位于 Goal 与 Queue 之前),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · <n> in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock包括这条计划条。

View File

@@ -72,8 +72,6 @@
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
"lib/types/**/*.d.ts"
]
}

View File

@@ -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;
@@ -166,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;
}

View File

@@ -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, formatRunDuration, 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'
@@ -41,9 +41,32 @@ export function MessageIconActions({
}: 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)}
@@ -58,9 +81,9 @@ export function MessageIconActions({
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 && (

View File

@@ -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 */
}

View File

@@ -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>
))}

View File

@@ -1,5 +1,4 @@
// 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'
@@ -8,46 +7,6 @@ export type ClockTranslate = Translate<'clock.md' | 'clock.ymd'>
/** The elapsed-duration share of the conversation dictionary. */
export type RunDurationTranslate = Translate<'duration.seconds' | 'duration.minutes'>
/**
* 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()
}
function pad2(n: number): string {
return String(n).padStart(2, '0')
}

View File

@@ -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': '载入历史…',
@@ -154,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…',

View File

@@ -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;
}

View File

@@ -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>}

View File

@@ -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);

View File

@@ -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>

View File

@@ -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

View File

@@ -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,7 +88,7 @@
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;
@@ -256,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,
@@ -268,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 {
@@ -354,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) {

View File

@@ -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'
@@ -489,19 +489,20 @@ export function InputBar({
</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 })}
@@ -512,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>

View File

@@ -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);
}

View File

@@ -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>
}
/>

View File

@@ -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);

View File

@@ -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>

View File

@@ -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'])

View File

@@ -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')
})
})

View File

@@ -90,7 +90,7 @@ async function bench(snapshot: ConversationSnapshot) {
const session = createSnapshotStore<ConversationSnapshot>(snapshot)
const list = createSnapshotStore<SessionListState>({
ids: [SID],
byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, waitingApproval: false, blank: false, updatedAt: 1 } },
byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, blank: false, updatedAt: 1 } },
current: SID,
phase: 'ready', subagentsByParent: {}, currentAddress: undefined,
})

View File

@@ -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)', () => {
@@ -244,7 +244,7 @@ describe('bash sample row', () => {
return createSnapshotStore<SessionListState>({
ids: [SID],
byId: {
[SID]: { id: SID, title: 'r', displayTitle: 'r', running: false, waitingApproval: false, blank: false, updatedAt: 0 },
[SID]: { id: SID, title: 'r', displayTitle: 'r', running: false, blank: false, updatedAt: 0 },
},
current: undefined,
phase: 'ready',

View File

@@ -93,7 +93,7 @@ describe('tails', () => {
const sid = 'root-1' as SessionId
const list = createSnapshotStore<SessionListState>({
ids: [sid],
byId: { [sid]: { id: sid, title: 'r', displayTitle: 'r', running: false, waitingApproval: false, blank: false, updatedAt: 0 } },
byId: { [sid]: { id: sid, title: 'r', displayTitle: 'r', running: false, blank: false, updatedAt: 0 } },
current: undefined,
phase: 'ready',
subagentsByParent: {},

View File

@@ -153,7 +153,7 @@ describe('chat row diff body', () => {
describe('FileMutationRow diff card', () => {
const list = () => createSnapshotStore<SessionListState>({
ids: [SID],
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd: '/w/app' } },
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd: '/w/app' } },
current: SID,
phase: 'ready',
subagentsByParent: {},
@@ -306,7 +306,7 @@ describe('DetailsPanel diff Output section', () => {
? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }
: {
ids: [SID],
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd } },
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } },
current: SID,
phase: 'ready',
subagentsByParent: {},

View File

@@ -143,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 ? '停止生成' : '发送消息'}"]`,
@@ -724,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'])

View File

@@ -167,7 +167,7 @@ describe('GenericToolCard read body', () => {
describe('ReadRow keyed toolview', () => {
const list = () => createSnapshotStore<SessionListState>({
ids: [SID],
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd: '/w/app' } },
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd: '/w/app' } },
current: SID,
phase: 'ready',
subagentsByParent: {},
@@ -254,7 +254,7 @@ describe('DetailsPanel Output section (read)', () => {
? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }
: {
ids: [SID],
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd } },
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } },
current: SID,
phase: 'ready',
subagentsByParent: {},

View File

@@ -92,10 +92,10 @@ function mount(
} = {},
) {
const root = sid('root')
const rootRow = { id: root, displayTitle: 'Root', running: false, waitingApproval: false, blank: false, updatedAt: 1 }
const rootRow = { id: root, displayTitle: 'Root', running: false, blank: false, updatedAt: 1 }
const childRow = {
id: SID, displayTitle: 'Child', parentId: root, cwd: '/projects/one',
running: false, waitingApproval: false, blank: options.summaryBlank ?? false, updatedAt: 2,
running: false, blank: options.summaryBlank ?? false, updatedAt: 2,
...(options.summaryOrigin === undefined ? {} : { origin: options.summaryOrigin }),
}
const listed = options.omitSummaryRow !== true

View File

@@ -342,7 +342,7 @@ describe('chat row terminal body', () => {
describe('BashRow terminal card', () => {
const list = () => createSnapshotStore<SessionListState>({
ids: [SID],
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0 } },
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0 } },
current: undefined,
phase: 'ready',
subagentsByParent: {},
@@ -448,7 +448,7 @@ describe('DetailsPanel Output section', () => {
? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }
: {
ids: [SID],
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd } },
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } },
current: SID,
phase: 'ready',
subagentsByParent: {},

View File

@@ -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()

View File

@@ -67,8 +67,6 @@
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
"lib/types/**/*.d.ts"
]
}

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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()

View File

@@ -56,8 +56,6 @@
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
"lib/types/**/*.d.ts"
]
}

View File

@@ -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>
)

View File

@@ -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. */

View File

@@ -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 },
},

View File

@@ -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()

View File

@@ -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,
})
})
})

View File

@@ -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(),
}

View File

@@ -68,8 +68,6 @@
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
"lib/types/**/*.d.ts"
]
}

View File

@@ -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);

View File

@@ -65,8 +65,6 @@
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
"lib/types/**/*.d.ts"
]
}

View File

@@ -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;

View File

@@ -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>

View File

@@ -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}

View File

@@ -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' } })
@@ -555,7 +555,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 () => {
@@ -574,10 +574,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()
})

View File

@@ -70,8 +70,6 @@
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
"lib/types/**/*.d.ts"
]
}

View File

@@ -66,8 +66,6 @@
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
"lib/types/**/*.d.ts"
]
}

View File

@@ -44,9 +44,7 @@
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
"lib/types/**/*.d.ts"
],
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",

View File

@@ -6,7 +6,7 @@
import clsx from 'clsx'
import css from './StateDot.module.css'
/** Four-color session state semantic (green done / amber approval-waiting / blue running ring / red error). */
/** Four-color state semantic (green done / amber user-attention / blue running ring / red error). */
export type StateDotState = 'done' | 'warning' | 'ongoing' | 'error'
/** Outer 3x3 matrix cells (2px pixels on a 10px grid), clockwise from top-left. */

View File

@@ -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; }
}

View File

@@ -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>
)}

View File

@@ -1,6 +1,5 @@
// Package-internal clipboard write, shared by every copy control in this
// package (CodeBlock's code copy, TerminalBlock's output copy). Not part of the
// public surface: consumers get the components, not the host detection.
// Host clipboard write shared by Web UI copy controls. Success feedback stays
// with each control; this seam only reports whether the host accepted a write.
/**
* Write text to the host clipboard, preferring the async Clipboard API and

View File

@@ -675,6 +675,26 @@ export const IconDataOutline16 = ({ size = 16, className }: IconProps) => (
</svg>
)
/** ic_send_outline_14 (figma extract): thin-stroke upward send arrow. */
export const IconSendOutline14 = ({ size = 14, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M7.24707 1.01771C7.52897 1.07653 7.77619 1.19694 8.00391 1.38001C8.19202 1.53136 8.39884 1.73784 8.61914 1.95814L12.6396 5.9806L11.6299 6.99134L7.71484 3.0763V13.0001H6.28516V3.0763L2.36914 6.99134L1.35938 5.9806L5.38086 1.95814C5.60116 1.73784 5.80798 1.53136 5.99609 1.38001C6.19476 1.22027 6.4385 1.06739 6.75195 1.01771C6.91296 0.992304 7.07471 0.997504 7.24707 1.01771Z"
fill="currentColor"
/>
</svg>
)
/** ic_queue_outline_14 (figma extract): open chat bubble with two queued lines. */
export const IconQueueOutline14 = ({ size = 14, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M7.00049 0.199829C3.24488 0.199829 0.199952 3.24408 0.199707 6.99963C0.199707 8.0414 0.434087 9.03061 0.854004 9.91467L1.11279 10.4576L2.19775 9.94202L1.94092 9.39905L1.81787 9.12268C1.5498 8.46885 1.40186 7.75171 1.40186 6.99963C1.4021 3.90808 3.90888 1.40198 7.00049 1.40198C10.0919 1.40219 12.5979 3.90821 12.5981 6.99963C12.5981 10.0913 10.0921 12.5981 7.00049 12.5983C6.36734 12.5983 5.90348 12.5535 5.49268 12.4401C5.08803 12.3283 4.7041 12.1414 4.24463 11.8209C3.57111 11.3511 2.60588 11.1855 1.81006 11.6881L1.79736 11.6959L1.78467 11.7047L1.25537 12.0778L1.65381 13.2672L2.46045 12.6989C2.75029 12.5214 3.18004 12.5442 3.55615 12.8063C4.10063 13.1861 4.60863 13.4423 5.17334 13.5983C5.73194 13.7525 6.31665 13.8004 7.00049 13.8004C10.7561 13.8002 13.8003 10.7553 13.8003 6.99963C13.8 3.24421 10.7559 0.200041 7.00049 0.199829ZM3.81201 7.47327V8.67542H7.11572V7.47327H3.81201ZM3.81201 6.34924H10.2173V5.14709H3.81201V6.34924Z"
fill="currentColor"
/>
</svg>
)
/** ic_checklist_outline_14 (figma extract): two rings + two list bars. */
export const IconChecklistOutline14 = ({ size = 14, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -703,7 +723,23 @@ export const IconListPenOutline16 = ({ size = 16, className }: IconProps) => (
</svg>
)
/** sparkle_16 (Others tool-row / goal strip leading glyph; hand-authored three-star
/** ic_ds_goal_outline_16 (goal strip leading glyph: dartboard with a landed arrow) */
export const IconGoalOutline16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M8 0C8.31451 0 8.62464 0.019379 8.92969 0.0546875C8.48228 0.403371 8.0952 0.825758 7.78809 1.30469C4.18586 1.41664 1.2998 4.37061 1.2998 8C1.2998 11.7003 4.29969 14.7002 8 14.7002C11.6297 14.7002 14.5829 11.8136 14.6943 8.21094C15.1734 7.90377 15.5956 7.51688 15.9443 7.06934C15.9797 7.37473 16 7.68512 16 8C16 12.4183 12.4183 16 8 16C3.58172 16 0 12.4183 0 8C0 3.58172 3.58172 0 8 0ZM7.0166 3.6084C7.00658 3.73765 7 3.86817 7 4C7 4.31845 7.03098 4.62973 7.08789 4.93164C5.76489 5.32438 4.7998 6.54958 4.7998 8C4.7998 9.76731 6.23269 11.2002 8 11.2002C9.45065 11.2002 10.6749 10.2345 11.0674 8.91113C11.3696 8.96818 11.6812 9 12 9C12.1315 9 12.2617 8.99239 12.3906 8.98242C11.9423 10.995 10.1477 12.5 8 12.5C5.51472 12.5 3.5 10.4853 3.5 8C3.5 5.85255 5.00435 4.05702 7.0166 3.6084Z"
fill="currentColor"
/>
<path d="M7.5 8.62109L9.12109 7" stroke="currentColor" strokeWidth="1.3" />
<path
d="M9.08245 3.35798L11.8651 0.575334C11.895 0.545384 11.9463 0.56391 11.9502 0.606086L12.2362 3.69859C12.2384 3.72259 12.2574 3.74159 12.2814 3.74378L15.3697 4.02583C15.4119 4.02968 15.4305 4.08101 15.4005 4.11098L12.618 6.89351C12.6086 6.90289 12.5959 6.90816 12.5826 6.90816L9.11781 6.90815C9.09019 6.90816 9.06781 6.88577 9.06781 6.85816L9.06781 3.39333C9.06781 3.38007 9.07308 3.36735 9.08245 3.35798Z"
stroke="currentColor"
strokeWidth="1.3"
/>
</svg>
)
/** sparkle_16 (Others tool-row leading glyph; hand-authored three-star
* approximation — the figma 43:31850 glyph is an SF Symbols "sparkles" text glyph,
* not extractable as vector data) */
export const IconSparkle16 = ({ size = 16, className }: IconProps) => (

View File

@@ -20,6 +20,7 @@ export { FishLogo } from './FishLogo.tsx'
export { BrandWordmark } from './BrandWordmark.tsx'
export { Tooltip } from './Tooltip.tsx'
export type { TooltipSide } from './Tooltip.tsx'
export { writeClipboard } from './clipboard.ts'
export { JsonTree } from './JsonTree.tsx'
export type { JsonTreeProps, JsonTreeLabels } from './JsonTree.tsx'
export { TerminalBlock, DEFAULT_TERMINAL_MAX_LINES } from './TerminalBlock.tsx'

View File

@@ -2,7 +2,9 @@
import { cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import * as primitives from '@deepseek-ai/dsh-client-ui-primitives'
import { IconApiOutline14, IconArchiveOutline20, IconFolderClose16, IconSendOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import {
IconApiOutline14, IconArchiveOutline20, IconFolderClose16, IconGoalOutline16, IconSendOutline16,
} from '@deepseek-ai/dsh-client-ui-primitives'
afterEach(cleanup)
@@ -14,8 +16,8 @@ const icons = Object.fromEntries(
const iconNames = Object.keys(icons)
describe('ic_ds_ icon set', () => {
it('exports the full P-I set (45 deepsuite + 15 figma extracts + the hand-authored sparkle)', () => {
expect(iconNames.length).toBe(61)
it('exports the full P-I set (46 deepsuite + 17 figma extracts + the hand-authored sparkle)', () => {
expect(iconNames.length).toBe(64)
})
it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', (name) => {
@@ -44,6 +46,12 @@ describe('ic_ds_ icon set', () => {
const archive = render(<IconArchiveOutline20 />)
expect(archive.container.querySelector('svg')!.getAttribute('width')).toBe('20')
})
it('renders reusable goal glyphs without document-global ids', () => {
const { container } = render(<><IconGoalOutline16 /><IconGoalOutline16 /></>)
expect(container.querySelector('[id]')).toBeNull()
expect(container.querySelector('[clip-path]')).toBeNull()
})
})
describe('FishLogo', () => {

View File

@@ -43,8 +43,9 @@ describe('Tooltip', () => {
const bubble = screen.getByRole('tooltip')
expect(bubble.textContent).toBe('Open sidebar')
expect(bubble.getAttribute('data-side')).toBe('right')
// jsdom rects are all-zero: right placement lands at the +10 gutter.
expect(bubble.style.left).toBe('10px')
// jsdom rects are all-zero: right placement lands at the +10 gutter, then
// the zero-width measured rect clamps to the 12px edge margin (10 + 12).
expect(bubble.style.left).toBe('22px')
expect(bubble.style.top).toBe('0px')
fireEvent.mouseLeave(anchor)
expect(screen.queryByRole('tooltip')).toBeNull()
@@ -60,12 +61,100 @@ describe('Tooltip', () => {
fireEvent.focus(anchor)
const bubble = screen.getByRole('tooltip')
expect(bubble.getAttribute('data-side')).toBe('bottom')
expect(bubble.style.left).toBe('0px')
// Zero-width jsdom rect at x=0 clamps to the 12px edge margin.
expect(bubble.style.left).toBe('12px')
expect(bubble.style.top).toBe('8px')
fireEvent.blur(anchor)
expect(screen.queryByRole('tooltip')).toBeNull()
})
// jsdom's default rects are all-zero, so the clamp tests stub the measured
// rect (anchor and bubble share the prototype stub) and derive expectations
// from it: pos.x = anchor center, then shifted by the measured overflow.
const rect = (left: number, right: number): DOMRect =>
({ left, right, top: 0, bottom: 20, width: right - left, height: 20, x: left, y: 0, toJSON: () => ({}) })
it('clamps a bubble overflowing the right viewport edge back inside', () => {
const spy = vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(rect(900, 1100))
try {
render(
<Tooltip label="Wide" side="bottom">
<button type="button">anchor</button>
</Tooltip>,
)
fireEvent.mouseEnter(screen.getByText('anchor'))
// pos.x = 1000 (anchor center); measured right edge 1100 overflows the
// 1024 viewport's 12px safe margin (limit 1012) by 88, so the clamp
// shifts left to 912.
expect(screen.getByRole('tooltip').style.left).toBe('912px')
} finally {
spy.mockRestore()
}
})
it('reclamps after label and viewport width changes', () => {
const originalWidth = window.innerWidth
const spy = vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
if (this.getAttribute('role') !== 'tooltip') return rect(900, 1000)
return this.textContent === 'Wide' ? rect(900, 1100) : rect(850, 950)
})
try {
const view = render(
<Tooltip label="Wide" side="bottom">
<button type="button">anchor</button>
</Tooltip>,
)
fireEvent.mouseEnter(screen.getByText('anchor'))
expect(screen.getByRole('tooltip').style.left).toBe('862px')
view.rerender(
<Tooltip label="Short" side="bottom">
<button type="button">anchor</button>
</Tooltip>,
)
expect(screen.getByRole('tooltip').style.left).toBe('950px')
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 900 })
fireEvent(window, new Event('resize'))
expect(screen.getByRole('tooltip').style.left).toBe('888px')
} finally {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalWidth })
spy.mockRestore()
}
})
it('clamps a bubble past the left viewport edge back inside', () => {
const spy = vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(rect(-20, 80))
try {
render(
<Tooltip label="Wide" side="bottom">
<button type="button">anchor</button>
</Tooltip>,
)
fireEvent.mouseEnter(screen.getByText('anchor'))
// pos.x = 30 (anchor center); measured left edge -20 underflows the
// 12px safe margin by 32, so the clamp shifts right to 62.
expect(screen.getByRole('tooltip').style.left).toBe('62px')
} finally {
spy.mockRestore()
}
})
it('supports top placement for anchors at the viewport bottom', () => {
render(
<Tooltip label="Above" side="top">
<button type="button">anchor</button>
</Tooltip>,
)
fireEvent.mouseEnter(screen.getByText('anchor'))
const bubble = screen.getByRole('tooltip')
expect(bubble.getAttribute('data-side')).toBe('top')
// jsdom rects are all-zero: top placement lands at the -8 gutter and the
// zero-width measured rect clamps left to the 12px edge margin.
expect(bubble.style.left).toBe('12px')
expect(bubble.style.top).toBe('-8px')
})
it('chains the anchor\'s own handlers ahead of the tooltip\'s', () => {
const onMouseEnter = vi.fn()
const onMouseLeave = vi.fn()

View File

@@ -63,8 +63,6 @@
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
"lib/types/**/*.d.ts"
]
}

View File

@@ -9,7 +9,9 @@
.frame {
display: flex;
justify-content: center;
padding: 6px 24px 10px;
/* Sides = clearance + 16px: the card lands on the shared content width
(input card - 32) at every viewport. */
padding: 6px calc(var(--dsh-composer-side-clearance) + 16px) 10px;
}
.card {
@@ -17,7 +19,7 @@
overflow: hidden;
flex-direction: column;
width: 100%;
max-width: 776px;
max-width: var(--dsh-chat-content-width);
/* Composer seat sits in a fixed-height conversation column (overflow
hidden): cap the card against the viewport and scroll the plan, so the
strip and the decision row stay reachable on a long plan. */
@@ -93,11 +95,19 @@
gap: 8px;
}
@media (max-width: 720px) {
.frame {
padding: 6px 10px 10px;
}
/* The discuss verb stays a quiet text button beside the two decision
capsules: 14px glyph against the 14px label with a slightly wider gap, so
the icon reads as a prefix rather than a peer-sized control. */
.discuss {
gap: 6px;
color: var(--dsw-alias-label-secondary);
}
.discuss:hover:not(:disabled) {
color: var(--dsw-alias-label-primary);
}
@media (max-width: 720px) {
.card {
border-radius: 16px;
}

View File

@@ -73,21 +73,21 @@ export function PlanReviewPanel({ pending, review, t }: PlanReviewPanelProps) {
<div className={css.feedback} role="status">{error}</div>
<div className={css.actions}>
<Button
size="sm" variant="ghost" icon={<IconEditOutline16 />}
variant="ghost" className={css.discuss} icon={<IconEditOutline16 size={14} />}
disabled={busy} onClick={() => { settle(() => pending.cancel()) }}
>
{t('plan.discuss')}
</Button>
{decline !== undefined && (
<Button
size="sm" variant="outline" {...tooltip(decline.description)}
variant="outline" {...tooltip(decline.description)}
disabled={busy} onClick={() => { decide(decline.label) }}
>
{t('plan.decline')}
</Button>
)}
<Button
size="sm" variant="primary" {...tooltip(review.approve.description)}
variant="primary" {...tooltip(review.approve.description)}
disabled={busy} onClick={() => { decide(review.approve.label) }}
>
{t('plan.approve')}

View File

@@ -1,9 +1,10 @@
/* The takeover seats where the input card sits, so the frame mirrors the
InputBar geometry (side pad 32, card cap 800) to keep both edges flush. */
/* The takeover seats where the input card sits, centered on the InputBar
axis at the shared content width (input card - 32): sides = clearance +
16px so the relation also holds on narrow viewports. */
.frame {
display: flex;
justify-content: center;
padding: 6px 32px 10px;
padding: 6px calc(var(--dsh-composer-side-clearance) + 16px) 10px;
}
/* Figma Input 973:36348 body over the 1019:36938 header: no banner strip —
@@ -12,7 +13,7 @@
display: flex;
flex-direction: column;
width: 100%;
max-width: 800px;
max-width: var(--dsh-chat-content-width);
/* Composer seat sits in a fixed-height conversation column (overflow
hidden): cap the card against the viewport and scroll the option list
so header and footer actions stay reachable on long batches. */
@@ -366,10 +367,6 @@
}
@media (max-width: 720px) {
.frame {
padding: 6px 10px 10px;
}
.card {
border-radius: 16px;
}

View File

@@ -70,8 +70,6 @@
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
"lib/types/**/*.d.ts"
]
}

View File

@@ -63,8 +63,6 @@
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
"lib/types/**/*.d.ts"
]
}

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-sidebar/README.md
README.md: 19c2d1033de4475816249aa8429f4a589eeb6481
README.zh.md: b8c154586570cf1b9fd4bf776bc09b36ab5ee7d2
README.md: 5bb697b3d2f9b5eaea9c382765d2510fa24806ce
README.zh.md: 302f66c540774b1f209fc797201e41c56b849310

Some files were not shown because too many files have changed in this diff Show More