Merge remote-tracking branch 'origin/master' into feat/web-terminal-card
# Conflicts: # apps/web/tests/navigation-panes.e2e.ts # apps/web/tests/snapshots/navigation-panes/details-open.expected.md # packages/client/ui-conversation/README.i18n.yaml # packages/client/ui-conversation/README.md # packages/client/ui-conversation/README.zh.md # packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx # packages/client/ui-conversation/src/client/chat/ToolRow.tsx # packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx # packages/client/ui-conversation/tests/chat-tool-row.spec.tsx
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/README.md
|
||||
README.md: d16e395a42e491461c0862227205931894c27e39
|
||||
README.zh.md: 3fb4181ce7ae7b0d79a13ca4358b9df39d83ef1f
|
||||
README.md: f5420b6f2f30837b030a0e832a438c34674a6f23
|
||||
README.zh.md: 7beeaadf380a742cbdb6447553f42692a97fad10
|
||||
|
||||
@@ -28,21 +28,22 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
|
||||
| [`workflow/`](workflow/README.md) | Workflow capability family: the script-engine seam, worker-thread engine, and model-facing `workflow` and fresh-agent `ralph` tools | Product — stable surface |
|
||||
| [`web/`](web/README.md) | Web capability family: seam, search/fetch provider impls, and the model-facing web tools | Product — stable surface |
|
||||
| [`spill/`](spill/README.md) | Spill capability family: storage seam, local impl, tool-result spill policy | Product — stable surface |
|
||||
| [`todo/`](todo/README.md) | Todo/planning family: the model-facing `todo_write` tool | Product — stable surface |
|
||||
| [`todo/`](todo/README.md) | The model-facing `todo_write` tool | Product — stable surface |
|
||||
| [`plan/`](plan/README.md) | Plan collaboration state with a direct entry command and reviewed exit | Product — stable surface |
|
||||
| [`timeout/`](timeout/README.md) | Tool-call timeout policy: the `tools/execute` deadline enforcer | Product — stable surface |
|
||||
| [`guard/`](guard/README.md) | Loop-hygiene guards: advisory repeat-call reminders | Product — stable surface |
|
||||
| [`cordis/`](cordis/README.md) | Self-referential runtime toolset: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable surface |
|
||||
| [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface |
|
||||
| [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface |
|
||||
| [`session-persistence/`](session-persistence/README.md) | Persistence seam + JSONL/SQLite backends | Product — stable surface |
|
||||
| [`session-projection/`](session-projection/README.md) | Projection seam: domain fold units serve whole values | Product — stable surface |
|
||||
| [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, bounded reads, lineage, event relationships, semantic filtering, and SQLite full-text search | Product — stable surface |
|
||||
| [`session-title/`](session-title/README.md) | Log-backed session titles: fallback service, shared LLM policy, and opt-in providers | Product — stable surface |
|
||||
| [`session-title/`](session-title/README.md) | Log-backed session titles: fallback service and opt-in LLM providers | Product — stable surface |
|
||||
| [`telemetry/`](telemetry/README.md) | Session reporting: capture/redact seam, OTel backend | Product — stable surface |
|
||||
| [`storage/`](storage/README.md) | Non-session storage hub + backends + domain form | Product — stable surface |
|
||||
| [`workspace/`](workspace/README.md) | Workspace entity | Product — stable surface |
|
||||
| [`sdk/`](sdk/README.md) | Project SDK tooling | Product — stable surface |
|
||||
| [`acp/`](acp/README.md) | Automation-only Agent Client Protocol server | Product — stable surface |
|
||||
| [`ui/`](ui/README.md) | Human/client integrations: TUI and JSON-RPC, approval/interaction seams, ask-user tool | Product — stable surface |
|
||||
| [`ui/`](ui/README.md) | TUI and JSON-RPC integrations, approval/interaction seams, ask-user tool | Product — stable surface |
|
||||
| [`examples/`](examples/README.md) | Demo bundles (agent-spine + TUI/CLI/ACP/JSON-RPC bins) leaves load | Support — example infra |
|
||||
| [`support/`](support/README.md) | Support infrastructure (testkits, invariants, replay, Loader smokes) | Support — lower compatibility expectations |
|
||||
| [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (`Branded<B>`, Harness home/path helpers, timeout, retention) | Support — small, stable, harness-dep-free |
|
||||
|
||||
@@ -28,21 +28,22 @@
|
||||
| [`workflow/`](workflow/README.md) | 工作流能力系列:脚本引擎 seam、worker 线程引擎、面向模型的 `workflow` 与新 agent `ralph` 工具 | 产品:稳定表面 |
|
||||
| [`web/`](web/README.md) | Web 能力系列:seam、搜索/获取提供方实现和面向模型的 Web 工具 | 产品:稳定表面 |
|
||||
| [`spill/`](spill/README.md) | 溢出能力系列:存储 seam、本地实现、工具结果溢出策略 | 产品:稳定表面 |
|
||||
| [`todo/`](todo/README.md) | Todo/规划系列:面向模型的 `todo_write` 工具 | 产品:稳定表面 |
|
||||
| [`todo/`](todo/README.md) | 面向模型的 `todo_write` 工具 | 产品:稳定表面 |
|
||||
| [`plan/`](plan/README.md) | Plan 协作状态,提供直接进入命令与经评审的退出 | 产品:稳定表面 |
|
||||
| [`timeout/`](timeout/README.md) | 工具调用超时策略:`tools/execute` 截止时间强制执行器 | 产品:稳定表面 |
|
||||
| [`guard/`](guard/README.md) | 循环卫生守卫:建议性重复调用提醒 | 产品:稳定表面 |
|
||||
| [`cordis/`](cordis/README.md) | 自指运行时工具集:检查实时运行时的插件与服务,挂载/卸载模型所写插件([设计](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | 产品:稳定表面 |
|
||||
| [`hooks/`](hooks/README.md) | 钩子桥接 + 共享 Claude Code/Codex 协议格式库 | 产品:稳定表面 |
|
||||
| [`session-persistence/`](session-persistence/README.md) | 持久化能力系列:seam + JSONL/SQLite 后端 | 产品:稳定表面 |
|
||||
| [`session-persistence/`](session-persistence/README.md) | 持久化 seam + JSONL/SQLite 后端 | 产品:稳定表面 |
|
||||
| [`session-projection/`](session-projection/README.md) | 投影 seam:领域折叠单元供给全量值 | 产品:稳定表面 |
|
||||
| [`session-query/`](session-query/README.md) | 会话检索系列:逻辑语料库、有界读取、血缘、事件关系、语义过滤和 SQLite 全文搜索 | 产品:稳定表面 |
|
||||
| [`session-title/`](session-title/README.md) | 日志支撑的会话标题:回退服务、共享 LLM 策略和选用提供方 | 产品:稳定表面 |
|
||||
| [`session-title/`](session-title/README.md) | 日志支撑的会话标题:回退服务与选用 LLM 提供方 | 产品:稳定表面 |
|
||||
| [`telemetry/`](telemetry/README.md) | 会话上报:捕获/脱敏 seam、OTel 后端 | 产品:稳定表面 |
|
||||
| [`storage/`](storage/README.md) | 非会话存储中枢 + 后端 + 领域形式 | 产品:稳定表面 |
|
||||
| [`workspace/`](workspace/README.md) | Workspace 实体 | 产品:稳定表面 |
|
||||
| [`sdk/`](sdk/README.md) | 项目 SDK 工具 | 产品:稳定表面 |
|
||||
| [`acp/`](acp/README.md) | 仅面向自动化的 Agent Client Protocol 服务器 | 产品:稳定表面 |
|
||||
| [`ui/`](ui/README.md) | 人类/客户端集成:TUI 与 JSON-RPC、批准/交互 seam、用户问答工具 | 产品:稳定表面 |
|
||||
| [`ui/`](ui/README.md) | TUI 与 JSON-RPC 集成、批准/交互 seam、用户问答工具 | 产品:稳定表面 |
|
||||
| [`examples/`](examples/README.md) | 演示组合包(agent-spine + TUI/CLI/ACP/JSON-RPC bin),由叶节点加载 | 支持:示例基础设施 |
|
||||
| [`support/`](support/README.md) | 支持基础设施(testkit、不变式、回放、Loader 冒烟测试) | 支持:兼容性预期较低 |
|
||||
| [`util/`](util/README.md) | 组间共享的低层零依赖工具(`Branded<B>`、Harness home/路径辅助函数、超时、保留策略) | 支持:小型、稳定、无 harness 依赖 |
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^"
|
||||
|
||||
@@ -9,7 +9,7 @@ export type {
|
||||
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
|
||||
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
|
||||
WorkspaceApi, WorkspaceId, WorkspaceView,
|
||||
CommandsApi, CommandDescriptor, CommandExecuteResult, SkillsApi, SkillEntry,
|
||||
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
|
||||
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
||||
ModelReasoningEffort, ModelTarget, SessionModels,
|
||||
} from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { SessionEvent, SessionId, TodoItem } from '@deepseek-ai/dsh-session/types'
|
||||
// Type-only: the brand constructor is host-side; the fixture casts at its
|
||||
// wire-fabrication boundary (the schema layer's one-cast-point posture).
|
||||
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import type {
|
||||
ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt,
|
||||
ModelTarget, RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary,
|
||||
@@ -342,18 +345,27 @@ function viewFor(event: SessionEvent, log: readonly SessionEvent[]): ToolEventVi
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Fold the latest fixture title into the host's control-frame projection. */
|
||||
function titleFrameOf(id: SessionId, log: readonly SessionEvent[]): Extract<MuxFrame, { type: 'session/title' }> | undefined {
|
||||
const event = log.findLast(item => (item as { type: string }).type === 'session/title')
|
||||
if (event === undefined) return undefined
|
||||
const titleEvent = event as unknown as { seq: number; time: number; data: { title: string } }
|
||||
return {
|
||||
type: 'session/title',
|
||||
sessionId: id,
|
||||
title: titleEvent.data.title,
|
||||
eventSeq: titleEvent.seq,
|
||||
updatedAt: titleEvent.time,
|
||||
/** Fixture parallel of the host's projection units: whole current values per key over the full log. */
|
||||
function projectionValuesOf(log: readonly SessionEvent[]): Record<string, unknown> {
|
||||
const values: Record<string, unknown> = {}
|
||||
const titleEvent = log.findLast(item => (item as { type: string }).type === 'session/title')
|
||||
if (titleEvent !== undefined) {
|
||||
values['title'] = (titleEvent as unknown as { data: { title: string } }).data.title
|
||||
}
|
||||
const todos = backscanTodos(log)
|
||||
if (todos !== undefined) values['todos'] = todos
|
||||
return values
|
||||
}
|
||||
|
||||
/** Host push-frame parallel: emit one session/projection frame per key the given event advanced. */
|
||||
function projectionFramesOf(id: SessionId, log: readonly SessionEvent[], event: SessionEvent): Extract<MuxFrame, { type: 'session/projection' }>[] {
|
||||
const type = (event as { type: string }).type
|
||||
const key = type === 'session/title' ? 'title' : type === 'todo/write' ? 'todos' : undefined
|
||||
if (key === undefined) return []
|
||||
const values = projectionValuesOf(log)
|
||||
/* v8 ignore next -- the advancing event is in the log, so its key always has a value. */
|
||||
if (!Object.hasOwn(values, key)) return []
|
||||
return [{ type: 'session/projection', sessionId: id, key, value: values[key], seq: event.seq }]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -583,10 +595,8 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
emitMux(view === undefined
|
||||
? { type: 'session/event', sessionId: id, event }
|
||||
: { type: 'session/event', sessionId: id, event, view })
|
||||
if ((event as { type: string }).type === 'session/title') {
|
||||
// The raw title is already in this log, so the latest-title fold must find it.
|
||||
emitMux(titleFrameOf(id, log) as Extract<MuxFrame, { type: 'session/title' }>)
|
||||
}
|
||||
// Host eager-drive parallel: a unit-advancing event pushes its finished value.
|
||||
for (const frame of projectionFramesOf(id, log, event)) emitMux(frame)
|
||||
}
|
||||
|
||||
/** At most one in-flight replay per session; cancel clears it. */
|
||||
@@ -739,14 +749,18 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
const log = logs.get(request.payload.sessionId) ?? []
|
||||
// Snapshot at request time, deliver after the transit delay (mirrors a real host under latency).
|
||||
const page = pageOf(log, request.payload.beforeSeq, request.payload.maxMessages ?? 50)
|
||||
// Tail page carries the session-level todo projection (host parallel: full-log backscan).
|
||||
const todos = request.payload.beforeSeq === undefined ? backscanTodos(log) : undefined
|
||||
// Tail page carries the projections block (host parallel: one consistent
|
||||
// cut over the registered units; asOfSeq = window tail seq, -1 on an
|
||||
// empty log — the host's session.seq-1 convention).
|
||||
const projections = request.payload.beforeSeq === undefined
|
||||
? { asOfSeq: log.length - 1, values: projectionValuesOf(log) }
|
||||
: undefined
|
||||
const doomed = failNextHistory
|
||||
failNextHistory = false
|
||||
const delay = historyDelayMs
|
||||
if (delay > 0) await new Promise(resolve => setTimeout(resolve, delay))
|
||||
if (doomed) throw new Error('fixture: simulated history transport failure')
|
||||
return ok(request, { ...page, ...todos === undefined ? {} : { todos } })
|
||||
return ok(request, { ...page, ...projections === undefined ? {} : { projections } })
|
||||
},
|
||||
models: request => ok(request, {
|
||||
current: modelTargets.get(request.payload.sessionId)
|
||||
@@ -847,6 +861,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
host: {
|
||||
describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions }),
|
||||
pickDirectory: request => ok(request, { path: null }),
|
||||
openPath: request => ok(request, { opened: true as const }),
|
||||
},
|
||||
workspace: {
|
||||
list: request => ok(request, { items: workspaces.map(w => ({ ...w })) }),
|
||||
@@ -950,25 +965,29 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
],
|
||||
})
|
||||
},
|
||||
// Pure admission, mirroring the host: an admitted command logs the
|
||||
// command/run + command/done lifecycle pair (mux-broadcast by append),
|
||||
// and the response only reports resolution.
|
||||
execute: (request) => {
|
||||
const missing = requireSession(request)
|
||||
if (missing !== undefined) return missing
|
||||
const line = request.payload.line.trim()
|
||||
const match = /^\/(\S+)(?:\s+(.*))?$/.exec(line)
|
||||
const id = request.payload.sessionId
|
||||
// Structured split mirroring the host parser: name + verbatim rawInput
|
||||
// (separator whitespace included) — the run payload carries no line.
|
||||
const match = /^\/(\S+)((?:\s.*)?)$/.exec(request.payload.line.trim())
|
||||
const name = match?.[1]
|
||||
if (name === 'compact' || name === 'echo') {
|
||||
return ok(request, {
|
||||
matched: true as const,
|
||||
result: { kind: 'success' as const, text: name === 'echo' ? (match?.[2] ?? '') : 'fixture:已压缩(假动作)' },
|
||||
})
|
||||
const args = match?.[2] ?? ''
|
||||
const outcomes: Record<string, string> = {
|
||||
compact: 'fixture:已压缩(假动作)',
|
||||
echo: args.trim(),
|
||||
'goal-fixture': `fixture:goal 已设置(${id})`,
|
||||
}
|
||||
if (name === 'goal-fixture') {
|
||||
return ok(request, {
|
||||
matched: true as const,
|
||||
result: { kind: 'success' as const, text: `fixture:goal 已设置(${request.payload.sessionId})` },
|
||||
})
|
||||
}
|
||||
return ok(request, { matched: false as const })
|
||||
const text = name === undefined ? undefined : outcomes[name]
|
||||
if (name === undefined || text === undefined) return ok(request, { matched: false as const })
|
||||
const commandId = `fx-cmd-${logOf(id).length}` as CommandId
|
||||
append(id, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } })
|
||||
append(id, { type: 'command/done', data: { commandId, kind: 'success', ...text === '' ? {} : { text } } })
|
||||
return ok(request, { matched: true as const, commandId })
|
||||
},
|
||||
},
|
||||
skills: {
|
||||
@@ -991,9 +1010,13 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
// Open baseline: subscribed sessions + pending interactions replayed with stable rpcIds.
|
||||
for (const s of sessions) {
|
||||
if (!s.running) continue
|
||||
conn.push({ rpcId: mint(), payload: { type: 'session/subscribed', sessionId: s.sessionId, lastSeq: (logs.get(s.sessionId)?.length ?? 0) - 1 } })
|
||||
const title = titleFrameOf(s.sessionId, logs.get(s.sessionId) ?? [])
|
||||
if (title !== undefined) conn.push({ rpcId: mint(), payload: title })
|
||||
const log = logs.get(s.sessionId) ?? []
|
||||
conn.push({ rpcId: mint(), payload: { type: 'session/subscribed', sessionId: s.sessionId, lastSeq: log.length - 1 } })
|
||||
// Post-subscribe projection baseline (host parallel: recomputed unit values ride push frames).
|
||||
const values = projectionValuesOf(log)
|
||||
for (const key of Object.keys(values)) {
|
||||
conn.push({ rpcId: mint(), payload: { type: 'session/projection', sessionId: s.sessionId, key, value: values[key], seq: log.length - 1 } })
|
||||
}
|
||||
}
|
||||
conn.push({
|
||||
rpcId: pendingApprovalRpcId,
|
||||
@@ -1098,6 +1121,7 @@ export class FixtureApiClient extends AbstractApiClient {
|
||||
case 'session.cancel': return this.api.sessions.cancel(request)
|
||||
case 'host.describe': return this.api.host.describe(request)
|
||||
case 'host.pickDirectory': return this.api.host.pickDirectory(request, new AbortController().signal)
|
||||
case 'host.openPath': return this.api.host.openPath(request, new AbortController().signal)
|
||||
case 'workspace.list': return this.api.workspace.list(request)
|
||||
case 'workspace.create': return this.api.workspace.create(request)
|
||||
case 'workspace.rename': return this.api.workspace.rename(request)
|
||||
|
||||
@@ -14,7 +14,7 @@ export type {
|
||||
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
|
||||
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
|
||||
ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
|
||||
CommandsApi, CommandDescriptor, CommandExecuteResult, SkillsApi, SkillEntry,
|
||||
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
|
||||
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
||||
ModelReasoningEffort, ModelTarget, SessionModels,
|
||||
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
|
||||
|
||||
@@ -26,7 +26,8 @@ export function apply(ctx: Context): void {
|
||||
path: API_PATH,
|
||||
handler: async (req, res) => {
|
||||
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
|
||||
if (pathname === `${API_PATH}/host.pickDirectory`
|
||||
if ((pathname === `${API_PATH}/host.pickDirectory`
|
||||
|| pathname === `${API_PATH}/host.openPath`)
|
||||
&& !isTrustedNativeDialogRequest(req)) {
|
||||
res.writeHead(403)
|
||||
res.end('forbidden')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Trust check for browser requests that can open an operating-system dialog. */
|
||||
/** Trust check for browser requests that can invoke privileged native host actions. */
|
||||
|
||||
import type { IncomingHttpHeaders } from 'node:http'
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// Test-local programmable IApiClient fake (NOT the fixture: fixture is a demo
|
||||
// data source on a real clock; behavior tests need per-case responses and
|
||||
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
|
||||
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import type {
|
||||
CommandDescriptor, CommandExecuteResult, HostFrame, IApiClient, ModelTarget, MuxFrame,
|
||||
CommandDescriptor, HostFrame, IApiClient, ModelTarget, MuxFrame,
|
||||
RpcRequest, RpcResponse, SessionId, SessionModels, SkillEntry,
|
||||
} from '../src/client/api.ts'
|
||||
import { RpcId } from '../src/client/api.ts'
|
||||
@@ -66,6 +67,8 @@ export class FakeApiClient implements IApiClient {
|
||||
() => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 }))
|
||||
onPickDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string | null }>> =
|
||||
() => Promise.resolve(ok({ path: null }))
|
||||
onOpenPath: (payload: unknown) => Promise<RpcResponse<{ opened: true }>> =
|
||||
() => Promise.resolve(ok({ opened: true as const }))
|
||||
|
||||
private readonly muxConns: StreamConn<MuxFrame>[] = []
|
||||
private readonly hostConns: StreamConn<HostFrame>[] = []
|
||||
@@ -88,6 +91,7 @@ export class FakeApiClient implements IApiClient {
|
||||
readonly host: IApiClient['host'] = {
|
||||
describe: payload => this.record('host.describe', payload, this.onDescribe(payload)),
|
||||
pickDirectory: payload => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)),
|
||||
openPath: payload => this.record('host.openPath', payload, this.onOpenPath(payload)),
|
||||
}
|
||||
|
||||
readonly workspace: IApiClient['workspace'] = {
|
||||
@@ -107,10 +111,12 @@ export class FakeApiClient implements IApiClient {
|
||||
|
||||
// Payloads stay `unknown` (lint-lane note above); response rows are the real
|
||||
// wire shapes so cases can program catalogs and skill lists without casts.
|
||||
onCommandList: (payload: unknown) => Promise<RpcResponse<{ commands: CommandDescriptor[] }>> = () => Promise.resolve(ok({ commands: [] }))
|
||||
onCommandExecute: (payload: unknown) => Promise<RpcResponse<{ matched: boolean; result?: CommandExecuteResult }>> =
|
||||
() => Promise.resolve(ok({ matched: false }))
|
||||
onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>> = () => Promise.resolve(ok({ skills: [] }))
|
||||
onCommandList: (payload: unknown) => Promise<RpcResponse<{ commands: CommandDescriptor[] }>>
|
||||
= () => Promise.resolve(ok({ commands: [] }))
|
||||
onCommandExecute: (payload: unknown) => Promise<RpcResponse<{ matched: boolean; commandId?: CommandId }>>
|
||||
= () => Promise.resolve(ok({ matched: false }))
|
||||
onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>>
|
||||
= () => Promise.resolve(ok({ skills: [] }))
|
||||
|
||||
readonly commands: IApiClient['commands'] = {
|
||||
list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)),
|
||||
|
||||
@@ -36,20 +36,37 @@ describe('createFixtureApi commands/skills', () => {
|
||||
expect(response.result).toMatchObject({ ok: false, error: { code: 'session-not-found' } })
|
||||
})
|
||||
|
||||
it('executes a known command line and reports matched with a result', async () => {
|
||||
it('executes a known command line: pure admission plus a mux-broadcast lifecycle pair', async () => {
|
||||
const api = createFixtureApi()
|
||||
const frames: unknown[] = []
|
||||
const abort = new AbortController()
|
||||
const stream = api.events.mux(req({}), abort.signal)
|
||||
const pump = (async () => {
|
||||
for await (const frame of stream) {
|
||||
frames.push(frame.payload)
|
||||
if (frames.filter(f => (f as { type: string }).type === 'session/event').length >= 2) abort.abort()
|
||||
}
|
||||
})()
|
||||
const response = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line: '/echo hello world' }), signal)
|
||||
if (!response.result.ok) throw new Error('execute failed')
|
||||
expect(response.result.value.matched).toBe(true)
|
||||
expect(response.result.value.result).toEqual({ kind: 'success', text: 'hello world' })
|
||||
expect(response.result.value).toMatchObject({ matched: true })
|
||||
expect(response.result.value.commandId).toBeTruthy()
|
||||
await pump
|
||||
const events = frames
|
||||
.filter((f): f is { type: string; event: { type: string; data: Record<string, unknown> } } => (f as { type: string }).type === 'session/event')
|
||||
.map(f => f.event)
|
||||
expect(events).toMatchObject([
|
||||
{ type: 'command/run', data: { name: 'echo', args: ' hello world', source: { kind: 'user' } } },
|
||||
{ type: 'command/done', data: { kind: 'success', text: 'hello world' } },
|
||||
])
|
||||
expect(events[0]?.data.commandId).toBe(events[1]?.data.commandId)
|
||||
})
|
||||
|
||||
it('addresses execute to the session (result text carries the id)', async () => {
|
||||
it('addresses execute to the session; an unknown session errs', async () => {
|
||||
const api = createFixtureApi()
|
||||
const hit = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line: '/goal-fixture ship' }), signal)
|
||||
if (!hit.result.ok) throw new Error('execute failed')
|
||||
expect(hit.result.value.matched).toBe(true)
|
||||
expect(hit.result.value.result?.text).toContain('fx-alpha')
|
||||
|
||||
const missing = await api.commands.execute(req({ sessionId: sid('fx-nope'), line: '/goal-fixture ship' }), signal)
|
||||
expect(missing.result).toMatchObject({ ok: false, error: { code: 'session-not-found' } })
|
||||
@@ -60,8 +77,8 @@ describe('createFixtureApi commands/skills', () => {
|
||||
for (const line of ['/nope', 'plain text', '/']) {
|
||||
const response = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line }), signal)
|
||||
if (!response.result.ok) throw new Error('execute failed')
|
||||
expect(response.result.value.matched).toBe(false)
|
||||
expect(response.result.value.result).toBeUndefined()
|
||||
// Pure admission value: the matched bit is the whole response shape.
|
||||
expect(response.result.value).toEqual({ matched: false })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -65,13 +65,11 @@ describe('createFixtureApi', () => {
|
||||
const clamped = await api.sessions.history(req({ sessionId: sid('fx-alpha'), beforeSeq: -5, maxMessages: 10 }))
|
||||
if (!clamped.result.ok) throw new Error('clamped failed')
|
||||
expect(clamped.result.value.events).toEqual([])
|
||||
// Unknown session: empty page, not an error (history of a bare id).
|
||||
// Unknown session: empty page, not an error (history of a bare id). The
|
||||
// tail block still rides it — empty-log cut at -1, the host convention.
|
||||
const empty = await api.sessions.history(req({ sessionId: sid('no-such'), maxMessages: 10 }))
|
||||
if (!empty.result.ok) throw new Error('empty failed')
|
||||
expect(empty.result.value).toEqual({
|
||||
events: [],
|
||||
hasMore: false,
|
||||
})
|
||||
expect(empty.result.value).toEqual({ events: [], hasMore: false, projections: { asOfSeq: -1, values: {} } })
|
||||
})
|
||||
|
||||
it('serves grouped models and keeps a selected target for later history and fixture requests', async () => {
|
||||
@@ -213,11 +211,13 @@ describe('createFixtureApi', () => {
|
||||
const second = await openOnce()
|
||||
expect(first[0]?.payload).toMatchObject({ type: 'session/subscribed', sessionId: 'fx-alpha' })
|
||||
expect((first[0]?.payload as { lastSeq: number }).lastSeq).toBeGreaterThan(0)
|
||||
expect(first[1]?.payload).toMatchObject({ type: 'session/title', sessionId: 'fx-alpha', title: 'Fixture 历史会话' })
|
||||
expect(first[2]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
|
||||
expect(second[2]?.rpcId).toBe(first[2]?.rpcId) // stable rpcId across replays (host replay semantics)
|
||||
expect(first[3]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' })
|
||||
expect(second[3]?.rpcId).toBe(first[3]?.rpcId)
|
||||
// Projection baseline frames follow the subscribed frame (title + todos units).
|
||||
expect(first[1]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'title', value: 'Fixture 历史会话' })
|
||||
expect(first[2]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'todos' })
|
||||
expect(first[3]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
|
||||
expect(second[3]?.rpcId).toBe(first[3]?.rpcId) // stable rpcId across replays (host replay semantics)
|
||||
expect(first[4]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' })
|
||||
expect(second[4]?.rpcId).toBe(first[4]?.rpcId)
|
||||
})
|
||||
|
||||
it('steer with no replay in flight falls through to a fresh queued turn; non-text blocks stringify empty', async () => {
|
||||
@@ -619,11 +619,11 @@ describe('createFixtureApi', () => {
|
||||
hooks.appendTitle('fx-alpha', 'Fixture 修订标题')
|
||||
await vi.waitFor(() => {
|
||||
expect(seen.some(f => f.type === 'session/event' && JSON.stringify(f.event.data).includes('正常直播'))).toBe(true)
|
||||
expect(seen.some(f => f.type === 'session/title' && f.title === 'Fixture 修订标题')).toBe(true)
|
||||
expect(seen.some(f => f.type === 'session/projection' && f.key === 'title' && f.value === 'Fixture 修订标题')).toBe(true)
|
||||
})
|
||||
expect(seen.some(f => f.type === 'session/event' && JSON.stringify(f.event.data).includes('静默丢帧'))).toBe(false)
|
||||
const rawTitleIndex = seen.findIndex(f => f.type === 'session/event' && (f.event as { type: string }).type === 'session/title')
|
||||
const titleControlIndex = seen.findIndex(f => f.type === 'session/title' && f.title === 'Fixture 修订标题')
|
||||
const titleControlIndex = seen.findIndex(f => f.type === 'session/projection' && f.key === 'title' && f.value === 'Fixture 修订标题')
|
||||
expect(titleControlIndex).toBe(rawTitleIndex + 1)
|
||||
// But history serves the silent event (the client's repull finds it).
|
||||
const repull = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 5 }))
|
||||
|
||||
@@ -28,22 +28,24 @@ describe('connection node half', () => {
|
||||
expect(routes).toHaveLength(1)
|
||||
expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH })
|
||||
|
||||
let status: number | undefined
|
||||
let body: unknown
|
||||
const deniedRequest = {
|
||||
url: '/api/host.pickDirectory',
|
||||
headers: {
|
||||
host: 'harness.example', origin: 'http://harness.example', 'sec-fetch-site': 'same-origin',
|
||||
},
|
||||
socket: { remoteAddress: '192.168.1.8' },
|
||||
} as unknown as IncomingMessage
|
||||
const deniedResponse = {
|
||||
writeHead(value: number) { status = value; return this },
|
||||
end(value?: unknown) { body = value; return this },
|
||||
} as unknown as ServerResponse
|
||||
await routes[0]!.handler(deniedRequest, deniedResponse)
|
||||
expect(status).toBe(403)
|
||||
expect(body).toBe('forbidden')
|
||||
for (const url of ['/api/host.pickDirectory', '/api/host.openPath']) {
|
||||
let status: number | undefined
|
||||
let body: unknown
|
||||
const deniedRequest = {
|
||||
url,
|
||||
headers: {
|
||||
host: 'harness.example', origin: 'http://harness.example', 'sec-fetch-site': 'same-origin',
|
||||
},
|
||||
socket: { remoteAddress: '192.168.1.8' },
|
||||
} as unknown as IncomingMessage
|
||||
const deniedResponse = {
|
||||
writeHead(value: number) { status = value; return this },
|
||||
end(value?: unknown) { body = value; return this },
|
||||
} as unknown as ServerResponse
|
||||
await routes[0]!.handler(deniedRequest, deniedResponse)
|
||||
expect(status).toBe(403)
|
||||
expect(body).toBe('forbidden')
|
||||
}
|
||||
|
||||
await fiber.dispose()
|
||||
expect(routes).toHaveLength(0)
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/commands"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
|
||||
@@ -32,10 +32,13 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title": "workspace:^",
|
||||
"immer": "^10.1.1",
|
||||
"react": "^18.2.0",
|
||||
"zustand": "~4.4.7"
|
||||
|
||||
@@ -7,6 +7,7 @@ import { SessionsService } from './sessions/service.ts'
|
||||
import type { SessionListState } from './sessions/service.ts'
|
||||
import { WorkspacesService } from './workspaces/service.ts'
|
||||
import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from './sessions/conversation.ts'
|
||||
import type { UseProjection } from './sessions/projection-store.ts'
|
||||
|
||||
export { SlotsService } from './slots.ts'
|
||||
export type { RootOwnerProps } from './slots.ts'
|
||||
@@ -28,12 +29,17 @@ export type {
|
||||
EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore,
|
||||
} from './contract/store.ts'
|
||||
export type {
|
||||
AssistantBlock, AssistantMessageNode, CodeSubCall, ComposerPhase, ContextMessageNode, ConversationNode,
|
||||
AssistantBlock, AssistantMessageNode, CodeSubCall, CommandNode, ComposerPhase, ContextMessageNode, ConversationNode,
|
||||
ConversationSnapshot, QueuedMessage, RunningToolCall,
|
||||
SteeringMessageNode, TodoItem, ToolResultNode, UnknownSurfaceNode, UserMessageNode,
|
||||
} from './sessions/conversation.ts'
|
||||
export { PendingWait } from './sessions/pending.ts'
|
||||
export type { PendingInteraction, PendingKind, PendingPayloads } from './sessions/pending.ts'
|
||||
// Projection value store (session-projection RFC, push model): host-computed
|
||||
// whole values per key; domains ship projection support with zero client code.
|
||||
export type {
|
||||
ProjectionsBaseline, ProjectionValueStore, SessionProjectionMap, UseProjection,
|
||||
} from './sessions/projection-store.ts'
|
||||
export type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
|
||||
/** Client-side Cordis context after declaration merging. */
|
||||
@@ -59,12 +65,16 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
useSession: SnapshotSelectorHook<ConversationSnapshot>
|
||||
/** The framework-resolved session id (owners never pass it). */
|
||||
sessionId: SessionId
|
||||
/** The fifth framework hook seat: key-addressed projection reader (undefined = capability absent). */
|
||||
useProjection: UseProjection
|
||||
}
|
||||
/** Standard kit for slots that remain mounted while current session changes. */
|
||||
interface SessionMaybeStandardProps {
|
||||
useSession: MaybeSnapshotSelectorHook<ConversationSnapshot>
|
||||
/** Current session id; absent in the no-session state. */
|
||||
sessionId: SessionId | undefined
|
||||
/** Key-addressed projection reader; every key reads absent while no session is current. */
|
||||
useProjection: UseProjection
|
||||
}
|
||||
/** Props injected into every global slot component. */
|
||||
interface GlobalStandardProps {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// substructures keep their references (the React.memo premise). callId/approvalId stay plain
|
||||
// string here (narrow to real brands when convenient).
|
||||
|
||||
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { TodoItem } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
@@ -120,6 +121,31 @@ export interface UnknownSurfaceNode {
|
||||
data: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* One slash-command lifecycle folded from the log-only `command/run` /
|
||||
* `command/done` pair (paired by commandId, mirroring tool call↔result).
|
||||
* Log-only events never enter the surface fold, so the FoldAdapter indexes
|
||||
* them separately and merges the nodes into the flow by seq. A window cut
|
||||
* between the pair soft-falls like tool pairs: a done with no in-window run
|
||||
* still builds a node (name/args null), and a run with no done renders as
|
||||
* still executing.
|
||||
*/
|
||||
export interface CommandNode {
|
||||
kind: 'command'
|
||||
/** Seq of the command/run event; the done event's seq when only the done is in-window. */
|
||||
seq: number
|
||||
/** Unix epoch ms of the anchoring event. */
|
||||
time: number
|
||||
/** Pairing id minted by the host executor. */
|
||||
commandId: CommandId
|
||||
/** Command name (run payload's structured field); null when the run fell outside the window. */
|
||||
name: string | null
|
||||
/** Verbatim rawInput after the name, separator whitespace included (run payload); null when the run fell outside the window. */
|
||||
args: string | null
|
||||
/** Settlement outcome (done payload); null while the command is still executing. */
|
||||
outcome: { kind: 'success' | 'error'; text?: string } | null
|
||||
}
|
||||
|
||||
/** Finalized conversation node union (kind discriminates; seq is the React key). */
|
||||
export type ConversationNode =
|
||||
| UserMessageNode
|
||||
@@ -127,6 +153,7 @@ export type ConversationNode =
|
||||
| SteeringMessageNode
|
||||
| ContextMessageNode
|
||||
| ToolResultNode
|
||||
| CommandNode
|
||||
| UnknownSurfaceNode
|
||||
|
||||
/**
|
||||
@@ -243,7 +270,4 @@ export interface ConversationSnapshot {
|
||||
*/
|
||||
blank: boolean
|
||||
lastAgentError: string | null
|
||||
/** Current whole-list `todo/write` projection — the tail page's full-log value, then each live
|
||||
* write (last write wins); empty = the log holds no plan. */
|
||||
todos: readonly TodoItem[]
|
||||
}
|
||||
|
||||
@@ -8,8 +8,9 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
// go through it — the package root points at lib/index.js (needs a build) which the vite
|
||||
// browser bundle cannot resolve; surface.ts has no Node dependencies.
|
||||
import { SurfaceManager, isSurfaceEligibleType } from '@deepseek-ai/dsh-session/surface'
|
||||
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ConversationNode } from './conversation.ts'
|
||||
import type { CommandNode, ConversationNode } from './conversation.ts'
|
||||
import { toAssistantBlocks } from './conversation.ts'
|
||||
|
||||
/** In-window tool/call index entry (result-card backfill + runningCalls material). */
|
||||
@@ -99,6 +100,15 @@ export class FoldAdapter {
|
||||
private callIdx = new Map<string, CallIndexEntry>()
|
||||
/** Wire result views keyed by the tool/result event's seq (views ride the envelope, not the event). */
|
||||
private resultViews = new Map<number, ToolResultView>()
|
||||
/**
|
||||
* Command lifecycle nodes by commandId (insertion = run order). The
|
||||
* `command/run`/`command/done` pair is log-only, so the surface fold never
|
||||
* emits it; this index folds the pair (done settles its run's node in
|
||||
* place) and nodes() merges the products into the flow by seq. Window cuts
|
||||
* soft-fall like tool pairs: a done with no in-window run still builds a
|
||||
* node.
|
||||
*/
|
||||
private commandIdx = new Map<string, CommandNode>()
|
||||
/** Window revision (bumped on reset/append) keying the nodes() result cache: an unchanged
|
||||
* window returns the previous ARRAY reference, not just cached elements — the snapshot's
|
||||
* reference-stability contract (§A.9.4) starts here. */
|
||||
@@ -128,10 +138,14 @@ export class FoldAdapter {
|
||||
this.degraded = false
|
||||
this.callIdx = new Map()
|
||||
this.resultViews.clear()
|
||||
this.commandIdx = new Map()
|
||||
for (let i = 0; i < events.length; i++) {
|
||||
const event = events[i]
|
||||
/* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */
|
||||
if (event !== undefined) this.indexCall(event, views?.[i])
|
||||
if (event !== undefined) {
|
||||
this.indexCall(event, views?.[i])
|
||||
this.indexCommand(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,6 +159,7 @@ export class FoldAdapter {
|
||||
this.rev++
|
||||
this.padded.push(event)
|
||||
this.indexCall(event, view)
|
||||
this.indexCommand(event)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -180,7 +195,23 @@ export class FoldAdapter {
|
||||
this.nodeCache.set(seq, node)
|
||||
out.push(node)
|
||||
}
|
||||
const value = { nodes: out, degraded: this.degraded }
|
||||
// Command nodes fold outside the surface (log-only events); merge by seq.
|
||||
// Both inputs are seq-ascending (surface order and run-index insertion
|
||||
// order share the log order), so one linear merge keeps flow order.
|
||||
let nodes = out
|
||||
if (this.commandIdx.size > 0) {
|
||||
nodes = []
|
||||
const commands = [...this.commandIdx.values()]
|
||||
let next = 0
|
||||
for (const node of out) {
|
||||
for (let cmd = commands[next]; cmd !== undefined && cmd.seq < node.seq; cmd = commands[++next]) {
|
||||
nodes.push(cmd)
|
||||
}
|
||||
nodes.push(node)
|
||||
}
|
||||
for (let cmd = commands[next]; cmd !== undefined; cmd = commands[++next]) nodes.push(cmd)
|
||||
}
|
||||
const value = { nodes, degraded: this.degraded }
|
||||
this.nodesResult = { rev: this.rev, value }
|
||||
return value
|
||||
}
|
||||
@@ -195,6 +226,36 @@ export class FoldAdapter {
|
||||
return seqs
|
||||
}
|
||||
|
||||
/** Fold one command lifecycle event into its node (run mints, done settles in place; done-only soft-falls). */
|
||||
private indexCommand(event: SessionEvent): void {
|
||||
// Log-only plugin events: the host-side dsh-commands declaration cannot
|
||||
// enter the client program, so this wire consumer narrows structurally
|
||||
// (the same posture as tool/code-dispatch in session.ts).
|
||||
if ((event.type as string) === 'command/run') {
|
||||
const data = event.data as unknown as { commandId: CommandId; name: string; args: string }
|
||||
this.commandIdx.set(data.commandId, {
|
||||
kind: 'command', seq: event.seq, time: event.time,
|
||||
commandId: data.commandId, name: data.name, args: data.args, outcome: null,
|
||||
})
|
||||
return
|
||||
}
|
||||
if ((event.type as string) !== 'command/done') return
|
||||
const data = event.data as unknown as { commandId: CommandId; kind: 'success' | 'error'; text?: string }
|
||||
const run = this.commandIdx.get(data.commandId)
|
||||
const outcome = { kind: data.kind, ...data.text === undefined ? {} : { text: data.text } }
|
||||
if (run === undefined) {
|
||||
// Cross-window cut: the run page fell out of the window — build the
|
||||
// node from the done alone (same soft-fall as a call-less tool result).
|
||||
this.commandIdx.set(data.commandId, {
|
||||
kind: 'command', seq: event.seq, time: event.time,
|
||||
commandId: data.commandId, name: null, args: null, outcome,
|
||||
})
|
||||
return
|
||||
}
|
||||
// Settle in place: a fresh node object (published references stay immutable).
|
||||
this.commandIdx.set(data.commandId, { ...run, outcome })
|
||||
}
|
||||
|
||||
private indexCall(event: SessionEvent, view?: ToolEventView): void {
|
||||
if (event.type === 'tool/result') {
|
||||
if (view?.for === 'result') this.resultViews.set(event.seq, view.view)
|
||||
|
||||
@@ -9,7 +9,12 @@ 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'
|
||||
// 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.
|
||||
import type {} from '@deepseek-ai/dsh-session-title/client'
|
||||
import { Notifier } from './notifier.ts'
|
||||
import { ProjectionValueStore } from './projection-store.ts'
|
||||
import { Session } from './session.ts'
|
||||
|
||||
/**
|
||||
@@ -43,12 +48,6 @@ type SessionListMutation =
|
||||
/** Per-session cap for pre-instantiation approval/question buffering (low-frequency frames; a few dozen covers any real backlog). */
|
||||
const PENDING_BUFFER_CAP = 32
|
||||
|
||||
/** Latest title control snapshot retained independently of list/instance arrival. */
|
||||
interface SessionTitleSnapshot {
|
||||
title: string
|
||||
eventSeq: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
/** Instance cluster + frame entry + the session list (see the web client architecture RFC). */
|
||||
export class SessionManager {
|
||||
@@ -58,7 +57,11 @@ export class SessionManager {
|
||||
* drop-and-backfill path; replayed and cleared on instantiation. Bounded per session (these
|
||||
* frames are low-frequency; overflow drops oldest) and dropped on session-removed (audit S7). */
|
||||
private readonly pendingBuffers = new Map<SessionId, RpcRequest<MuxFrame>[]>()
|
||||
private readonly titleSnapshots = new Map<SessionId, SessionTitleSnapshot>()
|
||||
/** 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
|
||||
* same store so history-baseline seeding and frames converge on one row set. */
|
||||
private readonly projectionStores = new Map<SessionId, ProjectionValueStore>()
|
||||
private summaries: SessionSummary[] = []
|
||||
private listState: 'idle' | 'loading' | 'error' = 'idle'
|
||||
/** Arrival phase; the pending → ready edge fires on the first successful pull (see SessionListPhase). */
|
||||
@@ -163,9 +166,23 @@ export class SessionManager {
|
||||
onEngaged: (engaged) => {
|
||||
this.recordMutation({ kind: 'engaged', sessionId: engaged.sessionId })
|
||||
},
|
||||
projections: this.projectionStore(sessionId),
|
||||
})
|
||||
}
|
||||
|
||||
/** Resident per-session projection store (create-on-demand; outlives instantiation). */
|
||||
private projectionStore(sessionId: SessionId): ProjectionValueStore {
|
||||
let store = this.projectionStores.get(sessionId)
|
||||
if (store === undefined) {
|
||||
store = new ProjectionValueStore()
|
||||
// List rows project off store keys (title); any-key changes re-enter
|
||||
// the manager's own batched rebuild channel.
|
||||
store.subscribeAny(() => { this.notifier.markDirty() })
|
||||
this.projectionStores.set(sessionId, store)
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
// ---- List surface ----
|
||||
|
||||
/** Full refresh via session.list (single-flight: an in-flight call is reused). */
|
||||
@@ -302,23 +319,20 @@ export class SessionManager {
|
||||
handleMuxEnvelope(envelope: RpcRequest<MuxFrame>): void {
|
||||
const frame = envelope.payload
|
||||
if (frame.type === 'stream/error') return // Controller already treats this as stream failure
|
||||
if (frame.type === 'session/title') {
|
||||
const current = this.titleSnapshots.get(frame.sessionId)
|
||||
if (current !== undefined && current.eventSeq >= frame.eventSeq) return
|
||||
this.titleSnapshots.set(frame.sessionId, {
|
||||
title: frame.title,
|
||||
eventSeq: frame.eventSeq,
|
||||
updatedAt: frame.updatedAt,
|
||||
})
|
||||
if (frame.type === 'session/projection') {
|
||||
// Finished host-computed value: land it in the resident store whether or
|
||||
// not the Session is instantiated (list rows read the 'title' key). The
|
||||
// synchronous markDirty keeps the list snapshot same-tick fresh (the
|
||||
// store's own any-key channel is microtask-batched).
|
||||
this.projectionStore(frame.sessionId).apply(frame.key, frame.value, frame.seq)
|
||||
this.notifier.markDirty()
|
||||
return
|
||||
}
|
||||
if (frame.type === 'session/subscribed') {
|
||||
const current = this.titleSnapshots.get(frame.sessionId)
|
||||
if (current !== undefined && current.eventSeq > frame.lastSeq) {
|
||||
this.titleSnapshots.delete(frame.sessionId)
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
// Rows past the host's durable baseline rode state a restart lost; drop
|
||||
// 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/queued 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
|
||||
@@ -377,7 +391,7 @@ export class SessionManager {
|
||||
this.recordMutation({ kind: 'remove', sessionId: frame.sessionId })
|
||||
this.sessions.get(frame.sessionId)?.handleRemoved() // instance survives (resident-instance rule), only flagged in the snapshot
|
||||
this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation
|
||||
this.titleSnapshots.delete(frame.sessionId)
|
||||
this.projectionStores.delete(frame.sessionId) // removed sessions drop their projection rows with the instance
|
||||
return
|
||||
}
|
||||
case 'host/session-status': {
|
||||
@@ -402,10 +416,12 @@ export class SessionManager {
|
||||
|
||||
private buildListSnapshot(): SessionListSnapshot {
|
||||
const merged: TitledSessionSummary[] = this.summaries.map((summary) => {
|
||||
const title = this.titleSnapshots.get(summary.sessionId)
|
||||
return title === undefined
|
||||
? summary
|
||||
: { ...summary, title: title.title, updatedAt: Math.max(summary.updatedAt, title.updatedAt) }
|
||||
// List rows read the generic 'title' projection key (host-computed unit
|
||||
// value; the bespoke session/title frame is retired).
|
||||
const title = this.projectionStores.get(summary.sessionId)?.get('title')
|
||||
return typeof title === 'string' && title !== ''
|
||||
? { ...summary, title }
|
||||
: summary
|
||||
})
|
||||
const fresh = flattenLineage(merged)
|
||||
const items = fresh.map((entry) => {
|
||||
|
||||
183
packages/client/runtime/src/client/sessions/projection-store.ts
Normal file
183
packages/client/runtime/src/client/sessions/projection-store.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Generic per-session projection value store (session-projection RFC, push
|
||||
* model): the host is the only computation site; the client holds finished
|
||||
* whole values per key — `key → { value, seq }` — seeded by the history tail
|
||||
* page's projections block and updated by `session/projection` push frames,
|
||||
* under the single rule **higher seq wins**. No client-side domain folding
|
||||
* exists: a domain ships projection support with zero client code. Per-key
|
||||
* bare observable faces feed `useProjection` (web-react binds them).
|
||||
*/
|
||||
import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
|
||||
import type { ObservableSnapshot } from '../contract/store.ts'
|
||||
import { Notifier } from './notifier.ts'
|
||||
|
||||
// The single projection type table, typed end to end (host unit, wire block,
|
||||
// client store, React hook) — the interface package's pure-type outlet
|
||||
// (`/types`, zero imports), never the package root: the root's dsh-agent →
|
||||
// dsh-session chain would drag the host `Context.sessions` merge into the
|
||||
// client program (one program must not hold both sides). No second
|
||||
// client-side "views" table (user ruling, RFC Alternatives).
|
||||
export type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
|
||||
|
||||
/**
|
||||
* The fifth framework hook seat (session-projection RFC): key-addressed
|
||||
* projection reader delivered through the standard kit. `undefined` uniformly
|
||||
* means capability absent — host unit unmounted, or no baseline/frame has
|
||||
* carried the key yet. The selector overload mirrors useSession (per-key uSES
|
||||
* binding; reference stability holds because a key's value reference changes
|
||||
* only when a frame or baseline lands).
|
||||
*/
|
||||
export type UseProjection = {
|
||||
<K extends Extract<keyof SessionProjectionMap, string>>(key: K): SessionProjectionMap[K] | undefined
|
||||
<K extends Extract<keyof SessionProjectionMap, string>, S>(
|
||||
key: K,
|
||||
selector: (value: SessionProjectionMap[K] | undefined) => S,
|
||||
eq?: (a: S, b: S) => boolean,
|
||||
): S
|
||||
}
|
||||
|
||||
/**
|
||||
* Tail-page projections baseline — structurally identical to the wire's
|
||||
* `SessionProjectionsBlock` (apiproxy api layer), restated here so the
|
||||
* React-free store depends only on the type table, not the wire package's
|
||||
* response vocabulary.
|
||||
*/
|
||||
export interface ProjectionsBaseline {
|
||||
/** The consistent-cut seq (equals the window tail seq by construction). */
|
||||
asOfSeq: number
|
||||
/** Whole current values by key; a registered key absent here means the capability is absent. */
|
||||
values: Partial<SessionProjectionMap>
|
||||
}
|
||||
|
||||
/** One key's row: the latest finished value and the seq it is consistent with. */
|
||||
interface Row {
|
||||
value: unknown
|
||||
seq: number
|
||||
}
|
||||
|
||||
/** Per-key notification channel: the bare face plus its batching notifier. */
|
||||
interface Channel {
|
||||
face: ObservableSnapshot<unknown>
|
||||
notifier: Notifier
|
||||
}
|
||||
|
||||
/**
|
||||
* One session's projection values. Framework semantics, uniform across every
|
||||
* key: a baseline seeds rows at its cut, a push frame updates one row, and in
|
||||
* both paths a lower-or-equal seq loses — a replayed frame cannot regress a
|
||||
* value, a stale baseline cannot overwrite a newer frame. A key the store has
|
||||
* never seen reads `undefined` (capability absent). Faces are identity-stable
|
||||
* per key (create-on-demand, cached) so the React side binds each exactly
|
||||
* once; the store-level channel (`subscribeAny`) serves coarse consumers (the
|
||||
* manager's list projection reads the `title` key).
|
||||
*/
|
||||
export class ProjectionValueStore {
|
||||
private readonly rows = new Map<string, Row>()
|
||||
private readonly channels = new Map<string, Channel>()
|
||||
/** Coarse any-key channel (no snapshot cache to rebuild: reads hit rows directly). */
|
||||
private readonly anyNotifier = new Notifier(() => {})
|
||||
|
||||
/**
|
||||
* Key-addressed bare observable face (the useProjection resolution path).
|
||||
* Always defined — absence is an `undefined` snapshot, never a missing
|
||||
* face, so a component may subscribe before the key ever carries a value.
|
||||
* @param key - projection key.
|
||||
* @returns the identity-stable face for this key.
|
||||
*/
|
||||
faceOf(key: string): ObservableSnapshot<unknown> {
|
||||
return this.channel(key).face
|
||||
}
|
||||
|
||||
/**
|
||||
* Current whole value for a key (erased framework read; typed reads go
|
||||
* through `useProjection`'s map lookup).
|
||||
* @param key - projection key.
|
||||
* @returns the value, or undefined while the key is absent.
|
||||
*/
|
||||
get(key: string): unknown {
|
||||
return this.rows.get(key)?.value
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to any-key changes (microtask-batched) — the manager's list
|
||||
* rebuild channel.
|
||||
* @param listener - change callback.
|
||||
* @returns the unsubscribe function.
|
||||
*/
|
||||
subscribeAny(listener: () => void): () => void {
|
||||
return this.anyNotifier.subscribe(listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one finished value (the `session/projection` push-frame path).
|
||||
* @param key - projection key.
|
||||
* @param value - whole value computed by the host unit.
|
||||
* @param seq - the unit's watermark at emission.
|
||||
*/
|
||||
apply(key: string, value: unknown, seq: number): void {
|
||||
const row = this.rows.get(key)
|
||||
if (row !== undefined && seq <= row.seq) return // higher seq wins; replays and stale frames drop
|
||||
this.rows.set(key, { value, seq })
|
||||
this.changed(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed from a history tail page's projections block: every carried key
|
||||
* lands under the same seq rule as frames; a key the block omits is
|
||||
* capability-absent as of the cut — its row clears unless a newer frame
|
||||
* already superseded the cut (a stale baseline can neither overwrite nor
|
||||
* clear newer values).
|
||||
* @param baseline - the response's projections block.
|
||||
*/
|
||||
seed(baseline: ProjectionsBaseline): void {
|
||||
// Erased walk: the framework crosses the open key space; per-key typing
|
||||
// is re-established at the consumer (useProjection's map lookup).
|
||||
const values = baseline.values as Record<string, unknown>
|
||||
for (const key of Object.keys(values)) this.apply(key, values[key], baseline.asOfSeq)
|
||||
for (const [key, row] of this.rows) {
|
||||
if (Object.hasOwn(values, key)) continue
|
||||
if (row.seq > baseline.asOfSeq) continue
|
||||
this.rows.delete(key)
|
||||
this.changed(key)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop rows past a mux-generation baseline (`session/subscribed.lastSeq`):
|
||||
* a row claiming knowledge beyond the host's own durable baseline rode
|
||||
* state a restart lost — under last-wins it would wrongly outrank the
|
||||
* host's recomputed (lower-seq) values forever. Durable replay and the next
|
||||
* baseline re-seed whatever truly survived (the title-snapshot precedent,
|
||||
* generalized).
|
||||
* @param lastSeq - the subscribed frame's durable baseline seq.
|
||||
*/
|
||||
truncate(lastSeq: number): void {
|
||||
for (const [key, row] of this.rows) {
|
||||
if (row.seq <= lastSeq) continue
|
||||
this.rows.delete(key)
|
||||
this.changed(key)
|
||||
}
|
||||
}
|
||||
|
||||
private changed(key: string): void {
|
||||
this.channels.get(key)?.notifier.markDirty()
|
||||
this.anyNotifier.markDirty()
|
||||
}
|
||||
|
||||
private channel(key: string): Channel {
|
||||
let channel = this.channels.get(key)
|
||||
if (channel === undefined) {
|
||||
// The notifier only batches (no snapshot cache to rebuild: faces read rows directly).
|
||||
const notifier = new Notifier(() => {})
|
||||
channel = {
|
||||
notifier,
|
||||
face: {
|
||||
getSnapshot: () => this.rows.get(key)?.value,
|
||||
subscribe: listener => notifier.subscribe(listener),
|
||||
},
|
||||
}
|
||||
this.channels.set(key, channel)
|
||||
}
|
||||
return channel
|
||||
}
|
||||
}
|
||||
@@ -301,7 +301,7 @@ export class SessionsService {
|
||||
props[name] = undefined
|
||||
}
|
||||
}
|
||||
return { sessionId: undefined, hooks, props }
|
||||
return { sessionId: undefined, hooks, props } // no projections face: every key reads absent without a session
|
||||
}
|
||||
|
||||
/** Materialize the standard-props bundle for one session (fails loud on duplicate member names). */
|
||||
@@ -334,7 +334,14 @@ export class SessionsService {
|
||||
props[name] = contributedProps[name]
|
||||
}
|
||||
}
|
||||
return { sessionId: binding.sessionId, hooks, props }
|
||||
return {
|
||||
sessionId: binding.sessionId,
|
||||
hooks,
|
||||
props,
|
||||
// The useProjection seat: key-addressed bare value faces off the
|
||||
// session's projection store (open key space — never a static roster member).
|
||||
projections: { faceOf: key => binding.session.projections.faceOf(key) },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { SessionEvent, TodoItem } from '@deepseek-ai/dsh-session/types'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult,
|
||||
SessionId, ToolEventView,
|
||||
@@ -20,6 +20,8 @@ import { PendingWait } from './pending.ts'
|
||||
import { FoldAdapter } from './fold-adapter.ts'
|
||||
import { Notifier } from './notifier.ts'
|
||||
import { PartialAccumulator } from './partial.ts'
|
||||
import { ProjectionValueStore } from './projection-store.ts'
|
||||
import type { ProjectionsBaseline } from './projection-store.ts'
|
||||
|
||||
/** Messages requested per history page. */
|
||||
export const PAGE_MESSAGES = 50
|
||||
@@ -35,6 +37,12 @@ export interface SessionOptions {
|
||||
* (hidden, still reusable by connectWorkspace).
|
||||
*/
|
||||
onEngaged?(session: Session): void
|
||||
/**
|
||||
* Manager-owned projection value store to adopt (frames route through the
|
||||
* manager and values outlive instantiation); omitted, the Session owns a
|
||||
* private store (bare object-layer construction).
|
||||
*/
|
||||
projections?: ProjectionValueStore
|
||||
}
|
||||
|
||||
/** Queue-row preview cap: the dock renders one line, the full content never leaves the host mirror. */
|
||||
@@ -99,9 +107,6 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
private queueCache: { rev: number; value: QueuedMessage[] } | null = null
|
||||
private frozenRev = 0
|
||||
private nodesCache: { folded: readonly ConversationNode[]; frozenRev: number; value: readonly ConversationNode[] } | null = null
|
||||
/** Current whole-list todo/write projection: each tail history response replaces it (an omitted
|
||||
* field is the authoritative empty list) and every live write overwrites it. */
|
||||
private todos: readonly TodoItem[] = []
|
||||
/** `run_code` sub-dispatches by parent callId (window-derived, like openCalls). Appends
|
||||
* copy-on-write the per-parent array so published snapshot references never mutate. */
|
||||
private codeDispatches = new Map<string, readonly CodeSubCall[]>()
|
||||
@@ -126,6 +131,19 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
/** subscribed.lastSeq baseline (gap detection; null when no subscribed frame arrived — degrade to the liveBuffer dedup path). */
|
||||
private subscribedLastSeq: number | null = null
|
||||
|
||||
/**
|
||||
* Per-session projection value store (session-projection RFC, push model):
|
||||
* finished whole values computed on the host, seeded by the tail page's
|
||||
* projections block and updated by `session/projection` frames under the
|
||||
* one higher-seq-wins rule. Keys are read via `projections.faceOf(key)`
|
||||
* (the useProjection resolution face); the conversation snapshot never
|
||||
* carries projection values, and no client-side domain folding exists.
|
||||
* Manager-owned when constructed through SessionManager (frames route and
|
||||
* the store outlives instantiation, the title-snapshot precedent); a bare
|
||||
* construction gets a private store.
|
||||
*/
|
||||
readonly projections: ProjectionValueStore
|
||||
|
||||
private snapshotCache: ConversationSnapshot
|
||||
private readonly notifier = new Notifier(() => {
|
||||
this.snapshotCache = this.buildSnapshot()
|
||||
@@ -149,6 +167,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
private readonly api: IApiClient,
|
||||
private readonly options: SessionOptions = {},
|
||||
) {
|
||||
this.projections = options.projections ?? new ProjectionValueStore()
|
||||
this.snapshotCache = this.buildSnapshot()
|
||||
}
|
||||
|
||||
@@ -482,13 +501,13 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
this.openError = result.error
|
||||
return
|
||||
}
|
||||
this.installWindow(result.value.events, result.value.hasMore, result.value.todos)
|
||||
this.installWindow(result.value.events, result.value.hasMore, result.value.projections)
|
||||
// Gap detection (§D.3-4): baseline past the window tail and liveBuffer did not cover it -> pull the tail page once more.
|
||||
const tailSeq = this.windowTailSeq()
|
||||
if (this.subscribedLastSeq !== null && tailSeq !== null && this.subscribedLastSeq > tailSeq) {
|
||||
result = (await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })).result
|
||||
if (generation !== this.openGeneration) return
|
||||
if (result.ok) this.installWindow(result.value.events, result.value.hasMore, result.value.todos)
|
||||
if (result.ok) this.installWindow(result.value.events, result.value.hasMore, result.value.projections)
|
||||
}
|
||||
this.openState = 'open'
|
||||
} catch (error) {
|
||||
@@ -505,22 +524,18 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
/** Install the history window + stitch the liveBuffer (seq is the sole dedup key).
|
||||
* Stitching MUST NOT route through acceptLiveEvent: openState is still 'loading' here
|
||||
* (doOpen flips it after install), so recursing would push every buffered event straight
|
||||
* back into liveBuffer where nothing ever drains it — a silent drop loop (audit S1). */
|
||||
private installWindow(entries: HistoryEntry[], hasMore: boolean, todos: readonly TodoItem[] | undefined): void {
|
||||
* back into liveBuffer where nothing ever drains it — a silent drop loop (audit S1).
|
||||
* A carried projections block seeds the value store (higher seq wins, so a stale
|
||||
* baseline cannot overwrite a newer push frame); the window events themselves are
|
||||
* never folded — the host is the only computation site. */
|
||||
private installWindow(entries: HistoryEntry[], hasMore: boolean, projections?: ProjectionsBaseline): void {
|
||||
this.events = entries.map(e => e.event)
|
||||
this.views = entries.map(e => e.view)
|
||||
this.baseSeq = this.events[0]?.seq ?? 0
|
||||
this.hasMore = hasMore
|
||||
// Session-level projection from the tail page (full-log latest todo/write,
|
||||
// independent of the window); an in-window write below re-derives the same
|
||||
// value, and later live events keep overwriting it. Every caller here is a
|
||||
// tail request (no beforeSeq), which the host answers with the projection
|
||||
// or omits it only when the full log holds no todo/write — so an absent
|
||||
// field is the authoritative empty list, not a missing carrier. Assigning
|
||||
// it clears a plan the log never kept (a write lost to a host crash).
|
||||
this.todos = todos ?? []
|
||||
this.foldAdapter.reset(this.events, this.baseSeq, this.views)
|
||||
this.rebuildDerivedFromWindow()
|
||||
if (projections !== undefined) this.projections.seed(projections)
|
||||
const buffered = this.liveBuffer
|
||||
this.liveBuffer = []
|
||||
for (const item of buffered) this.appendLive(item.event, item.view)
|
||||
@@ -569,7 +584,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
const { result } = await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })
|
||||
// Failure or superseded by a full resync: drop — the resync path rebuilds and clears the buffer itself.
|
||||
if (result.ok && generation === this.openGeneration && this.openState === 'open') {
|
||||
this.installWindow(result.value.events, result.value.hasMore, result.value.todos)
|
||||
this.installWindow(result.value.events, result.value.hasMore, result.value.projections)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[web-runtime] gap repair failed:', error)
|
||||
@@ -689,10 +704,6 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
if (this.openCalls.delete(String(event.data.callId))) this.callsRev++
|
||||
return
|
||||
}
|
||||
case 'todo/write': {
|
||||
this.todos = event.data.todos
|
||||
return
|
||||
}
|
||||
case 'turn/end': {
|
||||
// Aborted turns never finalize. The accumulated partial is VALUE, not residue: freeze it
|
||||
// into an interrupted terminal node (pulse stops, text survives) instead of deleting it.
|
||||
@@ -737,10 +748,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
|
||||
/** Re-derive state (partial/openCalls/frozenNodes) from raw window events after a rebuild — keeps
|
||||
* paging/stitching consistent, and makes the live freeze and the history replay converge on the
|
||||
* same interrupted nodes (chunks are logged, so the replayed sweep re-freezes identical text).
|
||||
* todos is deliberately NOT reset: it is session-level (seeded by the tail page's full-log
|
||||
* projection, not derivable from an arbitrary window). The window always extends to the log
|
||||
* tail, so an in-window todo/write can only overwrite it with the same latest value. */
|
||||
* same interrupted nodes (chunks are logged, so the replayed sweep re-freezes identical text). */
|
||||
private rebuildDerivedFromWindow(): void {
|
||||
this.partial = null
|
||||
this.openCalls.clear()
|
||||
@@ -810,7 +818,6 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
promptError: this.promptError,
|
||||
blank: this.blankBit,
|
||||
lastAgentError: this.lastAgentError,
|
||||
todos: this.todos,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,6 +182,17 @@ export class WorkspacesService {
|
||||
return response.result.value.path
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a filesystem path with the Host operating system's default application.
|
||||
* @param path - absolute or host-resolvable path.
|
||||
*/
|
||||
async openPath(path: string): Promise<void> {
|
||||
const response = await this.api.host.openPath({ path })
|
||||
if (!response.result.ok) {
|
||||
throw new Error(`path open failed: ${response.result.error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a Workspace.
|
||||
* @param workspaceId - target workspace.
|
||||
|
||||
@@ -40,8 +40,10 @@ export const ev = {
|
||||
at(seq, { type: 'step/end', data: { turn, step } }),
|
||||
turnEnd: (seq: number, turn: number, reason: 'completed' | 'cancelled' = 'completed'): SessionEvent =>
|
||||
at(seq, { type: 'turn/end', data: { turn, reason: { kind: reason } } }),
|
||||
todoWrite: (seq: number, todos: { content: string; status: 'pending' | 'in_progress' | 'completed' }[]): SessionEvent =>
|
||||
at(seq, { type: 'todo/write', data: { todos } }),
|
||||
commandRun: (seq: number, commandId: string, name: string, args = ''): SessionEvent =>
|
||||
at(seq, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }),
|
||||
commandDone: (seq: number, commandId: string, kind: 'success' | 'error' = 'success', text?: string): SessionEvent =>
|
||||
at(seq, { type: 'command/done', data: { commandId, kind, ...text === undefined ? {} : { text } } }),
|
||||
}
|
||||
|
||||
/** One complete plain turn (turn/start → user → step → assistant → turn/end), 6 events from startSeq. */
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// Test-local programmable IApiClient fake (NOT the fixture: fixture is a demo
|
||||
// data source on a real clock; behavior tests need per-case responses and
|
||||
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
|
||||
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import type {
|
||||
ClientResponse, CommandDescriptor, CommandExecuteResult, HostFrame, IApiClient, ModelTarget, MuxFrame,
|
||||
ClientResponse, CommandDescriptor, HostFrame, IApiClient, ModelTarget, MuxFrame,
|
||||
RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionModels, SkillEntry,
|
||||
WorkspaceId, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
@@ -63,7 +64,7 @@ export class FakeApiClient implements IApiClient {
|
||||
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
|
||||
readonly defaultModel: ModelTarget = { provider: 'deepseek', model: 'deepseek-v4-flash' }
|
||||
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
|
||||
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean; todos?: { content: string; status: 'pending' | 'in_progress' | 'completed' }[] }>> =
|
||||
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean }>> =
|
||||
() => Promise.resolve(ok({ events: [], hasMore: false }))
|
||||
|
||||
onModels: (payload: unknown) => Promise<RpcResponse<SessionModels>> = () => Promise.resolve(ok({
|
||||
@@ -84,6 +85,8 @@ export class FakeApiClient implements IApiClient {
|
||||
() => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 }))
|
||||
onPickDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string | null }>> =
|
||||
() => Promise.resolve(ok({ path: null }))
|
||||
onOpenPath: (payload: unknown) => Promise<RpcResponse<{ opened: true }>> =
|
||||
() => Promise.resolve(ok({ opened: true as const }))
|
||||
|
||||
private readonly muxConns: StreamConn<MuxFrame>[] = []
|
||||
private readonly hostConns: StreamConn<HostFrame>[] = []
|
||||
@@ -106,6 +109,7 @@ export class FakeApiClient implements IApiClient {
|
||||
readonly host: IApiClient['host'] = {
|
||||
describe: (payload: unknown) => this.record('host.describe', payload, this.onDescribe(payload)),
|
||||
pickDirectory: (payload: unknown) => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)),
|
||||
openPath: (payload: unknown) => this.record('host.openPath', payload, this.onOpenPath(payload)),
|
||||
}
|
||||
|
||||
onWorkspaceList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
|
||||
@@ -133,10 +137,12 @@ export class FakeApiClient implements IApiClient {
|
||||
// Payloads stay `unknown` (lint-lane note above); response rows are the real
|
||||
// wire shapes so cases can program requires-bearing catalogs and dual-address
|
||||
// skill lists without casts.
|
||||
onCommandList: (payload: unknown) => Promise<RpcResponse<{ commands: CommandDescriptor[] }>> = () => Promise.resolve(ok({ commands: [] }))
|
||||
onCommandExecute: (payload: unknown) => Promise<RpcResponse<{ matched: boolean; result?: CommandExecuteResult }>> =
|
||||
() => Promise.resolve(ok({ matched: false }))
|
||||
onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>> = () => Promise.resolve(ok({ skills: [] }))
|
||||
onCommandList: (payload: unknown) => Promise<RpcResponse<{ commands: CommandDescriptor[] }>>
|
||||
= () => Promise.resolve(ok({ commands: [] }))
|
||||
onCommandExecute: (payload: unknown) => Promise<RpcResponse<{ matched: boolean; commandId?: CommandId }>>
|
||||
= () => Promise.resolve(ok({ matched: false }))
|
||||
onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>>
|
||||
= () => Promise.resolve(ok({ skills: [] }))
|
||||
|
||||
readonly commands: IApiClient['commands'] = {
|
||||
list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)),
|
||||
|
||||
@@ -142,4 +142,75 @@ describe('FoldAdapter', () => {
|
||||
const node = adapter.nodes().nodes[0]
|
||||
expect(node).toMatchObject({ kind: 'tool-result', call: null, callView: null, resultView: { title: '孤儿' } })
|
||||
})
|
||||
|
||||
describe('command lifecycle nodes', () => {
|
||||
it('folds a run/done pair into one settled node merged into flow order by seq', () => {
|
||||
const adapter = new FoldAdapter()
|
||||
adapter.reset([
|
||||
ev.user(0, '先说话'),
|
||||
ev.commandRun(1, 'cmd-1', 'plan'),
|
||||
ev.commandDone(2, 'cmd-1', 'success', '已进入 plan mode'),
|
||||
ev.assistant(3, 0, '然后回答'),
|
||||
], 0)
|
||||
const { nodes } = adapter.nodes()
|
||||
expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 0], ['command', 1], ['assistant', 3]])
|
||||
expect(nodes[1]).toMatchObject({
|
||||
kind: 'command', commandId: 'cmd-1', name: 'plan', args: '',
|
||||
outcome: { kind: 'success', text: '已进入 plan mode' },
|
||||
})
|
||||
})
|
||||
|
||||
it('renders a run with no done as still executing (outcome null)', () => {
|
||||
const adapter = new FoldAdapter()
|
||||
adapter.reset([ev.commandRun(0, 'cmd-2', 'goal', ' ship it')], 0)
|
||||
expect(adapter.nodes().nodes[0]).toMatchObject({
|
||||
kind: 'command', name: 'goal', args: ' ship it', outcome: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('soft-falls a done-only window into a node built from the done (cross-window cut)', () => {
|
||||
const adapter = new FoldAdapter()
|
||||
adapter.reset([ev.commandDone(80, 'cmd-3', 'error', '失败了')], 80)
|
||||
expect(adapter.nodes().nodes[0]).toMatchObject({
|
||||
kind: 'command', seq: 80, commandId: 'cmd-3', name: null, args: null,
|
||||
outcome: { kind: 'error', text: '失败了' },
|
||||
})
|
||||
})
|
||||
|
||||
it('settles a live-appended done in place, keeping the node at the run seq', () => {
|
||||
const adapter = new FoldAdapter()
|
||||
adapter.reset(plainTurn(0, 0, 'q', 'a'), 0)
|
||||
adapter.append(ev.commandRun(6, 'cmd-4', 'clear'))
|
||||
const running = adapter.nodes().nodes.find(n => n.kind === 'command')
|
||||
expect(running).toMatchObject({ outcome: null })
|
||||
adapter.append(ev.commandDone(7, 'cmd-4'))
|
||||
const settled = adapter.nodes().nodes.find(n => n.kind === 'command')
|
||||
expect(settled).toMatchObject({ seq: 6, outcome: { kind: 'success' } })
|
||||
// Settlement replaced the node object rather than mutating the published one.
|
||||
expect(settled).not.toBe(running)
|
||||
})
|
||||
|
||||
it('tails command nodes whose seq is past every surface node', () => {
|
||||
const adapter = new FoldAdapter()
|
||||
adapter.reset([ev.user(0, '问'), ev.commandRun(1, 'cmd-tail', 'plan')], 0)
|
||||
expect(adapter.nodes().nodes.map(n => n.kind)).toEqual(['user', 'command'])
|
||||
})
|
||||
|
||||
it('command nodes survive the degraded linear-scan branch', () => {
|
||||
const adapter = new FoldAdapter()
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
try {
|
||||
adapter.reset([
|
||||
ev.commandRun(0, 'cmd-5', 'plan'),
|
||||
ev.commandDone(1, 'cmd-5'),
|
||||
at(2, { type: 'assistant/message', surfaceOp: 'bogus-op', data: { turn: 0, step: 0, content: [{ type: 'text', text: '坏 op' }], provenance: { provider: 'x', model: 'y' } } }),
|
||||
], 0)
|
||||
const { nodes, degraded } = adapter.nodes()
|
||||
expect(degraded).toBe(true)
|
||||
expect(nodes.some(n => n.kind === 'command')).toBe(true)
|
||||
} finally {
|
||||
errorSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -133,21 +133,18 @@ describe('list lifecycle', () => {
|
||||
expect(manager.getListSnapshot().items.map(i => i.sessionId)).toEqual([S2])
|
||||
})
|
||||
|
||||
it('retains monotonic title snapshots before list arrival, merges recency, and clears them on removal', async () => {
|
||||
it('retains title projections before list arrival, keeps last-wins by seq, and clears them on removal', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'title-new' as never,
|
||||
payload: { type: 'session/title', sessionId: S1, title: 'Newest', eventSeq: 4, updatedAt: 300 },
|
||||
})
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'title-stale' as never,
|
||||
payload: { type: 'session/title', sessionId: S1, title: 'Stale', eventSeq: 3, updatedAt: 900 },
|
||||
})
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'title-equal' as never,
|
||||
payload: { type: 'session/title', sessionId: S1, title: 'Equal', eventSeq: 4, updatedAt: 901 },
|
||||
})
|
||||
const titleFrame = (rpcId: string, title: string, seq: number) => {
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: rpcId as never,
|
||||
payload: { type: 'session/projection', sessionId: S1, key: 'title', value: title, seq } as never,
|
||||
})
|
||||
}
|
||||
titleFrame('title-new', 'Newest', 4)
|
||||
titleFrame('title-stale', 'Stale', 3)
|
||||
titleFrame('title-equal', 'Equal', 4)
|
||||
api.onList = () => Promise.resolve(ok({
|
||||
items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[],
|
||||
}))
|
||||
@@ -155,7 +152,7 @@ describe('list lifecycle', () => {
|
||||
|
||||
const titled = manager.getListSnapshot()
|
||||
expect(titled.items.map(item => item.sessionId)).toEqual([S1, S2])
|
||||
expect(titled.items[0]).toMatchObject({ title: 'Newest', updatedAt: 300 })
|
||||
expect(titled.items[0]?.title).toBe('Newest')
|
||||
expect(titled.items[1]?.title).toBeUndefined()
|
||||
|
||||
manager.handleHostEnvelope({ rpcId: 'removed' as never, payload: { type: 'host/session-removed', sessionId: S1 } })
|
||||
@@ -163,34 +160,27 @@ describe('list lifecycle', () => {
|
||||
expect(manager.getListSnapshot().items.find(item => item.sessionId === S1)?.title).toBeUndefined()
|
||||
})
|
||||
|
||||
it('drops a retained title beyond the subscription baseline before accepting its durable replay', async () => {
|
||||
it('drops a projection row beyond the subscription baseline before accepting its durable replay', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onList = () => Promise.resolve(ok({ items: [summary(S1)] as never[] }))
|
||||
const manager = new SessionManager(api)
|
||||
await manager.refreshList()
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'title-unflushed' as never,
|
||||
payload: { type: 'session/title', sessionId: S1, title: 'Unflushed', eventSeq: 4, updatedAt: 400 },
|
||||
})
|
||||
const frame = (rpcId: string, payload: object) => {
|
||||
manager.handleMuxEnvelope({ rpcId: rpcId as never, payload: payload as never })
|
||||
}
|
||||
frame('title-unflushed', { type: 'session/projection', sessionId: S1, key: 'title', value: 'Unflushed', seq: 4 })
|
||||
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'subscribed-recovered' as never,
|
||||
payload: { type: 'session/subscribed', sessionId: S1, lastSeq: 2 },
|
||||
})
|
||||
// The durable baseline says the host only knows up to seq 2: the phantom
|
||||
// row rode lost state and must drop, or last-wins pins it forever.
|
||||
frame('subscribed-recovered', { type: 'session/subscribed', sessionId: S1, lastSeq: 2 })
|
||||
expect(manager.getListSnapshot().items[0]?.title).toBeUndefined()
|
||||
expect(manager.getListSnapshot().items[0]?.updatedAt).toBe(100)
|
||||
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'title-durable' as never,
|
||||
payload: { type: 'session/title', sessionId: S1, title: 'Durable', eventSeq: 2, updatedAt: 200 },
|
||||
})
|
||||
expect(manager.getListSnapshot().items[0]).toMatchObject({ title: 'Durable', updatedAt: 200 })
|
||||
frame('title-durable', { type: 'session/projection', sessionId: S1, key: 'title', value: 'Durable', seq: 2 })
|
||||
expect(manager.getListSnapshot().items[0]?.title).toBe('Durable')
|
||||
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'subscribed-current' as never,
|
||||
payload: { type: 'session/subscribed', sessionId: S1, lastSeq: 2 },
|
||||
})
|
||||
expect(manager.getListSnapshot().items[0]).toMatchObject({ title: 'Durable', updatedAt: 200 })
|
||||
// A baseline at or past the row's seq keeps it (nothing phantom to drop).
|
||||
frame('subscribed-current', { type: 'session/subscribed', sessionId: S1, lastSeq: 2 })
|
||||
expect(manager.getListSnapshot().items[0]?.title).toBe('Durable')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
187
packages/client/runtime/tests/projection-store.spec.ts
Normal file
187
packages/client/runtime/tests/projection-store.spec.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Projection value store (session-projection RFC, push model): the single
|
||||
* higher-seq-wins rule on both paths (a stale baseline cannot overwrite a
|
||||
* newer push frame; a replayed frame cannot regress), capability absence as
|
||||
* undefined, generation truncation, and the Session/manager wiring (tail-page
|
||||
* seeding, session/projection frame routing pre- and post-instantiation, the
|
||||
* list rows' title projection).
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { ProjectionValueStore } from '../src/client/sessions/projection-store.ts'
|
||||
import { Session } from '../src/client/sessions/session.ts'
|
||||
import { SessionManager } from '../src/client/sessions/manager.ts'
|
||||
import { FakeApiClient, ok } from './fake-api.ts'
|
||||
import { entries, plainTurn } from './event-script.ts'
|
||||
|
||||
// Test-domain keys merged into the projection map (the interface package's
|
||||
// pure-type outlet), the same way domain host plugins merge theirs.
|
||||
declare module '@deepseek-ai/dsh-session-projection/types' {
|
||||
interface SessionProjectionMap {
|
||||
'test/marks': { marks: string[] }
|
||||
}
|
||||
}
|
||||
|
||||
const SID = 'fk-s1' as SessionId
|
||||
|
||||
describe('ProjectionValueStore semantics', () => {
|
||||
it('reads undefined until a value lands (capability absence)', () => {
|
||||
const store = new ProjectionValueStore()
|
||||
expect(store.get('test/marks')).toBeUndefined()
|
||||
expect(store.faceOf('test/marks').getSnapshot()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('applies frames last-wins by seq: replayed and stale frames drop', () => {
|
||||
const store = new ProjectionValueStore()
|
||||
store.apply('test/marks', { marks: ['a'] }, 5)
|
||||
store.apply('test/marks', { marks: ['a', 'b'] }, 9)
|
||||
expect(store.get('test/marks')).toEqual({ marks: ['a', 'b'] })
|
||||
store.apply('test/marks', { marks: ['stale'] }, 5)
|
||||
store.apply('test/marks', { marks: ['equal'] }, 9)
|
||||
expect(store.get('test/marks')).toEqual({ marks: ['a', 'b'] })
|
||||
})
|
||||
|
||||
it('a stale baseline can neither overwrite nor clear a newer frame; a fresh one reseeds and clears', () => {
|
||||
const store = new ProjectionValueStore()
|
||||
store.apply('test/marks', { marks: ['frame-20'] }, 20)
|
||||
// Stale cut: carried key loses to the newer frame; omitted key survives.
|
||||
store.seed({ asOfSeq: 10, values: { 'test/marks': { marks: ['baseline-10'] } } })
|
||||
expect(store.get('test/marks')).toEqual({ marks: ['frame-20'] })
|
||||
store.seed({ asOfSeq: 15, values: {} })
|
||||
expect(store.get('test/marks')).toEqual({ marks: ['frame-20'] })
|
||||
// Fresh cut: carried key reseeds…
|
||||
store.seed({ asOfSeq: 30, values: { 'test/marks': { marks: ['baseline-30'] } } })
|
||||
expect(store.get('test/marks')).toEqual({ marks: ['baseline-30'] })
|
||||
// …and an omitting fresh cut clears (capability absent as of the cut).
|
||||
store.seed({ asOfSeq: 40, values: {} })
|
||||
expect(store.get('test/marks')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('truncate drops rows past the durable baseline and keeps the rest', () => {
|
||||
const store = new ProjectionValueStore()
|
||||
store.apply('test/marks', { marks: ['durable'] }, 5)
|
||||
store.apply('other', 'phantom', 50)
|
||||
store.truncate(10)
|
||||
expect(store.get('test/marks')).toEqual({ marks: ['durable'] })
|
||||
expect(store.get('other')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('notifies the key face on change (batched) and not on dropped applications', async () => {
|
||||
const store = new ProjectionValueStore()
|
||||
let keyTicks = 0
|
||||
let anyTicks = 0
|
||||
store.faceOf('test/marks').subscribe(() => { keyTicks += 1 })
|
||||
store.subscribeAny(() => { anyTicks += 1 })
|
||||
store.apply('test/marks', { marks: ['a'] }, 5)
|
||||
await Promise.resolve()
|
||||
expect(keyTicks).toBe(1)
|
||||
expect(anyTicks).toBe(1)
|
||||
store.apply('test/marks', { marks: ['replay'] }, 3)
|
||||
await Promise.resolve()
|
||||
expect(keyTicks).toBe(1)
|
||||
expect(anyTicks).toBe(1)
|
||||
})
|
||||
|
||||
it('faces are identity-stable per key (the React binding cache premise)', () => {
|
||||
const store = new ProjectionValueStore()
|
||||
expect(store.faceOf('test/marks')).toBe(store.faceOf('test/marks'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Session tail-page seeding', () => {
|
||||
it('seeds the store from a history response carrying a projections block', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const session = new Session(SID, api)
|
||||
api.onHistory = () => Promise.resolve(ok({
|
||||
events: entries(plainTurn(0, 0, '问', '答')) as never[], hasMore: false,
|
||||
projections: { asOfSeq: 5, values: { 'test/marks': { marks: ['from-baseline'] } } },
|
||||
} as never))
|
||||
await session.open()
|
||||
expect(session.projections.get('test/marks')).toEqual({ marks: ['from-baseline'] })
|
||||
})
|
||||
|
||||
it('a resync serving a stale block keeps the newer pushed value (seq rule end to end)', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const session = new Session(SID, api)
|
||||
api.onHistory = () => Promise.resolve(ok({
|
||||
events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false,
|
||||
projections: { asOfSeq: 5, values: { 'test/marks': { marks: ['baseline'] } } },
|
||||
} as never))
|
||||
await session.open()
|
||||
session.projections.apply('test/marks', { marks: ['pushed-9'] }, 9)
|
||||
await session.resync()
|
||||
expect(session.projections.get('test/marks')).toEqual({ marks: ['pushed-9'] })
|
||||
})
|
||||
|
||||
it('treats a blockless response as no reset: pushed values survive', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const session = new Session(SID, api)
|
||||
api.onHistory = () => Promise.resolve(ok({ events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false }))
|
||||
await session.open()
|
||||
session.projections.apply('test/marks', { marks: ['pushed'] }, 9)
|
||||
await session.resync()
|
||||
expect(session.projections.get('test/marks')).toEqual({ marks: ['pushed'] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('manager frame routing', () => {
|
||||
const sid = (s: string): SessionId => s as SessionId
|
||||
|
||||
it('lands session/projection frames before instantiation and the Session adopts the same store', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'p1' as never,
|
||||
payload: { type: 'session/projection', sessionId: sid('s1'), key: 'test/marks', value: { marks: ['early'] }, seq: 7 } as never,
|
||||
})
|
||||
const session = manager.get(sid('s1'))
|
||||
expect(session.projections.get('test/marks')).toEqual({ marks: ['early'] })
|
||||
// Frames after instantiation land in the same store.
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'p2' as never,
|
||||
payload: { type: 'session/projection', sessionId: sid('s1'), key: 'test/marks', value: { marks: ['later'] }, seq: 9 } as never,
|
||||
})
|
||||
expect(session.projections.get('test/marks')).toEqual({ marks: ['later'] })
|
||||
})
|
||||
|
||||
it('projects the title key into list rows and truncates phantom rows on the subscribed baseline', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
api.onList = () => Promise.resolve(ok({
|
||||
items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }],
|
||||
}) as never)
|
||||
await manager.refreshList()
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 't1' as never,
|
||||
payload: { type: 'session/projection', sessionId: sid('s1'), key: 'title', value: 'Projected title', seq: 4 } as never,
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(manager.getListSnapshot().items[0]?.title).toBe('Projected title')
|
||||
// The durable baseline says the host only knows up to seq 2: the row rode
|
||||
// lost state and must drop (the un-flushed title precedent).
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'sub' as never,
|
||||
payload: { type: 'session/subscribed', sessionId: sid('s1'), lastSeq: 2 } as never,
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(manager.getListSnapshot().items[0]?.title).toBeUndefined()
|
||||
})
|
||||
|
||||
it('drops the projection store with the removed session', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
api.onList = () => Promise.resolve(ok({
|
||||
items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }],
|
||||
}) as never)
|
||||
await manager.refreshList()
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 't1' as never,
|
||||
payload: { type: 'session/projection', sessionId: sid('s1'), key: 'title', value: 'Doomed', seq: 4 } as never,
|
||||
})
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'rm' as never,
|
||||
payload: { type: 'host/session-removed', sessionId: sid('s1') } as never,
|
||||
})
|
||||
expect(manager.get(sid('s1')).projections.get('title')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -22,9 +22,9 @@ function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session:
|
||||
return { api, session: new Session(SID, api) }
|
||||
}
|
||||
|
||||
function histResponse(events: SessionEvent[], hasMore = false, todos?: { content: string; status: 'pending' | 'in_progress' | 'completed' }[]) {
|
||||
function histResponse(events: SessionEvent[], hasMore = false) {
|
||||
// history now returns HistoryEntry[] ({event, view?}); these tests are view-less.
|
||||
return Promise.resolve(ok({ events: entries(events) as never[], hasMore, ...todos === undefined ? {} : { todos } }))
|
||||
return Promise.resolve(ok({ events: entries(events) as never[], hasMore }))
|
||||
}
|
||||
|
||||
describe('open', () => {
|
||||
@@ -104,6 +104,28 @@ describe('live event path', () => {
|
||||
expect(session.getSnapshot().nodes).toEqual(before.nodes)
|
||||
})
|
||||
|
||||
it('materializes a command node from live lifecycle frames and reproduces it from a history window', async () => {
|
||||
// Live path: run mints an executing node, done settles it in the flow.
|
||||
const { session } = await opened()
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
feed(ev.commandRun(6, 'cmd-live', 'plan'))
|
||||
let command = session.getSnapshot().nodes.at(-1)
|
||||
expect(command).toMatchObject({ kind: 'command', name: 'plan', args: '', outcome: null })
|
||||
feed(ev.commandDone(7, 'cmd-live', 'success', '已进入 plan mode'))
|
||||
command = session.getSnapshot().nodes.at(-1)
|
||||
expect(command).toMatchObject({ kind: 'command', seq: 6, outcome: { kind: 'success', text: '已进入 plan mode' } })
|
||||
|
||||
// Replay path (refresh): the same pair inside the history window folds identically.
|
||||
const replayed = await opened([
|
||||
...plainTurn(0, 0, 'a', 'b'),
|
||||
ev.commandRun(6, 'cmd-live', 'plan'),
|
||||
ev.commandDone(7, 'cmd-live', 'success', '已进入 plan mode'),
|
||||
])
|
||||
expect(replayed.session.getSnapshot().nodes.at(-1)).toMatchObject({
|
||||
kind: 'command', seq: 6, name: 'plan', outcome: { kind: 'success', text: '已进入 plan mode' },
|
||||
})
|
||||
})
|
||||
|
||||
it('accumulates chunks into partial, then finalize swaps partial out as the node lands', async () => {
|
||||
const { session } = await opened()
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
@@ -158,42 +180,6 @@ describe('live event path', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('folds todo/write into snapshot.todos last-write-wins, live and on window replay', async () => {
|
||||
const listA = [{ content: '搭骨架', status: 'completed' as const }, { content: '写组件', status: 'in_progress' as const }]
|
||||
const listB = [{ content: '搭骨架', status: 'completed' as const }, { content: '写组件', status: 'completed' as const }]
|
||||
const { session } = await opened()
|
||||
expect(session.getSnapshot().todos).toEqual([])
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
feed(ev.todoWrite(6, listA))
|
||||
expect(session.getSnapshot().todos).toEqual(listA)
|
||||
feed(ev.todoWrite(7, listB))
|
||||
expect(session.getSnapshot().todos).toEqual(listB)
|
||||
// Window replay converges on the same last snapshot (history contains both writes).
|
||||
const replayed = makeSession()
|
||||
replayed.api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ev.todoWrite(6, listA), ev.todoWrite(7, listB)])
|
||||
await replayed.session.open()
|
||||
expect(replayed.session.getSnapshot().todos).toEqual(listB)
|
||||
})
|
||||
|
||||
it('seeds todos from the tail page projection when the last write precedes the window', async () => {
|
||||
const list = [{ content: '窗口外的计划', status: 'in_progress' as const }]
|
||||
// Cold open: the page window carries NO todo/write; the projection rides the response.
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => histResponse(plainTurn(100, 9, '问', '答'), true, list)
|
||||
await session.open()
|
||||
expect(session.getSnapshot().todos).toEqual(list)
|
||||
// Paging an older window in must not clear the session-level projection.
|
||||
api.onHistory = () => histResponse(plainTurn(94, 8, '旧问', '旧答'), false)
|
||||
await session.loadOlder()
|
||||
expect(session.getSnapshot().todos).toEqual(list)
|
||||
// A later live write still overrides the seeded projection.
|
||||
session.handleMuxEnvelope('r' as never, {
|
||||
type: 'session/event', sessionId: SID,
|
||||
event: ev.todoWrite(106, [{ content: '新计划', status: 'pending' as const }]),
|
||||
})
|
||||
expect(session.getSnapshot().todos).toEqual([{ content: '新计划', status: 'pending' }])
|
||||
})
|
||||
|
||||
it('repairs a seq gap by repulling the tail page instead of appending a hole', async () => {
|
||||
const { api, session } = await opened(plainTurn(0, 0, 'a', 'b')) // tail seq = 5
|
||||
const repaired = [...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')]
|
||||
@@ -207,37 +193,6 @@ describe('live event path', () => {
|
||||
const seqs = session.getSnapshot().nodes.map(n => n.seq)
|
||||
expect(seqs).toEqual([1, 3, 7, 9]) // both turns' user/assistant, no hole, no duplicate 9
|
||||
})
|
||||
|
||||
it('gap repair adopts the repull response projection (a missed todo/write outside the new tail page)', async () => {
|
||||
const { api, session } = await opened(plainTurn(0, 0, 'a', 'b')) // tail seq = 5
|
||||
expect(session.getSnapshot().todos).toEqual([])
|
||||
// The missed range contained a todo/write that the repulled page no longer
|
||||
// covers; the response's session-level projection is the only carrier.
|
||||
const current = [{ content: '断线期间写的', status: 'in_progress' as const }]
|
||||
api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...plainTurn(8, 1, 'c', 'd')], false, current)
|
||||
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.assistant(11, 1, 'd') })
|
||||
await vi.waitFor(() => {
|
||||
expect(api.callsOf('session.history').length).toBe(2)
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(session.getSnapshot().todos).toEqual(current)
|
||||
})
|
||||
|
||||
it('clears the plan when a tail response omits the projection (a write the log never kept)', async () => {
|
||||
// Live write lands, then the host crashes before persisting it: the
|
||||
// authoritative log holds no todo/write, so the resync tail response
|
||||
// carries no projection — an omitted field on a tail request is the empty
|
||||
// list, not a missing carrier, and the rolled-back plan must disappear.
|
||||
const { api, session } = await opened(plainTurn(0, 0, 'a', 'b'))
|
||||
session.handleMuxEnvelope('r' as never, {
|
||||
type: 'session/event', sessionId: SID,
|
||||
event: ev.todoWrite(6, [{ content: '丢失的计划', status: 'in_progress' as const }]),
|
||||
})
|
||||
expect(session.getSnapshot().todos).toEqual([{ content: '丢失的计划', status: 'in_progress' }])
|
||||
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
|
||||
await session.resync()
|
||||
expect(session.getSnapshot().todos).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('paging', () => {
|
||||
|
||||
@@ -47,7 +47,7 @@ describe('list store projection', () => {
|
||||
const b = bench()
|
||||
b.svc.handleMuxEnvelope({
|
||||
rpcId: 'title' as never,
|
||||
payload: { type: 'session/title', sessionId: sid('s1'), title: 'Durable title', eventSeq: 2, updatedAt: 3 },
|
||||
payload: { type: 'session/projection', sessionId: sid('s1'), key: 'title', value: 'Durable title', seq: 2 } as never,
|
||||
})
|
||||
await feedList(b, [
|
||||
{ id: 's1', cwd: '/home/u/proj-a/' },
|
||||
|
||||
@@ -236,6 +236,17 @@ describe('WorkspacesService', () => {
|
||||
expect(api.callsOf('host.pickDirectory')).toEqual([{}, {}])
|
||||
})
|
||||
|
||||
it('opens a filesystem path through the host without local state', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api)
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
await expect(workspaces.openPath('/w/alpha/a.ts')).resolves.toBeUndefined()
|
||||
expect(api.callsOf('host.openPath')).toEqual([{ path: '/w/alpha/a.ts' }])
|
||||
api.onOpenPath = () => Promise.resolve(err({ code: 'internal', message: 'boom', details: {} }))
|
||||
await expect(workspaces.openPath('/missing')).rejects.toThrow(/path open failed/)
|
||||
})
|
||||
|
||||
it('deletes a Workspace or preserves it when the Host rejects deletion', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
|
||||
@@ -23,6 +23,15 @@
|
||||
{
|
||||
"path": "../../host/apiproxy"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/commands"
|
||||
},
|
||||
{
|
||||
"path": "../../session-projection/session-projection"
|
||||
},
|
||||
{
|
||||
"path": "../../session-title/session-title"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
min-width: 220px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
/* Elevated surface: the scrollbar thumb takes the l2 elevation tokens
|
||||
(see ui-theme styles/scrollbar.css for the rebinding contract). */
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
||||
border: 1px solid var(--dsw-alias-border-inverted);
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-specific-menu);
|
||||
|
||||
@@ -227,7 +227,15 @@ export class CommandService extends Service implements CommandServiceContract {
|
||||
}
|
||||
}
|
||||
|
||||
/** The command.execute transaction, addressed to the session's agent. */
|
||||
/**
|
||||
* The command.execute transaction, addressed to the session's agent — pure
|
||||
* admission semantics. An unmatched line reports an error outcome (the
|
||||
* composer's immediate admission feedback); an admitted command reports
|
||||
* plain success regardless of its handler outcome, because the host
|
||||
* executor durably logged the lifecycle (`command/run`/`command/done`) and
|
||||
* the outcome renders as a persistent flow node — the composer never
|
||||
* echoes it. Transport failures throw.
|
||||
*/
|
||||
private async execute(
|
||||
session: ClientSessionContext,
|
||||
line: string,
|
||||
@@ -236,25 +244,25 @@ export class CommandService extends Service implements CommandServiceContract {
|
||||
const { result } = await connection.api.commands.execute({ sessionId: session.sessionId, line })
|
||||
if (!result.ok) throw new Error(`command.execute failed: ${result.error.code}: ${result.error.message}`)
|
||||
if (!result.value.matched) return { kind: 'error', text: `unknown or malformed command: ${line}` }
|
||||
const detached = result.value.result
|
||||
return detached === undefined
|
||||
? { kind: 'success' }
|
||||
: { kind: detached.kind, ...(detached.text !== undefined ? { text: detached.text } : {}) }
|
||||
return { kind: 'success' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget execute for the internal ('handled') paths. The detached
|
||||
* result surfaces as a notice routed to the triggering session's composer,
|
||||
* so a late result lands on its own session after a switch.
|
||||
* Fire-and-forget execute for the internal ('handled') paths. Outcomes are
|
||||
* NOT surfaced here: the host executor durably logs the command lifecycle
|
||||
* (`command/run`/`command/done`), and the mux-broadcast events render as a
|
||||
* persistent flow node on every tab. Only a transport/admission failure —
|
||||
* which never entered a handler and therefore never logged — falls back to
|
||||
* the composer notice as immediate feedback.
|
||||
*/
|
||||
private runDetached(desc: CommandDescriptor, session: ClientSessionContext, line: string): void {
|
||||
void this.execute(session, line).then(
|
||||
(outcome) => {
|
||||
if (outcome.kind === 'error') this.noticeFor(session.sessionId, desc.name, 'error', outcome.text ?? `/${desc.name} failed`)
|
||||
else if (outcome.text !== undefined) this.noticeFor(session.sessionId, desc.name, 'info', outcome.text)
|
||||
// matched:false maps to an error outcome with no logged lifecycle.
|
||||
if (outcome.kind === 'error') this.noticeFor(session.sessionId, 'error', outcome.text ?? `/${desc.name} failed`)
|
||||
},
|
||||
(error: unknown) => {
|
||||
this.noticeFor(session.sessionId, desc.name, 'error', error instanceof Error ? error.message : String(error))
|
||||
this.noticeFor(session.sessionId, 'error', error instanceof Error ? error.message : String(error))
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -270,8 +278,8 @@ export class CommandService extends Service implements CommandServiceContract {
|
||||
})
|
||||
}
|
||||
|
||||
/** Route a detached result to the session's composer notice channel (scope gone = attempt died with it). */
|
||||
private noticeFor(id: SessionId, _name: string, level: 'info' | 'error', text: string): void {
|
||||
/** Route an admission/transport failure to the session's composer notice channel (scope gone = attempt died with it). */
|
||||
private noticeFor(id: SessionId, level: 'info' | 'error', text: string): void {
|
||||
const actx = this.scopeFor(id)
|
||||
if (actx === undefined) return
|
||||
const conversation = actx.get('conversation')
|
||||
|
||||
@@ -31,7 +31,7 @@ const S2_CMDS: CommandDescriptor[] = [
|
||||
{ name: 'attach', description: 'scoped shadow', input: { hint: 'path' } },
|
||||
]
|
||||
|
||||
type ExecuteValue = { matched: boolean; result?: { kind: 'success' | 'error'; text?: string } }
|
||||
type ExecuteValue = { matched: boolean }
|
||||
|
||||
interface BenchOptions {
|
||||
/** Scripted catalog per list payload; default serves the fixed catalogs by session. */
|
||||
@@ -361,16 +361,18 @@ describe('matchEnter (enter column)', () => {
|
||||
})
|
||||
|
||||
describe('execute payload', () => {
|
||||
it('claim.submit addresses the session and maps the detached result', async () => {
|
||||
it('claim.submit addresses the session; admitted outcomes stay off the composer (flow card owns them)', async () => {
|
||||
const { source, warm, executeCalls } = await bench({
|
||||
execute: () => Promise.resolve({ matched: true, result: { kind: 'success', text: 'goal set' } }),
|
||||
execute: () => Promise.resolve({ matched: true }),
|
||||
})
|
||||
await warm(proj('s1'))
|
||||
const outcome = source.matchSpace!(proj('s1'), '/goal')
|
||||
if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim')
|
||||
const settled = await outcome.claim.submit('ship it', new Context())
|
||||
expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/goal ship it' }])
|
||||
expect(settled).toEqual({ kind: 'success', text: 'goal set' })
|
||||
// Pure admission: no outcome text ever rides the submit result — the
|
||||
// durable command lifecycle events render the outcome in the flow.
|
||||
expect(settled).toEqual({ kind: 'success' })
|
||||
})
|
||||
|
||||
it('maps matched:false to an error outcome and a matched bare result to success', async () => {
|
||||
@@ -389,33 +391,29 @@ describe('execute payload', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('detached result notices', () => {
|
||||
describe('detached admission notices', () => {
|
||||
const flush = () => new Promise(resolve => setTimeout(resolve, 0))
|
||||
|
||||
it('success text → info; error result → error; rejection → error, all on the triggering session', async () => {
|
||||
let mode: 'info' | 'error' | 'reject' = 'info'
|
||||
it('admitted outcomes stay silent; admission miss and transport rejection notice as errors', async () => {
|
||||
let mode: 'admitted' | 'miss' | 'reject' = 'admitted'
|
||||
const { source, mint, warm, notices } = await bench({
|
||||
execute: () => {
|
||||
if (mode === 'reject') return Promise.reject(new Error('network down'))
|
||||
return Promise.resolve({
|
||||
matched: true,
|
||||
result: mode === 'info'
|
||||
? { kind: 'success' as const, text: 'compacted 12 messages' }
|
||||
: { kind: 'error' as const, text: 'plan mode refused' },
|
||||
})
|
||||
return Promise.resolve({ matched: mode === 'admitted' })
|
||||
},
|
||||
})
|
||||
mint('s1')
|
||||
await warm(proj('s1'))
|
||||
// Admitted: the durable lifecycle events own the outcome — no notice.
|
||||
menuPick(source, 'plan', proj('s1'))
|
||||
await flush()
|
||||
expect(notices).toEqual([{ scope: sid('s1'), level: 'info', text: 'compacted 12 messages' }])
|
||||
expect(notices).toEqual([])
|
||||
|
||||
notices.length = 0
|
||||
mode = 'error'
|
||||
// Admission miss (matched:false): immediate composer feedback stays.
|
||||
mode = 'miss'
|
||||
await source.matchEnter!(proj('s1'), '/plan', new AbortController().signal)
|
||||
await flush()
|
||||
expect(notices).toEqual([{ scope: sid('s1'), level: 'error', text: 'plan mode refused' }])
|
||||
expect(notices).toEqual([{ scope: sid('s1'), level: 'error', text: 'unknown or malformed command: /plan' }])
|
||||
|
||||
notices.length = 0
|
||||
mode = 'reject'
|
||||
@@ -424,9 +422,9 @@ describe('detached result notices', () => {
|
||||
expect(notices).toEqual([{ scope: sid('s1'), level: 'error', text: 'network down' }])
|
||||
})
|
||||
|
||||
it('success without text stays silent; a torn-down scope drops the notice', async () => {
|
||||
it('a torn-down scope drops the failure notice', async () => {
|
||||
const { source, warm, notices } = await bench({
|
||||
execute: () => Promise.resolve({ matched: true, result: { kind: 'success' as const, text: 'orphan' } }),
|
||||
execute: () => Promise.reject(new Error('orphan failure')),
|
||||
})
|
||||
await warm(proj('ghost')) // never minted: scopeFor misses
|
||||
menuPick(source, 'plan', proj('ghost'))
|
||||
|
||||
@@ -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: 39d185014c658f490f0a3672ea3d7c99f30d8df2
|
||||
README.zh.md: 6b63631287bf420af0985a743f068f27b9c97873
|
||||
README.md: 2d7c26f829678c18e76d185f1a59e40cc4775682
|
||||
README.zh.md: 095f0ef069f042682b308f46fbe8adb9f31f01d7
|
||||
|
||||
@@ -8,11 +8,11 @@ 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.
|
||||
|
||||
Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and `Write · <path>` or `Edit · <path>` summary while retaining the shared row-to-details interaction. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged), and the details panel resolves a selected sub-call id to its full logged args and complete output. Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering.
|
||||
Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file with the host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering.
|
||||
|
||||
A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. The keyed `BashRow` carries the card resident below its summary row and outside that row's click target, so copying or expanding the output does not open the details panel; the render-site fallback row keeps it behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed for this intent alone; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)).
|
||||
A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed for this intent alone; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)).
|
||||
|
||||
Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders).
|
||||
Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders).
|
||||
|
||||
The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`<done>/<total> 已完成 · <active item>` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1` — above the queue rows — and is the durable plan strip: it selects `todos` off the session snapshot and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and collapses to a header of title plus `"<done>/<total> tasks · <n> in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the persistent list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.
|
||||
|
||||
@@ -33,7 +33,7 @@ None; this package neither assembles nor sends a provider request.
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **The stats line has no duration segment** — assistant `usage` carries token accounting only; elapsed-time needs a host data source.
|
||||
- **Details panel is the minimal form** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred.
|
||||
- **Details panel is the minimal form and currently has no entry point** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred. Tool rows stopped being details-panel click targets and nothing replaced that gesture, so `ChatViewInjected.openDetails` is implemented but uncalled and the panel (including its terminal card) is unreachable in the assembled application; its rendering stays covered by mounting it with a selection directly.
|
||||
- **Assistant footer extensions (IconActions row, per-message paging) are reserved slots** — drawn in the design, not implemented.
|
||||
- **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export.
|
||||
- **Approval cards are display-only placeholders** — question requests answer through the composer chain (ui-question), while web-side approval answering is the P-II approvals project.
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
|
||||
视图环本身就是 slot:会话注册声明 `'conversation.view'` 列表 slot(Session scope),并将其列在 `children` 表中;ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: <active id>`);视图标签页从环账本的注册选项(`id`/`order`/`label`)投影而来。聊天视图是该包自身的环配置项;其他插件(ui-trajectory)通过普通的 `ctx.slots.register` 贡献标签页。先前包内的视图注册表(`registerView`/`ViewEntry`/`ConversationViewMap` 及 chrome 附加表)已退役,逐视图 chrome 则被拆入视图组件自身。
|
||||
|
||||
通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和 `Write · <path>` 或 `Edit · <path>` 摘要,同时保留共享的行到详情交互。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行),details 面板则会根据选中的子调用 id 解析出其完整记录的参数与完整输出。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。
|
||||
通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击后通过宿主操作系统的默认应用打开文件(`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行)。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。
|
||||
|
||||
声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。键控的 `BashRow` 把卡片常驻在摘要行下方、且位于该行点击目标之外,因此复制或展开输出不会打开详情面板;渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出只对该意图开放;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。
|
||||
声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出只对该意图开放;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。
|
||||
|
||||
工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。
|
||||
工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openFile`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。
|
||||
|
||||
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: -1` 占用 `'conversation.input.dock'` 列表 slot(位于队列行之上),是常驻的计划条:它从会话快照中选取 `todos` 并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏,折叠时收成标题加 `"<已完成>/<总数> tasks · <n> in progress"` 的表头(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;常驻列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。
|
||||
|
||||
@@ -33,7 +33,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **统计行没有耗时区段**:assistant `usage` 只携带 token 计数;耗时需要主机数据源。
|
||||
- **详情面板是最小形态**:以原始形式显示已选择调用的参数/结果;Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。
|
||||
- **详情面板是最小形态,且当前没有入口**:以原始形式显示已选择调用的参数/结果;Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。工具行已不再是详情面板的点击目标,且没有任何手势接替它,因此 `ChatViewInjected.openDetails` 虽已实现却无人调用,该面板(含其终端卡片)在组装后的应用中不可达;其渲染仍由直接以选中态挂载它来覆盖。
|
||||
- **assistant footer 扩展(IconActions 行、逐消息分页)是预留 slot**:设计中已有图稿,尚未实现。
|
||||
- **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。
|
||||
- **审批卡片只是只读占位符**:问题请求通过编辑器链回答(ui-question),Web 侧审批回答属于 P-II 审批项目。
|
||||
|
||||
@@ -49,6 +49,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-todo": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { ViewTab } from './contract/views.ts'
|
||||
import type {
|
||||
ChatViewInjected, ComposerBarInjected, ConversationInjected, ConversationSessionInjected, DetailsInjected,
|
||||
} from './contract/slots.ts'
|
||||
import { resolveToolPath } from './contract/tool-call-model.ts'
|
||||
import { createChatStore } from './stores.ts'
|
||||
import { ConversationService } from './service.ts'
|
||||
import { InputHub } from './input/hub.ts'
|
||||
@@ -158,7 +159,10 @@ export function apply(ctx: Context): void {
|
||||
id: 'chat',
|
||||
order: 0,
|
||||
label: 'Chat',
|
||||
children: { 'conversation.chat.toolview': { kind: 'keyed', scope: 'session' } },
|
||||
children: {
|
||||
'conversation.chat.toolview': { kind: 'keyed', scope: 'session' },
|
||||
'conversation.chat.commandview': { kind: 'keyed', scope: 'session' },
|
||||
},
|
||||
store: chatStore,
|
||||
inject: (sessionId: SessionId, actions: BoundActions<typeof chatStore>): ChatViewInjected => {
|
||||
const scoped = scopedConversation(sessions, sessionId)
|
||||
@@ -167,6 +171,13 @@ export function apply(ctx: Context): void {
|
||||
actions.select(target)
|
||||
layout.openDetails()
|
||||
},
|
||||
openFile: (path) => {
|
||||
const cwd = sessions.list.getSnapshot().byId[sessionId]?.cwd
|
||||
void workspaces.openPath(resolveToolPath(cwd, path)).catch(() => {
|
||||
// Host/OS open failures stay silent in the chat row; the native
|
||||
// app surfaces its own error dialog when the path is unusable.
|
||||
})
|
||||
},
|
||||
loadOlder: () => { void scoped.loadOlder() },
|
||||
}
|
||||
},
|
||||
|
||||
@@ -20,14 +20,14 @@ import {
|
||||
memo, useLayoutEffect, useMemo, useRef, useState, type ReactNode,
|
||||
} from 'react'
|
||||
import type {
|
||||
CodeSubCall, ConversationNode, ConversationSnapshot, RunningToolCall, ToolResultNode,
|
||||
CodeSubCall, CommandNode, ConversationNode, ConversationSnapshot, RunningToolCall, ToolResultNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import type { SelectionTarget } from '../contract/views.ts'
|
||||
import { deriveChatFlow, type ChatFlowItem } from './chat-flow.ts'
|
||||
import { AssistantMarkdown } from './AssistantMarkdown.tsx'
|
||||
import { GenericCommandCard } from './GenericCommandCard.tsx'
|
||||
import { GenericToolCard } from './GenericToolCard.tsx'
|
||||
import { MessageItem } from './MessageItem.tsx'
|
||||
import { PendingCard } from './PendingCard.tsx'
|
||||
@@ -36,7 +36,7 @@ import css from './ChatView.module.css'
|
||||
|
||||
const FOLLOW_THRESHOLD = 24
|
||||
|
||||
type OpenDetails = (target: SelectionTarget) => void
|
||||
type OpenFile = (path: string) => void
|
||||
|
||||
/** The declared toolview hole's render share (stable framework binding, passed through memoized rows). */
|
||||
type RenderToolRow = ChatViewSlotProps['renderSlot']
|
||||
@@ -49,20 +49,18 @@ type UseConversation = SnapshotSelectorHook<ConversationSnapshot>
|
||||
* top-level call (same registrations, same fallback), nested by the parent.
|
||||
* A started-but-unsettled sub-call arrives as the RunningToolCall shape and
|
||||
* renders the running state exactly as a native in-flight row. */
|
||||
const SubCallRow = memo(function SubCallRow({ renderSlot, node, onOpenDetails, selected, cwd }: {
|
||||
const SubCallRow = memo(function SubCallRow({ renderSlot, node, openFile, selected, cwd }: {
|
||||
renderSlot: RenderToolRow
|
||||
node: CodeSubCall
|
||||
onOpenDetails: OpenDetails
|
||||
openFile: OpenFile
|
||||
selected: boolean
|
||||
cwd: string | undefined
|
||||
}) {
|
||||
const settled = 'kind' in node
|
||||
const toolName = settled ? node.call?.name ?? '' : node.name
|
||||
const seq = settled ? node.seq : node.time
|
||||
const owner = useMemo(() => ({
|
||||
callId: node.callId, toolName, block: node, cwd,
|
||||
openDetails: () => { onOpenDetails({ turnSeq: seq, callId: node.callId, toolName }) },
|
||||
}), [node, toolName, seq, cwd, onOpenDetails])
|
||||
callId: node.callId, toolName, block: node, openFile, cwd,
|
||||
}), [node, toolName, openFile, cwd])
|
||||
return (
|
||||
<div className={css.callRow} data-selected={selected || undefined}>
|
||||
{renderSlot('conversation.chat.toolview', owner, {
|
||||
@@ -79,15 +77,13 @@ const SubCallRow = memo(function SubCallRow({ renderSlot, node, onOpenDetails, s
|
||||
* renders its logged sub-dispatches as always-visible indented rows —
|
||||
* each one the same keyed-slot dispatch as a native top-level call. */
|
||||
const CallRow = memo(function CallRow({
|
||||
renderSlot, callId, toolName, block, seq, onOpenDetails, selected, subCalls, selectedCallId, cwd,
|
||||
renderSlot, callId, toolName, block, openFile, selected, subCalls, selectedCallId, cwd,
|
||||
}: {
|
||||
renderSlot: RenderToolRow
|
||||
callId: string
|
||||
toolName: string
|
||||
block: ToolResultNode | RunningToolCall
|
||||
/** Surface seq for finalized results; the call's turn for running calls. */
|
||||
seq: number
|
||||
onOpenDetails: OpenDetails
|
||||
openFile: OpenFile
|
||||
selected: boolean
|
||||
/** `run_code` sub-dispatches in dispatch order (reference-stable per
|
||||
* parent; running entries settle in place); undefined for ordinary calls. */
|
||||
@@ -98,9 +94,8 @@ const CallRow = memo(function CallRow({
|
||||
cwd: string | undefined
|
||||
}) {
|
||||
const owner = useMemo(() => ({
|
||||
callId, toolName, block, cwd,
|
||||
openDetails: () => { onOpenDetails({ turnSeq: seq, callId, toolName }) },
|
||||
}), [callId, toolName, block, seq, cwd, onOpenDetails])
|
||||
callId, toolName, block, openFile, cwd,
|
||||
}), [callId, toolName, block, openFile, cwd])
|
||||
return (
|
||||
<div className={css.callRow} data-selected={selected || undefined}>
|
||||
{renderSlot('conversation.chat.toolview', owner, {
|
||||
@@ -114,7 +109,7 @@ const CallRow = memo(function CallRow({
|
||||
key={node.callId}
|
||||
renderSlot={renderSlot}
|
||||
node={node}
|
||||
onOpenDetails={onOpenDetails}
|
||||
openFile={openFile}
|
||||
selected={node.callId === selectedCallId}
|
||||
cwd={cwd}
|
||||
/>
|
||||
@@ -126,10 +121,10 @@ const CallRow = memo(function CallRow({
|
||||
})
|
||||
|
||||
/** Consecutive tool results as one step-run group (uniform 16px rhythm). */
|
||||
const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails, selectedCallId, codeDispatches, cwd }: {
|
||||
const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selectedCallId, codeDispatches, cwd }: {
|
||||
renderSlot: RenderToolRow
|
||||
results: readonly ToolResultNode[]
|
||||
onOpenDetails: OpenDetails
|
||||
openFile: OpenFile
|
||||
/** Only set when the selected call lives in THIS group, top-level or nested (memo economy). */
|
||||
selectedCallId: string | undefined
|
||||
/** Sub-dispatch index off the snapshot (map reference is chunk-storm stable). */
|
||||
@@ -146,8 +141,7 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails,
|
||||
callId={node.callId}
|
||||
toolName={node.call?.name ?? ''}
|
||||
block={node}
|
||||
seq={node.seq}
|
||||
onOpenDetails={onOpenDetails}
|
||||
openFile={openFile}
|
||||
selected={node.callId === selectedCallId}
|
||||
subCalls={codeDispatches.get(node.callId)}
|
||||
selectedCallId={selectedCallId}
|
||||
@@ -158,6 +152,24 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails,
|
||||
)
|
||||
})
|
||||
|
||||
/** One command lifecycle row: keyed dispatch on the command name with the
|
||||
* generic card as the render-site fallback (zero registration required). A
|
||||
* run-less cross-window node has no name and always lands on the fallback. */
|
||||
const CommandRow = memo(function CommandRow({ renderSlot, node }: {
|
||||
renderSlot: RenderToolRow
|
||||
node: CommandNode
|
||||
}) {
|
||||
const owner = useMemo(() => ({ node }), [node])
|
||||
return (
|
||||
<div className={css.callRow}>
|
||||
{renderSlot('conversation.chat.commandview', owner, {
|
||||
entryKey: node.name ?? '',
|
||||
fallback: <GenericCommandCard {...owner} />,
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
/** Turn loader: one row of four 2.5px pixels (half a notch above the StateDot
|
||||
* 2px cell, same blue) chasing left to right with a stepped trail — flat
|
||||
* keyframe holds, no tweening, no rotation. Phase offsets come from
|
||||
@@ -210,7 +222,7 @@ function StreamingTail({ useSession, onGrow }: {
|
||||
* The chat view slot entry: pure component over the composed props (tool rows
|
||||
* render through the declared keyed hole's renderSlot share).
|
||||
*/
|
||||
export function ChatView({ useSession, useSessions, useStore, renderSlot, sessionId, openDetails, loadOlder }: ChatViewSlotProps) {
|
||||
export function ChatView({ useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder }: ChatViewSlotProps) {
|
||||
const nodes = useSession(s => s.nodes)
|
||||
// Workspace root off the session list row: path summaries display relative to it.
|
||||
const cwd = useSessions(s => s.byId[sessionId]?.cwd)
|
||||
@@ -311,7 +323,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
|
||||
key={item.key}
|
||||
renderSlot={renderSlot}
|
||||
results={item.results}
|
||||
onOpenDetails={openDetails}
|
||||
openFile={openFile}
|
||||
selectedCallId={inGroup ? selectedCallId : undefined}
|
||||
codeDispatches={codeDispatches}
|
||||
cwd={cwd}
|
||||
@@ -322,6 +334,9 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
|
||||
if (node.kind === 'assistant') {
|
||||
return <AssistantMarkdown key={item.key} blocks={node.blocks} streaming={false} interrupted={node.interrupted} />
|
||||
}
|
||||
if (node.kind === 'command') {
|
||||
return <CommandRow key={item.key} renderSlot={renderSlot} node={node} />
|
||||
}
|
||||
/* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */
|
||||
if (node.kind === 'tool-result') return null
|
||||
return <MessageItem key={item.key} node={node} />
|
||||
@@ -351,8 +366,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
|
||||
callId={call.callId}
|
||||
toolName={call.name}
|
||||
block={call}
|
||||
seq={call.turn}
|
||||
onOpenDetails={openDetails}
|
||||
openFile={openFile}
|
||||
selected={call.callId === selectedCallId}
|
||||
subCalls={codeDispatches.get(call.callId)}
|
||||
selectedCallId={selectedCallId}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// GenericCommandCard: the default command row — a stripped-down
|
||||
// GenericToolCard rendering the dispatched command line and the settlement
|
||||
// text. Supplied by the chat view as the keyed commandview slot's render-site
|
||||
// fallback (an unregistered command name lands here); registrants may compose
|
||||
// it as a base, feeding the same owner payload through.
|
||||
|
||||
import { ToolRow } from './ToolRow.tsx'
|
||||
import type { ToolRowState } from '../contract/tool-call-model.ts'
|
||||
import type { CommandRowOwnerProps } from '../contract/slots.ts'
|
||||
import { IconApiOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
|
||||
/** Node state → row state semantic (running while unsettled; outcome kind after). */
|
||||
function stateOf(outcome: CommandRowOwnerProps['node']['outcome']): ToolRowState {
|
||||
if (outcome === null) return 'running'
|
||||
return outcome.kind === 'error' ? 'error' : 'ok'
|
||||
}
|
||||
|
||||
export function GenericCommandCard({ node }: CommandRowOwnerProps) {
|
||||
const text = node.outcome?.text
|
||||
const summary = node.outcome === null
|
||||
? '执行中…'
|
||||
: text ?? (node.outcome.kind === 'error' ? '命令失败' : '已完成')
|
||||
// Display line rebuilt from the structured payload (args carries its own
|
||||
// separator whitespace verbatim); a cross-window node whose run page fell
|
||||
// out of the window has neither.
|
||||
const title = node.name === null ? '命令' : `/${node.name}${node.args ?? ''}`
|
||||
return (
|
||||
<ToolRow
|
||||
variant="others"
|
||||
icon={<IconApiOutline14 size={16} />}
|
||||
title={title}
|
||||
summary={summary}
|
||||
// Expandable only when the outcome text overflows a one-line summary.
|
||||
body={text !== undefined && text.includes('\n') ? text : null}
|
||||
state={stateOf(node.outcome)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -26,8 +26,9 @@ const VARIANT_ICONS: Record<ToolRowVariant, ReactNode> = {
|
||||
others: <IconSparkle16 size={14} />,
|
||||
}
|
||||
|
||||
export function GenericToolCard({ toolName, block, cwd, openDetails }: ToolRowOwnerProps) {
|
||||
export function GenericToolCard({ toolName, block, cwd, openFile }: ToolRowOwnerProps) {
|
||||
const model = toolRowModel(toolName, block, cwd)
|
||||
const singleFile = model.filePath !== undefined
|
||||
return (
|
||||
<ToolRow
|
||||
variant={model.variant}
|
||||
@@ -35,10 +36,12 @@ export function GenericToolCard({ toolName, block, cwd, openDetails }: ToolRowOw
|
||||
icon={VARIANT_ICONS[model.variant]}
|
||||
title={model.title}
|
||||
summary={model.summary}
|
||||
body={model.body}
|
||||
// Single-file tools never expose an args body — the path link is the only action.
|
||||
body={singleFile ? null : model.body}
|
||||
terminal={terminalCardModel(block)}
|
||||
state={model.state}
|
||||
onOpenDetails={openDetails}
|
||||
filePath={model.filePath}
|
||||
onOpenFile={singleFile ? openFile : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -41,10 +41,9 @@
|
||||
90%, 100% { left: 100%; }
|
||||
}
|
||||
|
||||
/* Clickable rows keep only the cursor affordance — no hover fill. */
|
||||
.row[data-clickable] {
|
||||
/* Expand-on-row (Think / code): pointer only — no row fill hover. */
|
||||
.row[data-expandable] {
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.leading {
|
||||
@@ -143,6 +142,29 @@ button.leading {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* File-tool path: same geometry as .summary; hover underline + pointer. */
|
||||
.fileLink {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fileLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Expanded body: pad-left 22 indented gray text, no border, no fill. */
|
||||
.body {
|
||||
padding: 4px 0 4px 22px;
|
||||
|
||||
@@ -4,12 +4,9 @@
|
||||
// line; the expanded body is indented gray text, the run_code program through
|
||||
// CodeBlock, or — for a call whose render intent is a terminal card — the
|
||||
// command's own output through TerminalBlock, capped at
|
||||
// CHAT_TERMINAL_MAX_LINES so the message flow stays scannable. The details
|
||||
// panel remains the full-height reading surface for the same call. Expand
|
||||
// state is component-local view state; row click hands the selection off to
|
||||
// the owner.
|
||||
// TODO(ux): converge every chat-tab tool row on in-place expansion for its
|
||||
// expandable content, retiring the details-panel handoff where feasible.
|
||||
// CHAT_TERMINAL_MAX_LINES so the message flow stays scannable. Expand state is
|
||||
// component-local view state. File-tool summaries are path links that open
|
||||
// through the host; the row itself is not a details-panel control.
|
||||
|
||||
import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
@@ -39,8 +36,13 @@ export interface ToolRowProps {
|
||||
state: ToolRowState
|
||||
/** Makes the row itself the expand control instead of only its leading icon. */
|
||||
expandOnRowClick?: boolean | undefined
|
||||
/** Selection handoff (row click), already bound to this call by the owner. */
|
||||
onOpenDetails?: (() => void) | undefined
|
||||
/**
|
||||
* Filesystem path from tool args; when set with onOpenFile, the summary
|
||||
* renders as a hover-underline link that opens the host default app.
|
||||
*/
|
||||
filePath?: string | undefined
|
||||
/** Open the path with the host OS default application (already cwd-resolved). */
|
||||
onOpenFile?: ((path: string) => void) | undefined
|
||||
}
|
||||
|
||||
/** Leading-slot state substitution: the tool icon yields to the terminal state
|
||||
@@ -64,11 +66,18 @@ export function ToolRow({
|
||||
terminal,
|
||||
state,
|
||||
expandOnRowClick = false,
|
||||
onOpenDetails,
|
||||
filePath,
|
||||
onOpenFile,
|
||||
}: ToolRowProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const terminalBody = terminal ?? null
|
||||
const expandable = body !== null || terminalBody !== null
|
||||
// A row that names a single file keeps one interaction (open that path);
|
||||
// args expand is off whether or not the open callback is wired yet. Terminal
|
||||
// material still expands: only the file variants carry a path, so a terminal
|
||||
// card and a file link never land on the same row.
|
||||
const singleFile = filePath !== undefined
|
||||
const fileLink = singleFile && onOpenFile !== undefined
|
||||
const expandable = (body !== null && !singleFile) || terminalBody !== null
|
||||
// The text arms take the empty string for a null body: a row expandable
|
||||
// only through its terminal material renders the terminal body instead, so
|
||||
// this substitution never shows.
|
||||
@@ -87,6 +96,10 @@ export function ToolRow({
|
||||
event.preventDefault()
|
||||
toggleExpand()
|
||||
}
|
||||
const openFile = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation()
|
||||
if (filePath !== undefined) onOpenFile?.(filePath)
|
||||
}
|
||||
// Expandable rows preview the toggle on hover: the tool icon yields to a
|
||||
// down chevron (CSS swap on .row:hover); state dots still take precedence.
|
||||
const collapsedIcon = expandable
|
||||
@@ -104,11 +117,11 @@ export function ToolRow({
|
||||
<div className={css.root} data-variant={variant} data-tool={toolName} data-state={state}>
|
||||
<div
|
||||
className={css.row}
|
||||
data-clickable={rowExpands || onOpenDetails !== undefined || undefined}
|
||||
data-expandable={rowExpands || undefined}
|
||||
role={rowExpands ? 'button' : undefined}
|
||||
tabIndex={rowExpands ? 0 : undefined}
|
||||
aria-expanded={rowExpands ? open : undefined}
|
||||
onClick={rowExpands ? toggleExpand : onOpenDetails}
|
||||
onClick={rowExpands ? toggleExpand : undefined}
|
||||
onKeyDown={rowExpands ? toggleFromKeyboard : undefined}
|
||||
>
|
||||
{expandable && !rowExpands ? (
|
||||
@@ -129,7 +142,17 @@ export function ToolRow({
|
||||
{!open && (
|
||||
<>
|
||||
<span className={css.sep} aria-hidden />
|
||||
<span className={css.summary}>{summary}</span>
|
||||
{fileLink ? (
|
||||
<button
|
||||
type="button"
|
||||
className={css.fileLink}
|
||||
onClick={openFile}
|
||||
>
|
||||
{summary}
|
||||
</button>
|
||||
) : (
|
||||
<span className={css.summary}>{summary}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { ReactNode, RefObject } from 'react'
|
||||
import type {
|
||||
InjectFace, MaybeSnapshotSelectorHook, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ConversationSnapshot, ObservableSnapshot, PendingInteraction, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { CommandNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import type { ComposerKeyboard, InputActions, InputNotice, InputState } from '../input/contract.ts'
|
||||
import type { createChatStore } from '../stores.ts'
|
||||
@@ -33,6 +33,15 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
* `fallback` for unregistered tools.
|
||||
*/
|
||||
'conversation.chat.toolview': { kind: 'keyed'; scope: 'session'; owner: ToolRowOwnerProps }
|
||||
/**
|
||||
* The chat view's per-command row hole: keyed dispatch on the command
|
||||
* name (`command/run.name`; a run-less cross-window node has none and
|
||||
* always lands on the fallback). Declared by the chat view entry; the
|
||||
* render site dispatches via `entryKey: name` with GenericCommandCard as
|
||||
* the `fallback` — a slash command renders durably with zero
|
||||
* registration, and a domain upgrades by registering one row component.
|
||||
*/
|
||||
'conversation.chat.commandview': { kind: 'keyed'; scope: 'session'; owner: CommandRowOwnerProps }
|
||||
/**
|
||||
* The composer takeover chain: entries are selector-routed replacements
|
||||
* of the default InputBar. Declared by this package's 'conversation'
|
||||
@@ -145,8 +154,11 @@ export interface ToolRowOwnerProps {
|
||||
block: ToolCallBlock
|
||||
/** Session workspace root; path summaries display relative to it. */
|
||||
cwd?: string | undefined
|
||||
/** Open the details panel for this call (session-level facility, supplied by the view). */
|
||||
openDetails: () => void
|
||||
/**
|
||||
* Open a tool-arg filesystem path with the host OS default application.
|
||||
* The chat view resolves relative paths against the session cwd.
|
||||
*/
|
||||
openFile: (path: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -158,6 +170,22 @@ export interface ToolRowOwnerProps {
|
||||
*/
|
||||
export type ToolRowProps = PropsRuntime<'conversation.chat.toolview'>
|
||||
|
||||
/**
|
||||
* Owner share of the per-command row slot: the frozen {@link CommandNode}
|
||||
* slice off the snapshot (cache-stable reference — memo premise). The node
|
||||
* carries the whole lifecycle (structured name/args, pairing id,
|
||||
* outcome-or-executing), so a
|
||||
* registrant needs no second data channel; domain state arrives through its
|
||||
* own projection cell.
|
||||
*/
|
||||
export interface CommandRowOwnerProps {
|
||||
/** Folded command lifecycle node (run + optional done). */
|
||||
node: CommandNode
|
||||
}
|
||||
|
||||
/** Full props of a registered command-row component (same shape rule as {@link ToolRowProps}). */
|
||||
export type CommandRowProps = PropsRuntime<'conversation.chat.commandview'>
|
||||
|
||||
/**
|
||||
* Base props of a conversation view entry: the framework standard kit for the
|
||||
* session-scope 'conversation.view' slot (useSession narrowed to the
|
||||
@@ -285,12 +313,17 @@ export type ConversationSessionSlotProps =
|
||||
export interface ChatViewInjected {
|
||||
/** Selection write + details panel opening in one gesture (store action + layout orchestration). */
|
||||
openDetails: (target: SelectionTarget) => void
|
||||
/**
|
||||
* Open a tool-arg filesystem path with the host OS default application
|
||||
* (relative paths resolve against the session cwd).
|
||||
*/
|
||||
openFile: (path: string) => void
|
||||
loadOlder: () => void
|
||||
}
|
||||
|
||||
/** Full chat-view component props: runtime share & the declared toolview hole's render share & store share & injected share. */
|
||||
/** Full chat-view component props: runtime share & the declared toolview/commandview holes' render share & store share & injected share. */
|
||||
export type ChatViewSlotProps =
|
||||
PropsRuntime<'conversation.view'> & PropsRenderSlots<'conversation.chat.toolview'>
|
||||
PropsRuntime<'conversation.view'> & PropsRenderSlots<'conversation.chat.toolview' | 'conversation.chat.commandview'>
|
||||
& PropsStore<ChatStore> & ChatViewInjected
|
||||
|
||||
/**
|
||||
|
||||
@@ -64,6 +64,12 @@ export interface ToolRowModel {
|
||||
variant: ToolRowVariant
|
||||
title: string
|
||||
summary: string
|
||||
/**
|
||||
* Filesystem path from args (`path` / `file_path`) when the row is a file
|
||||
* tool; absent for URL reads and non-file tools. The chat view resolves
|
||||
* relative values against the session cwd before opening.
|
||||
*/
|
||||
filePath: string | undefined
|
||||
/** Expanded-body text (pretty args); null = row not expandable. */
|
||||
body: string | null
|
||||
state: ToolRowState
|
||||
@@ -123,6 +129,35 @@ function deriveSummary(variant: ToolRowVariant, argsRaw: string): string {
|
||||
return firstLine(argsRaw)
|
||||
}
|
||||
|
||||
/** Path keys only — never `url` (web_fetch lands on the read variant). */
|
||||
const FILE_PATH_KEYS = ['path', 'file_path'] as const
|
||||
|
||||
/** File-tool variants whose summary may be an openable workspace path. */
|
||||
const FILE_PATH_VARIANTS: ReadonlySet<ToolRowVariant> = new Set(['read', 'write', 'edit'])
|
||||
|
||||
function deriveFilePath(variant: ToolRowVariant, argsRaw: string): string | undefined {
|
||||
if (!FILE_PATH_VARIANTS.has(variant)) return undefined
|
||||
const parsed = parseArgs(argsRaw)
|
||||
if (typeof parsed !== 'object' || parsed === null) return undefined
|
||||
const picked = pickString(parsed as Record<string, unknown>, FILE_PATH_KEYS)
|
||||
return picked === undefined ? undefined : firstLine(picked)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a tool-arg path against the session cwd for host.openPath.
|
||||
* Absolute POSIX/Windows paths pass through; relative paths join under cwd.
|
||||
* @param cwd - session working directory (may be absent for ungrouped sessions).
|
||||
* @param path - path as carried in tool args.
|
||||
* @returns a host-facing path string.
|
||||
*/
|
||||
export function resolveToolPath(cwd: string | undefined, path: string): string {
|
||||
if (path.startsWith('/') || /^[A-Za-z]:[/\\]/.test(path) || path.startsWith('\\\\')) return path
|
||||
if (cwd === undefined || cwd === '') return path
|
||||
const base = cwd.replace(/[/\\]+$/, '')
|
||||
const rel = path.replace(/^[/\\]+/, '')
|
||||
return `${base}/${rel}`
|
||||
}
|
||||
|
||||
function deriveBody(variant: ToolRowVariant, argsRaw: string): string | null {
|
||||
if (argsRaw === '') return null
|
||||
const parsed = parseArgs(argsRaw)
|
||||
@@ -161,6 +196,7 @@ export function toolRowModel(toolName: string, block: ToolCallBlock, cwd?: strin
|
||||
variant,
|
||||
title: toolTitle ?? VARIANT_TITLES[variant],
|
||||
summary,
|
||||
filePath: deriveFilePath(variant, argsRaw),
|
||||
body: deriveBody(variant, argsRaw),
|
||||
state,
|
||||
}
|
||||
|
||||
@@ -13,7 +13,8 @@ export type {
|
||||
} from './contract/views.ts'
|
||||
export type { ToolCallBlock } from './contract/tool-call-model.ts'
|
||||
export type {
|
||||
ChatStore, ChatViewInjected, ChatViewSlotProps, ComposerBarInjected, ComposerChainProps, ConversationInjected,
|
||||
ChatStore, ChatViewInjected, ChatViewSlotProps, CommandRowOwnerProps, CommandRowProps, ComposerBarInjected,
|
||||
ComposerChainProps, ConversationInjected,
|
||||
ConversationSessionInjected, ConversationSlotProps, ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps,
|
||||
EmptyWorkspaceOwnerProps, ToolRowOwnerProps, ToolRowProps,
|
||||
} from './contract/slots.ts'
|
||||
|
||||
@@ -87,6 +87,13 @@
|
||||
box-shadow: var(--dsw-shadow-lv2);
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
/* Elevated surface in dark, same as the menus: the textarea inside scrolls
|
||||
once the composer hits its height cap, so the thumb takes the l2 pair.
|
||||
Declared on the card because the elevation belongs to the surface, and the
|
||||
custom properties inherit down to the textarea that actually scrolls (see
|
||||
ui-theme styles/scrollbar.css for the rebinding contract). */
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
||||
}
|
||||
|
||||
.accessory {
|
||||
|
||||
@@ -11,6 +11,13 @@
|
||||
border: 1px solid var(--dsw-alias-border-l1);
|
||||
border-radius: 14px;
|
||||
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
|
||||
elevation tokens. Declared here because the elevation belongs to the
|
||||
surface, and the custom properties inherit down to `.list` (see ui-theme
|
||||
styles/scrollbar.css for the rebinding contract). */
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
||||
}
|
||||
|
||||
.body {
|
||||
|
||||
@@ -8,7 +8,11 @@
|
||||
import { useId, useState } from 'react'
|
||||
import type { Context } from 'cordis'
|
||||
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { TodoItem } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// The domain's client-namespace pure-type outlet: one import edge delivers
|
||||
// the `todos` projection-key merge (single source, no consumer-side restated
|
||||
// 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 css from './TodoPanel.module.css'
|
||||
|
||||
@@ -115,10 +119,10 @@ export function TodoPanel({ todos }: TodoPanelProps) {
|
||||
/** Full props of a dock entry: InputZone owner share + session standard kit + global seat. */
|
||||
export type TodoDockProps = PropsRuntime<'conversation.input.dock'>
|
||||
|
||||
/** Dock adapter: selects the plan off the session snapshot and hands the strip a plain list. */
|
||||
export function TodoDock({ useSession }: TodoDockProps) {
|
||||
const todos = useSession(s => s.todos)
|
||||
return <TodoPanel todos={todos} />
|
||||
/** Dock adapter: reads the host-computed 'todos' projection (whole list; absent or null renders nothing). */
|
||||
export function TodoDock({ useProjection }: TodoDockProps) {
|
||||
const todos = useProjection('todos')
|
||||
return <TodoPanel todos={todos ?? []} />
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,8 +21,6 @@
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* Running sweep glare — same deepsuite ShimmerText pattern as ToolRow. */
|
||||
|
||||
@@ -39,11 +39,11 @@ function stateStatus(state: ToolRowState): string | null {
|
||||
|
||||
/**
|
||||
* Bash row: icon + Bash · {description} in the shared ToolRow chrome, with the
|
||||
* command's terminal card below it. The summary row keeps its own click target
|
||||
* (the details handoff); the terminal card sits outside that row, so its copy
|
||||
* and expand controls do not open the details panel.
|
||||
* command's terminal card resident below it. The summary row is not a
|
||||
* details-panel control (tool rows stopped being one), so the card's copy and
|
||||
* expand controls are the row's only interactions.
|
||||
*/
|
||||
export function BashRow({ toolName, block, openDetails, sessionId, useSessions }: ToolRowProps) {
|
||||
export function BashRow({ toolName, block, sessionId, useSessions }: ToolRowProps) {
|
||||
const model = toolRowModel(toolName, block)
|
||||
const terminal = terminalCardModel(block)
|
||||
const isChild = useSessions(list => list.byId[sessionId]?.parentId !== undefined)
|
||||
@@ -55,8 +55,6 @@ export function BashRow({ toolName, block, openDetails, sessionId, useSessions }
|
||||
data-sample={isChild ? 'bash-scoped' : 'bash-global'}
|
||||
data-variant="bash"
|
||||
data-state={model.state}
|
||||
data-clickable
|
||||
onClick={openDetails}
|
||||
>
|
||||
<span className={css.leading}>{leadingFor(model.state)}</span>
|
||||
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.leading {
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
// durable list itself renders in the TodoPanel above the composer, so the
|
||||
// row stays one line. Chrome matches ToolRow (figma 780:53675).
|
||||
|
||||
import type { KeyboardEvent } from 'react'
|
||||
import type { Context } from 'cordis'
|
||||
import { IconChecklistOutline16, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ToolRowProps } from '../contract/slots.ts'
|
||||
@@ -51,29 +50,18 @@ function leadingFor(state: ToolRowState) {
|
||||
}
|
||||
}
|
||||
|
||||
/** One-line plan update row (click opens the raw args in details). Non-ok
|
||||
* execution states keep the generic row's dot semantics — a cancelled call
|
||||
* wrote no todo/write, so it must not read as a completed update. */
|
||||
export function TodoRow({ toolName, block, openDetails }: ToolRowProps) {
|
||||
/** One-line plan update row. Non-ok execution states keep the generic row's
|
||||
* dot semantics — a cancelled call wrote no todo/write, so it must not read
|
||||
* as a completed update. */
|
||||
export function TodoRow({ toolName, block }: ToolRowProps) {
|
||||
const model = toolRowModel(toolName, block)
|
||||
const argsRaw = ('kind' in block ? block.call?.argsRaw : block.argsRaw) ?? ''
|
||||
const summary = summarize(argsRaw) ?? model.summary
|
||||
// Button semantics, not a <button>: the row carries inline spans a button
|
||||
// would flatten, and ToolRow takes the same role/tabIndex/Enter-Space route.
|
||||
const openFromKeyboard = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return
|
||||
event.preventDefault()
|
||||
openDetails()
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={css.row}
|
||||
data-sample="todo-row"
|
||||
data-state={model.state}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={openDetails}
|
||||
onKeyDown={openFromKeyboard}
|
||||
>
|
||||
<span className={css.leading} aria-hidden>{leadingFor(model.state)}</span>
|
||||
<span className={css.title}>更新任务清单</span>
|
||||
|
||||
@@ -107,6 +107,7 @@ async function bench() {
|
||||
const workspacesFake = {
|
||||
list: workspaceStore,
|
||||
connectWorkspace: vi.fn(async () => ROOT),
|
||||
openPath: vi.fn(async () => {}),
|
||||
}
|
||||
ctx.provide('workspaces', workspacesFake)
|
||||
const layoutFake = { openDetails: vi.fn(), closeDetails: vi.fn() }
|
||||
@@ -259,6 +260,15 @@ describe('conversation slot inject surface', () => {
|
||||
expect(conv.instance).toBe(instance)
|
||||
})
|
||||
|
||||
it('openFile (chat view face) resolves against session cwd and calls workspaces.openPath', async () => {
|
||||
const b = await bench()
|
||||
const { injected } = b.chatViewSurface(ROOT)
|
||||
injected.openFile('src/a.ts')
|
||||
await vi.waitFor(() => {
|
||||
expect(b.workspacesFake.openPath).toHaveBeenCalledWith('/proj/src/a.ts')
|
||||
})
|
||||
})
|
||||
|
||||
it('routes navigation and workspace switching through the runtime owners, carrying the draft', async () => {
|
||||
const b = await bench()
|
||||
const { injected } = b.conversationSurface(ROOT)
|
||||
|
||||
@@ -48,6 +48,7 @@ async function bench() {
|
||||
ctx.provide('workspaces', {
|
||||
startSession: vi.fn(),
|
||||
sendSession: vi.fn(),
|
||||
openPath: vi.fn(async () => {}),
|
||||
})
|
||||
ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
ctx.provide('locale', { bind: () => (key: string) => key })
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// always-visible nested rows through the SAME keyed toolview hole — the bash
|
||||
// sub-call lands in the bash sample plugin's registration exactly like a
|
||||
// top-level bash row, unregistered sub-tools fall back to GenericToolCard —
|
||||
// and a sub-row click opens details for the sub-callId. Running parents
|
||||
// and a file sub-row click opens the host path. Running parents
|
||||
// (runningCalls) nest their so-far dispatches the same way.
|
||||
|
||||
import { Context } from 'cordis'
|
||||
@@ -56,7 +56,7 @@ function snapshotWith(
|
||||
): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls, codeDispatches,
|
||||
pending: [], queue: [], todos: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false,
|
||||
pending: [], queue: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
||||
}
|
||||
@@ -114,14 +114,16 @@ async function bench(snapshot: ConversationSnapshot) {
|
||||
open: vi.fn(),
|
||||
}
|
||||
ctx.provide('sessions', sessionsFake)
|
||||
ctx.provide('workspaces', {
|
||||
const workspaces = {
|
||||
list: createSnapshotStore<WorkspaceListState>({
|
||||
items: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
}),
|
||||
startSession: vi.fn(),
|
||||
sendSession: vi.fn(),
|
||||
})
|
||||
openPath: vi.fn(async () => {}),
|
||||
}
|
||||
ctx.provide('workspaces', workspaces)
|
||||
ctx.provide('layout', layout)
|
||||
ctx.provide('i18n', { bind: () => (key: string) => key })
|
||||
|
||||
@@ -136,7 +138,7 @@ async function bench(snapshot: ConversationSnapshot) {
|
||||
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
return { ctx, slots, fiber, session, layout }
|
||||
return { ctx, slots, fiber, session, layout, workspaces }
|
||||
}
|
||||
|
||||
function mountApp(slots: SlotsService) {
|
||||
@@ -220,15 +222,21 @@ describe('run_code sub-calls through the real chat machinery', () => {
|
||||
expect(nested).not.toBeNull()
|
||||
})
|
||||
|
||||
it('a sub-row click opens details for the sub-callId', async () => {
|
||||
it('a file sub-row click opens the host path; bash sub-rows do not open details', async () => {
|
||||
const parent = 'call-64'
|
||||
const dispatches = new Map([[parent, [
|
||||
subCall(11, parent, 1, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt'),
|
||||
subCall(11, parent, 1, 'read', { path: 'notes/demo.txt' }, 'ok'),
|
||||
subCall(12, parent, 2, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt'),
|
||||
]]])
|
||||
const b = await bench(snapshotWith([codeResult(10, parent)], dispatches))
|
||||
const view = mountApp(b.slots)
|
||||
view.getByText('notes/demo.txt').click()
|
||||
expect(b.layout.openDetails).not.toHaveBeenCalled()
|
||||
await vi.waitFor(() => {
|
||||
expect(b.workspaces.openPath).toHaveBeenCalledWith('notes/demo.txt')
|
||||
})
|
||||
view.getByText('List notes').click()
|
||||
expect(b.layout.openDetails).toHaveBeenCalledTimes(1)
|
||||
expect(b.layout.openDetails).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('a RUNNING run_code call nests its so-far dispatches under the spinner row', async () => {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// standard useSessions kit (no registry predicates — tool ring dissolved).
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import type {
|
||||
AssistantMessageNode, ConversationSnapshot, SessionId, SessionListState, ToolResultNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -27,7 +27,7 @@ const assistant = (seq: number, turn: number, usage?: unknown): AssistantMessage
|
||||
function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
||||
}
|
||||
}
|
||||
@@ -133,10 +133,9 @@ describe('bash sample row', () => {
|
||||
|
||||
const rowProps = (sessionId: SessionId, over?: {
|
||||
store?: ReturnType<typeof listStore>
|
||||
openDetails?: () => void
|
||||
}): ToolRowProps => ({
|
||||
callId: 'c1', toolName: 'bash', block: result('c1'),
|
||||
openDetails: over?.openDetails ?? vi.fn(),
|
||||
openFile: vi.fn(),
|
||||
sessionId,
|
||||
useSessions: bindSnapshotSelector(over?.store ?? listStore()),
|
||||
} as unknown as ToolRowProps)
|
||||
@@ -169,21 +168,17 @@ describe('bash sample row', () => {
|
||||
expect(view.container.querySelector('[data-sample="bash-scoped"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('summarizes as Bash · description and hands clicks to openDetails on both arms', () => {
|
||||
const openGlobal = vi.fn()
|
||||
const global = render(<BashRow {...rowProps(ROOT, { openDetails: openGlobal })} />)
|
||||
it('summarizes as Bash · description on both arms without row click targets', () => {
|
||||
const global = render(<BashRow {...rowProps(ROOT)} />)
|
||||
// Two renders share document.body: query inside each container.
|
||||
const globalRow = global.container.querySelector('[data-sample="bash-global"]')!
|
||||
expect(globalRow.textContent).toContain('Bash')
|
||||
expect(globalRow.textContent).toContain('Build')
|
||||
fireEvent.click(globalRow)
|
||||
expect(openGlobal).toHaveBeenCalledTimes(1)
|
||||
const openScoped = vi.fn()
|
||||
const scoped = render(<BashRow {...rowProps(CHILD, { openDetails: openScoped })} />)
|
||||
expect(globalRow.getAttribute('data-clickable')).toBeNull()
|
||||
const scoped = render(<BashRow {...rowProps(CHILD)} />)
|
||||
const scopedRow = scoped.container.querySelector('[data-sample="bash-scoped"]')!
|
||||
expect(scopedRow.textContent).toContain('Bash')
|
||||
expect(scopedRow.textContent).toContain('Build')
|
||||
fireEvent.click(scopedRow)
|
||||
expect(openScoped).toHaveBeenCalledTimes(1)
|
||||
expect(scopedRow.getAttribute('data-clickable')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
|
||||
afterEach(cleanup)
|
||||
import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { classifyTool, toolRowModel } from '../src/client/contract/tool-call-model.ts'
|
||||
import { classifyTool, resolveToolPath, toolRowModel } from '../src/client/contract/tool-call-model.ts'
|
||||
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
|
||||
import { ToolRow } from '../src/client/chat/ToolRow.tsx'
|
||||
import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
|
||||
@@ -64,6 +64,22 @@ describe('tool-call-model', () => {
|
||||
expect(toolRowModel('', running({ argsRaw: '' })).summary).toBe('c1')
|
||||
})
|
||||
|
||||
it('exposes filePath for path/file_path args and skips URL-only reads', () => {
|
||||
expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"src/a.ts"}' })).filePath).toBe('src/a.ts')
|
||||
expect(toolRowModel('write', running({ name: 'write', argsRaw: '{"file_path":"src/a.ts"}' })).filePath).toBe('src/a.ts')
|
||||
expect(toolRowModel('edit', running({ name: 'edit', argsRaw: '{"file_path":"src/a.ts"}' })).filePath).toBe('src/a.ts')
|
||||
expect(toolRowModel('web_fetch', running({ name: 'web_fetch', argsRaw: '{"url":"https://example.com"}' })).filePath)
|
||||
.toBeUndefined()
|
||||
expect(toolRowModel('bash', running()).filePath).toBeUndefined()
|
||||
})
|
||||
|
||||
it('resolveToolPath joins relative paths under cwd and passes absolute through', () => {
|
||||
expect(resolveToolPath('/w', 'src/a.ts')).toBe('/w/src/a.ts')
|
||||
expect(resolveToolPath('/w/', '/abs/a.ts')).toBe('/abs/a.ts')
|
||||
expect(resolveToolPath(undefined, 'src/a.ts')).toBe('src/a.ts')
|
||||
expect(resolveToolPath('/w', 'C:\\x\\a.ts')).toBe('C:\\x\\a.ts')
|
||||
})
|
||||
|
||||
it('displays workspace-rooted paths relative to the session cwd', () => {
|
||||
const cwd = '/Users/u/ws/'
|
||||
expect(toolRowModel('edit', running({ name: 'edit', argsRaw: '{"file_path":"/Users/u/ws/src/x.ts"}' }), cwd).summary).toBe('src/x.ts')
|
||||
@@ -170,13 +186,34 @@ describe('ToolRow', () => {
|
||||
expect(view.queryByRole('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('row click hands off to onOpenDetails; the expand toggle does not', () => {
|
||||
it('file-path summary opens through onOpenFile; the leading slot is not an expand control', () => {
|
||||
const open = vi.fn()
|
||||
const view = render(<ToolRow {...rowProps} onOpenDetails={open} />)
|
||||
const view = render(
|
||||
<ToolRow {...rowProps} variant="read" title="Read" summary="src/a.ts" filePath="src/a.ts" onOpenFile={open} />,
|
||||
)
|
||||
fireEvent.click(view.getByText('src/a.ts'))
|
||||
expect(open).toHaveBeenCalledWith('src/a.ts')
|
||||
// Only the path link is a button — no args-expand affordance on file rows.
|
||||
expect(view.container.querySelectorAll('button')).toHaveLength(1)
|
||||
expect(view.container.querySelector('[aria-expanded]')).toBeNull()
|
||||
expect(view.queryByText(/"a": 1/)).toBeNull()
|
||||
})
|
||||
|
||||
it('a single-file path disables expand even when onOpenFile is absent', () => {
|
||||
const view = render(
|
||||
<ToolRow {...rowProps} variant="write" title="Write" summary="作文.md" filePath="作文.md" />,
|
||||
)
|
||||
expect(view.container.querySelector('button')).toBeNull()
|
||||
expect(view.container.querySelector('[aria-expanded]')).toBeNull()
|
||||
fireEvent.click(view.getByText('作文.md'))
|
||||
expect(view.queryByText(/"a": 1/)).toBeNull()
|
||||
})
|
||||
|
||||
it('non-file rows do not open anything when the summary is clicked', () => {
|
||||
const open = vi.fn()
|
||||
const view = render(<ToolRow {...rowProps} onOpenFile={open} />)
|
||||
fireEvent.click(view.getByText('List files'))
|
||||
expect(open).toHaveBeenCalledTimes(1)
|
||||
fireEvent.click(view.container.querySelector('button')!)
|
||||
expect(open).toHaveBeenCalledTimes(1)
|
||||
expect(open).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -201,7 +238,7 @@ describe('ThinkRow', () => {
|
||||
|
||||
describe('GenericToolCard', () => {
|
||||
const props = (toolName: string, block: RunningToolCall | ToolResultNode): ToolRowOwnerProps => ({
|
||||
callId: 'c1', toolName, block, openDetails: vi.fn(),
|
||||
callId: 'c1', toolName, block, openFile: vi.fn(),
|
||||
})
|
||||
|
||||
it('renders the classified variant row from the frozen slice', () => {
|
||||
@@ -246,10 +283,15 @@ describe('GenericToolCard', () => {
|
||||
expect(view.container.querySelector('svg')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('row click reaches openDetails', () => {
|
||||
const p = props('bash', result())
|
||||
const view = render(<GenericToolCard {...p} />)
|
||||
fireEvent.click(view.getByText('List files'))
|
||||
expect(p.openDetails).toHaveBeenCalledTimes(1)
|
||||
it('file-path summary click reaches openFile; bash summary does not', () => {
|
||||
const file = props('read', running({ name: 'read', argsRaw: '{"path":"src/x.ts"}' }))
|
||||
const fileView = render(<GenericToolCard {...file} />)
|
||||
fireEvent.click(fileView.getByText('src/x.ts'))
|
||||
expect(file.openFile).toHaveBeenCalledWith('src/x.ts')
|
||||
|
||||
const bash = props('bash', result())
|
||||
const bashView = render(<GenericToolCard {...bash} />)
|
||||
fireEvent.click(bashView.getByText('List files'))
|
||||
expect(bash.openFile).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -43,7 +43,7 @@ const toolResult = (seq: number, callId: string, name: string, args = '{"command
|
||||
function snapshotWith(nodes: ToolResultNode[]): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
||||
}
|
||||
}
|
||||
@@ -121,14 +121,16 @@ async function bench(nodes: ToolResultNode[]) {
|
||||
open: vi.fn(),
|
||||
updateIntent: vi.fn(),
|
||||
})
|
||||
ctx.provide('workspaces', {
|
||||
const workspaces = {
|
||||
list: createSnapshotStore<WorkspaceListState>({
|
||||
items: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
}),
|
||||
startSession: vi.fn(),
|
||||
sendSession: vi.fn(),
|
||||
})
|
||||
openPath: vi.fn(async () => {}),
|
||||
}
|
||||
ctx.provide('workspaces', workspaces)
|
||||
ctx.provide('layout', layout)
|
||||
ctx.provide('locale', { bind: () => (key: string) => key })
|
||||
|
||||
@@ -143,7 +145,7 @@ async function bench(nodes: ToolResultNode[]) {
|
||||
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
return { ctx, slots, fiber, session, list, layout }
|
||||
return { ctx, slots, fiber, session, list, layout, workspaces }
|
||||
}
|
||||
|
||||
/** Render the whole tree through the ctx-level root seam (the shell's own entry). */
|
||||
@@ -186,11 +188,22 @@ describe('keyed toolview hole through the real machinery', () => {
|
||||
expect(mounted!.querySelector('pre.shiki')?.textContent).toBe(code)
|
||||
})
|
||||
|
||||
it('row clicks travel owner openDetails → chat inject → layout orchestration', async () => {
|
||||
it('file-path clicks travel owner openFile → chat inject → workspaces.openPath', async () => {
|
||||
const b = await bench([toolResult(3, 'c1', 'read', '{"path":"src/a.ts"}')])
|
||||
const view = mountApp(b.slots)
|
||||
view.getByText('src/a.ts').click()
|
||||
expect(b.layout.openDetails).not.toHaveBeenCalled()
|
||||
await vi.waitFor(() => {
|
||||
expect(b.workspaces.openPath).toHaveBeenCalledWith('src/a.ts')
|
||||
})
|
||||
})
|
||||
|
||||
it('bash summary clicks do not open details or host paths', async () => {
|
||||
const b = await bench([toolResult(3, 'c1', 'bash')])
|
||||
const view = mountApp(b.slots)
|
||||
view.getByText('Build').click()
|
||||
expect(b.layout.openDetails).toHaveBeenCalledTimes(1)
|
||||
expect(b.layout.openDetails).not.toHaveBeenCalled()
|
||||
expect(b.workspaces.openPath).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('a live keyed registration takes over its tool row and unload reverts to the fallback', async () => {
|
||||
@@ -271,6 +284,7 @@ describe('registrant load-order seam', () => {
|
||||
}),
|
||||
startSession: vi.fn(),
|
||||
sendSession: vi.fn(),
|
||||
openPath: vi.fn(async () => {}),
|
||||
})
|
||||
ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
ctx.provide('locale', { bind: () => (key: string) => key })
|
||||
|
||||
@@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Profiler } from 'react'
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import type {
|
||||
AssistantMessageNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId,
|
||||
AssistantMessageNode, CommandNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId,
|
||||
SessionListState, ToolResultNode, UserMessageNode, WorkspaceListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
@@ -30,7 +30,7 @@ const SID = 's1' as SessionId
|
||||
function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,7 @@ function emptyWorkspaces() {
|
||||
function makeHarness(init?: Partial<ConversationSnapshot>) {
|
||||
const { set, source } = makeSource(init)
|
||||
const openDetails = vi.fn<(t: SelectionTarget) => void>()
|
||||
const openFile = vi.fn<(path: string) => void>()
|
||||
const loadOlder = vi.fn()
|
||||
// Selection rides the REAL chat store (same construction path as
|
||||
// production; the view reads it through the PropsStore useStore share).
|
||||
@@ -105,6 +106,7 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
||||
useSession: bindSnapshotSelector(source),
|
||||
useSessions: emptySessions(),
|
||||
useWorkspaces: emptyWorkspaces(),
|
||||
useProjection: (() => undefined),
|
||||
useInput: (() => { throw new Error('unused') }),
|
||||
inputActions: { setDraft: () => {}, submit: () => {} },
|
||||
useStore: bindSnapshotSelector(chat),
|
||||
@@ -112,10 +114,11 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
||||
renderSlot,
|
||||
SessionProvider: SessionProviderStub,
|
||||
openDetails,
|
||||
openFile,
|
||||
loadOlder,
|
||||
}
|
||||
const setSelection = (next: SelectionTarget | null): void => { chat.actions.select(next) }
|
||||
return { set, ChatView, props, openDetails, loadOlder, setSelection }
|
||||
return { set, ChatView, props, openDetails, openFile, loadOlder, setSelection }
|
||||
}
|
||||
|
||||
describe('chat-flow derivation', () => {
|
||||
@@ -281,16 +284,31 @@ describe('ChatView', () => {
|
||||
expect(view.getByText(/"command": "cmd-a"/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('clicking a tool row opens details with callId and toolName; selection marks data-selected', () => {
|
||||
it('clicking a bash summary does not open details; selection still marks data-selected', () => {
|
||||
const h = makeHarness({ nodes: [toolResult(3, 'a')] })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
fireEvent.click(view.getByText('run a'))
|
||||
expect(h.openDetails).toHaveBeenCalledWith({ turnSeq: 3, callId: 'a', toolName: 'bash' })
|
||||
expect(h.openDetails).not.toHaveBeenCalled()
|
||||
expect(h.openFile).not.toHaveBeenCalled()
|
||||
expect(view.container.querySelector('[data-selected]')).toBeNull()
|
||||
act(() => { h.setSelection({ turnSeq: 3, callId: 'a', toolName: 'bash' }) })
|
||||
expect(view.container.querySelector('[data-selected]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('clicking a file-tool path summary opens the host file, not details', () => {
|
||||
const h = makeHarness({
|
||||
nodes: [{
|
||||
kind: 'tool-result', seq: 3, time: 3_000, callId: 'r1',
|
||||
call: { name: 'read', argsRaw: '{"path":"src/a.ts"}' },
|
||||
callTime: 2_500, content: [], isError: false, callView: null, resultView: null,
|
||||
}],
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
fireEvent.click(view.getByText('src/a.ts'))
|
||||
expect(h.openFile).toHaveBeenCalledWith('src/a.ts')
|
||||
expect(h.openDetails).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('running calls render as a live tool group with the running state', () => {
|
||||
const h = makeHarness({ runningCalls: [runningCall('r1')], running: true })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
@@ -378,4 +396,41 @@ describe('ChatView', () => {
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
expect(view.getByText(/等待审批/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders command nodes as durable rows: settled text, error state, executing spinner, run-less soft-fall', () => {
|
||||
const command = (over: Partial<CommandNode>): CommandNode => ({
|
||||
kind: 'command', seq: 5, time: 5_000, commandId: 'cmd-1' as CommandNode['commandId'],
|
||||
name: 'plan', args: '', outcome: { kind: 'success', text: '已进入 plan mode' },
|
||||
...over,
|
||||
})
|
||||
// Settled success: the command line is the title, the outcome text the summary.
|
||||
const settled = makeHarness({ nodes: [user(1, 'hi'), command({})] })
|
||||
const view = render(<settled.ChatView {...settled.props} />)
|
||||
expect(view.getByText('/plan')).toBeTruthy()
|
||||
expect(view.getByText('已进入 plan mode')).toBeTruthy()
|
||||
|
||||
// Error outcome flips the row state; a text-less error gets the default copy.
|
||||
const failed = makeHarness({
|
||||
nodes: [command({ seq: 6, commandId: 'cmd-2' as CommandNode['commandId'], outcome: { kind: 'error' } })],
|
||||
})
|
||||
const fv = render(<failed.ChatView {...failed.props} />)
|
||||
expect(fv.container.querySelector('[data-state="error"]')).not.toBeNull()
|
||||
expect(fv.getByText('命令失败')).toBeTruthy()
|
||||
|
||||
// Still executing: running state with the executing copy.
|
||||
const executing = makeHarness({
|
||||
nodes: [command({ seq: 7, commandId: 'cmd-3' as CommandNode['commandId'], outcome: null })],
|
||||
})
|
||||
const xv = render(<executing.ChatView {...executing.props} />)
|
||||
expect(xv.container.querySelector('[data-state="running"]')).not.toBeNull()
|
||||
expect(xv.getByText('执行中…')).toBeTruthy()
|
||||
|
||||
// Cross-window soft-fall (run page truncated): generic title, outcome preserved.
|
||||
const orphan = makeHarness({
|
||||
nodes: [command({ seq: 8, commandId: 'cmd-4' as CommandNode['commandId'], name: null, args: null, outcome: { kind: 'success' } })],
|
||||
})
|
||||
const ov = render(<orphan.ChatView {...orphan.props} />)
|
||||
expect(ov.getByText('命令')).toBeTruthy()
|
||||
expect(ov.getByText('已完成')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -82,7 +82,7 @@ describe('tails', () => {
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
}
|
||||
const props: ToolRowOwnerProps = {
|
||||
callId: 'c5', toolName: 'todo_write', block: settled, openDetails: vi.fn(),
|
||||
callId: 'c5', toolName: 'todo_write', block: settled, openFile: vi.fn(),
|
||||
}
|
||||
const view = render(<GenericToolCard {...props} />)
|
||||
// Settled ok state keeps the variant icon (sparkle) instead of a StateDot.
|
||||
@@ -99,7 +99,7 @@ describe('tails', () => {
|
||||
phase: 'ready',
|
||||
})
|
||||
const props = (block: RunningToolCall | ToolResultNode) => ({
|
||||
callId: 'c1', toolName: 'bash', block, openDetails: vi.fn(),
|
||||
callId: 'c1', toolName: 'bash', block, openFile: vi.fn(),
|
||||
sessionId: sid, useSessions: bindSnapshotSelector(list),
|
||||
} as unknown as ToolRowProps)
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ const SID = 's1' as SessionId
|
||||
function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,7 @@ describe('render branch tails', () => {
|
||||
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} })}
|
||||
useSessions={bindSnapshotSelector(emptyList)}
|
||||
useWorkspaces={bindSnapshotSelector(emptyWorkspaces)}
|
||||
useProjection={(() => undefined)}
|
||||
useInput={(() => { throw new Error('unused') })}
|
||||
inputActions={{ setDraft: () => {}, submit: () => {} }}
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
@@ -111,6 +112,7 @@ describe('render branch tails', () => {
|
||||
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} })}
|
||||
useSessions={bindSnapshotSelector(emptyList)}
|
||||
useWorkspaces={bindSnapshotSelector(emptyWorkspaces)}
|
||||
useProjection={(() => undefined)}
|
||||
useInput={(() => { throw new Error('unused') })}
|
||||
inputActions={{ setDraft: () => {}, submit: () => {} }}
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
|
||||
@@ -21,7 +21,7 @@ const SID = 's1' as SessionId
|
||||
function snapshotOf(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false,
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, lastAgentError: null,
|
||||
...overrides,
|
||||
@@ -88,6 +88,7 @@ function bench(over?: BenchOptions) {
|
||||
items: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
})),
|
||||
useProjection: (() => undefined),
|
||||
useInput: bindSnapshotSelector(shell.state),
|
||||
inputActions: shell.actions,
|
||||
keyboard: shell,
|
||||
|
||||
@@ -24,7 +24,7 @@ const SID = 's1' as SessionId
|
||||
function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled?: boolean }) {
|
||||
const session = createSnapshotStore<ConversationSnapshot>({
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], todos: [], running: over?.running ?? false, composerPhase: 'active',
|
||||
pending: [], queue: [], running: over?.running ?? false, composerPhase: 'active',
|
||||
removed: over?.disabled ?? false, openState: 'open', openError: null, hasMore: false,
|
||||
loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
||||
})
|
||||
@@ -39,6 +39,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
|
||||
items: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
})),
|
||||
useProjection: (() => undefined),
|
||||
useInput: bindSnapshotSelector(shell.state),
|
||||
inputActions: shell.actions,
|
||||
keyboard: shell,
|
||||
|
||||
@@ -110,7 +110,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
|
||||
const wiring = shell
|
||||
const sessionStore = createSnapshotStore<ConversationSnapshot>({
|
||||
sessionId, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false,
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, lastAgentError: null,
|
||||
})
|
||||
@@ -125,6 +125,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
|
||||
items: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
})),
|
||||
useProjection: (() => undefined),
|
||||
useInput: bindSnapshotSelector(shell.state),
|
||||
inputActions: shell.actions,
|
||||
keyboard: shell,
|
||||
|
||||
@@ -19,7 +19,7 @@ const SID = 's1' as SessionId
|
||||
function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue, todos: [], running: true, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
pending: [], queue, running: true, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,7 @@ function kitFor(snapshot: ConversationSnapshot) {
|
||||
sessionId: SID,
|
||||
useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<SessionListState>,
|
||||
useWorkspaces: (() => { throw new Error('unused') }) as never,
|
||||
useProjection: (() => undefined) as never,
|
||||
useInput: (() => { throw new Error('unused') }) as never,
|
||||
inputActions: { setDraft: () => {}, submit: () => {} } as never,
|
||||
session: snapshot,
|
||||
|
||||
@@ -48,7 +48,7 @@ const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState =>
|
||||
function conversationSnapshot(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false,
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, lastAgentError: null,
|
||||
...overrides,
|
||||
@@ -93,6 +93,7 @@ function mount(
|
||||
useSession={useSession}
|
||||
useSessions={props.useSessions}
|
||||
useWorkspaces={props.useWorkspaces}
|
||||
useProjection={(() => undefined)}
|
||||
useInput={useInput}
|
||||
inputActions={inputActions}
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
@@ -115,6 +116,7 @@ function mount(
|
||||
useSession={useSession}
|
||||
useSessions={props.useSessions}
|
||||
useWorkspaces={props.useWorkspaces}
|
||||
useProjection={(() => undefined)}
|
||||
useInput={useInput}
|
||||
inputActions={inputActions}
|
||||
keyboard={wiring}
|
||||
@@ -135,6 +137,7 @@ function mount(
|
||||
useSession,
|
||||
useSessions: bindSnapshotSelector(sessions),
|
||||
useWorkspaces: bindSnapshotSelector(workspaces),
|
||||
useProjection: (() => undefined),
|
||||
useInput,
|
||||
inputActions,
|
||||
renderSlot,
|
||||
|
||||
@@ -109,7 +109,7 @@ describe('terminalCardModel', () => {
|
||||
|
||||
describe('chat row terminal body', () => {
|
||||
const ownerProps = (block: RunningToolCall | ToolResultNode): ToolRowOwnerProps => ({
|
||||
callId: 'c1', toolName: 'bash', block, openDetails: vi.fn(),
|
||||
callId: 'c1', toolName: 'bash', block, openFile: vi.fn(),
|
||||
})
|
||||
|
||||
it('the expanded body is the command output, capped tighter than the panel', () => {
|
||||
@@ -184,22 +184,19 @@ describe('BashRow terminal card', () => {
|
||||
phase: 'ready',
|
||||
})
|
||||
|
||||
const rowProps = (block: RunningToolCall | ToolResultNode, openDetails = vi.fn()): ToolRowProps => ({
|
||||
callId: 'c1', toolName: 'bash', block, openDetails,
|
||||
const rowProps = (block: RunningToolCall | ToolResultNode): ToolRowProps => ({
|
||||
callId: 'c1', toolName: 'bash', block, openFile: vi.fn(),
|
||||
sessionId: SID, useSessions: bindSnapshotSelector(list()),
|
||||
} as unknown as ToolRowProps)
|
||||
|
||||
it('renders the command output under the summary row, without an expand gesture', () => {
|
||||
const openDetails = vi.fn()
|
||||
const view = render(<BashRow {...rowProps(settled(), openDetails)} />)
|
||||
const view = render(<BashRow {...rowProps(settled())} />)
|
||||
expect(view.getByText('List files')).toBeTruthy()
|
||||
expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy()
|
||||
// The terminal card sits outside the row's click target: copying does not
|
||||
// open the details panel.
|
||||
fireEvent.click(view.getByText('复制'))
|
||||
expect(openDetails).not.toHaveBeenCalled()
|
||||
fireEvent.click(view.getByText('List files'))
|
||||
expect(openDetails).toHaveBeenCalledTimes(1)
|
||||
// The card's controls are the row's only interactions: a bash row is not a
|
||||
// path link and no longer a details-panel target, so nothing here navigates.
|
||||
expect(view.container.querySelector('[data-clickable]')).toBeNull()
|
||||
expect(view.getByText('复制')).toBeTruthy()
|
||||
})
|
||||
|
||||
// The row's leading StateDot and the card's run-state dot describe the same
|
||||
@@ -244,6 +241,7 @@ describe('DetailsPanel Output section', () => {
|
||||
useWorkspaces={bindSnapshotSelector(workspaces)}
|
||||
useInput={(() => { throw new Error('unused') })}
|
||||
inputActions={{ setDraft: () => {}, submit: () => {} }}
|
||||
useProjection={(() => undefined)}
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
closeDetails={vi.fn()}
|
||||
@@ -254,7 +252,7 @@ describe('DetailsPanel Output section', () => {
|
||||
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false,
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, lastAgentError: null, ...over,
|
||||
}
|
||||
@@ -360,6 +358,7 @@ describe('DetailsPanel Output section', () => {
|
||||
}))}
|
||||
useInput={(() => { throw new Error('unused') })}
|
||||
inputActions={{ setDraft: () => {}, submit: () => {} }}
|
||||
useProjection={(() => undefined)}
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
closeDetails={closeDetails}
|
||||
|
||||
@@ -64,20 +64,23 @@ describe('TodoPanel', () => {
|
||||
})
|
||||
})
|
||||
|
||||
/** Dock props stub: the adapter reads useSession only; the rest of the owner share is unused. */
|
||||
function dockProps(store: ReturnType<typeof createSnapshotStore<{ todos: readonly TodoItem[] }>>): TodoDockProps {
|
||||
return { useSession: bindSnapshotSelector(store) } as unknown as TodoDockProps
|
||||
/** Dock props stub: the adapter reads the 'todos' projection only; the rest of the owner share is unused. */
|
||||
function dockProps(store: ReturnType<typeof createSnapshotStore<{ value: readonly TodoItem[] | null | undefined }>>): TodoDockProps {
|
||||
const useProjection = (_key: string, selector?: (v: unknown) => unknown) =>
|
||||
bindSnapshotSelector(store)(s => (selector ?? (v => v))(s.value))
|
||||
return { useProjection } as unknown as TodoDockProps
|
||||
}
|
||||
|
||||
describe('TodoDock', () => {
|
||||
it('selects the plan off the session snapshot and follows later writes', () => {
|
||||
const store = createSnapshotStore<{ todos: readonly TodoItem[] }>({ todos: [] })
|
||||
it('reads the host-computed todos projection and follows pushed updates', () => {
|
||||
const store = createSnapshotStore<{ value: readonly TodoItem[] | null | undefined }>({ value: undefined })
|
||||
render(<TodoDock {...dockProps(store)} />)
|
||||
// Capability absent (no baseline/frame yet) renders nothing.
|
||||
expect(screen.queryByTestId('todo-panel')).toBeNull()
|
||||
act(() => { store.set({ todos: LIST }) })
|
||||
act(() => { store.set({ value: LIST }) })
|
||||
expect(screen.getByText('1/3 tasks · 1 in progress')).toBeTruthy()
|
||||
// A rollback to the empty list retires the strip (the panel owns no data).
|
||||
act(() => { store.set({ todos: [] }) })
|
||||
// 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()
|
||||
})
|
||||
|
||||
@@ -96,10 +99,10 @@ const resultNode = (argsRaw: string, over?: Partial<ToolResultNode>): ToolResult
|
||||
content: [], isError: false, callView: null, resultView: null, ...over,
|
||||
})
|
||||
|
||||
function rowProps(block: unknown, openDetails = vi.fn()): ToolRowProps {
|
||||
function rowProps(block: unknown): ToolRowProps {
|
||||
return {
|
||||
callId: 'c1', toolName: 'todo_write', block,
|
||||
openDetails,
|
||||
openFile: vi.fn(),
|
||||
sessionId: 's1',
|
||||
useSessions: () => undefined,
|
||||
} as unknown as ToolRowProps
|
||||
@@ -140,27 +143,10 @@ describe('TodoRow', () => {
|
||||
expect(screen.getByText('todo_write · not json')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('falls back when parsed args carry no todos array, and click opens details', () => {
|
||||
const openDetails = vi.fn()
|
||||
render(<TodoRow {...rowProps(resultNode('{"other":1}'), openDetails)} />)
|
||||
it('falls back when parsed args carry no todos array and stays non-interactive', () => {
|
||||
render(<TodoRow {...rowProps(resultNode('{"other":1}'))} />)
|
||||
expect(screen.getByText('todo_write · {"other":1}')).toBeTruthy()
|
||||
fireEvent.click(screen.getByText('更新任务清单'))
|
||||
expect(openDetails).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('opens details from the keyboard on Enter and Space, ignoring other keys', () => {
|
||||
const openDetails = vi.fn()
|
||||
render(<TodoRow {...rowProps(resultNode(ARGS), openDetails)} />)
|
||||
const row = screen.getByRole('button')
|
||||
expect(row.getAttribute('tabindex')).toBe('0')
|
||||
fireEvent.keyDown(row, { key: 'Enter' })
|
||||
fireEvent.keyDown(row, { key: ' ' })
|
||||
expect(openDetails).toHaveBeenCalledTimes(2)
|
||||
// Space must not also scroll the flow: the handler claims the event.
|
||||
expect(fireEvent.keyDown(row, { key: ' ' })).toBe(false)
|
||||
fireEvent.keyDown(row, { key: 'a' })
|
||||
fireEvent.keyDown(row, { key: 'ArrowDown' })
|
||||
expect(openDetails).toHaveBeenCalledTimes(3)
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -49,6 +49,8 @@ describe('view-ring type negatives (compile-time; body never runs)', () => {
|
||||
const chatProps = (props: ChatViewSlotProps): ReactNode => {
|
||||
// @ts-expect-error openDetails takes a SelectionTarget, not a string
|
||||
props.openDetails('nope')
|
||||
// @ts-expect-error openFile takes a path string, not a SelectionTarget
|
||||
props.openFile({ turnSeq: 1, callId: 'c' })
|
||||
return null
|
||||
}
|
||||
void chatProps
|
||||
|
||||
@@ -23,6 +23,12 @@
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
{
|
||||
"path": "../../session-projection/session-projection"
|
||||
},
|
||||
{
|
||||
"path": "../../todo/tool-todo"
|
||||
},
|
||||
{
|
||||
"path": "../ui-slash"
|
||||
},
|
||||
|
||||
@@ -79,6 +79,13 @@
|
||||
background: var(--dsw-specific-menu);
|
||||
box-shadow: var(--dsw-shadow-lv3);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
/* Elevated surface: the scrollbar thumb takes the l2 elevation tokens.
|
||||
Declared here rather than on the scrolling `.groups` child so the
|
||||
elevation choice sits with the surface; the custom properties inherit
|
||||
down to whichever descendant actually scrolls (see ui-theme
|
||||
styles/scrollbar.css for the rebinding contract). */
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
||||
}
|
||||
|
||||
.status,
|
||||
|
||||
@@ -17,6 +17,13 @@
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-specific-menu);
|
||||
box-shadow: var(--dsw-shadow-lv3);
|
||||
/* Elevated surface: the scrollbar thumb takes the l2 elevation tokens. The
|
||||
declaration sits on the card rather than on `.scrollable .viewport`
|
||||
because the elevation is a property of this surface, and the custom
|
||||
properties inherit down to whichever descendant actually scrolls (see
|
||||
ui-theme styles/scrollbar.css for the rebinding contract). */
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
||||
}
|
||||
|
||||
/* Primary card is 218 wide in the design across both hosts. */
|
||||
|
||||
@@ -19,6 +19,13 @@
|
||||
background: var(--dsw-specific-input-major);
|
||||
box-shadow: var(--dsw-shadow-lv1-blur);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
/* Elevated surface in dark, same as the menus: the option list inside scrolls
|
||||
once the card hits the cap above, so the thumb takes the l2 pair. Declared
|
||||
on the card because the elevation belongs to the surface, and the custom
|
||||
properties inherit down to `.options` (see ui-theme styles/scrollbar.css
|
||||
for the rebinding contract). */
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
||||
}
|
||||
|
||||
.card,
|
||||
|
||||
@@ -25,6 +25,7 @@ const kit = {
|
||||
useSession: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<ConversationSnapshot>,
|
||||
useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<SessionListState>,
|
||||
useWorkspaces: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<WorkspaceListState>,
|
||||
useProjection: (() => undefined) as never,
|
||||
useInput: (() => { throw new Error('unused') }) as never,
|
||||
inputActions: { setDraft: () => { throw new Error('unused') }, submit: () => { throw new Error('unused') } } as never,
|
||||
}
|
||||
|
||||
@@ -76,6 +76,13 @@
|
||||
overflow: hidden;
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
box-shadow: var(--dsw-shadow-lv3);
|
||||
/* Elevated surface: the scrollbar thumb takes the l2 elevation tokens.
|
||||
Declared on the panel rather than the scrolling `.options` child so the
|
||||
elevation choice sits with the surface; the custom properties inherit
|
||||
down to whichever descendant scrolls (see ui-theme
|
||||
styles/scrollbar.css for the rebinding contract). */
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
||||
}
|
||||
|
||||
/* Nav rail (figma .Setting-nav 501:29958): 188 wide, pad (12,22,12,0),
|
||||
|
||||
@@ -13,6 +13,10 @@
|
||||
max-width: 537px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
/* Elevated surface: the scrollbar thumb takes the l2 elevation tokens
|
||||
(see ui-theme styles/scrollbar.css for the rebinding contract). */
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -44,6 +44,15 @@ export interface SessionMaybeProvideInfo {
|
||||
hooks: Record<string, HostObservable<unknown> | undefined>
|
||||
/** Static plain-member roster; values are undefined with the session. */
|
||||
props: Record<string, unknown>
|
||||
/**
|
||||
* Key-addressed projection value sources (the useProjection framework seat,
|
||||
* session-projection RFC). Unlike `hooks`, the key space is open — values
|
||||
* arrive from host-computed push frames — so the render side binds per
|
||||
* resolved key instead of per static roster member. Faces are always
|
||||
* defined per key (absence is an `undefined` snapshot); the whole member is
|
||||
* absent with the session.
|
||||
*/
|
||||
projections?: { faceOf(key: string): HostObservable<unknown> } | undefined
|
||||
}
|
||||
|
||||
/** Definite per-session standard props resolved for strict session slots. */
|
||||
|
||||
@@ -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-theme/README.md
|
||||
README.md: 1227df357cb93241fcf28da9b74d7ba15207e9c5
|
||||
README.zh.md: cd87ede7264c8d47dd780acaa11128e83d7862f9
|
||||
README.md: a1ff7d840dae86f5da98de1208ecda3b8b62026b
|
||||
README.zh.md: 49b52bcb1e07527e98c602086404228c5513091a
|
||||
|
||||
@@ -4,6 +4,12 @@ English | [中文](README.zh.md)
|
||||
|
||||
Theme plugin: ThemeService over the --dsw-* token base stylesheets (static scale + alias semantic layers). The service owns the theme preference (`light`/`dark`/`system`, persisted under `dsh.theme`), resolves `system` through `prefers-color-scheme`, and publishes immutable `ThemeSnapshot`s on the `theme/change` event; it never touches the DOM — ui-layout's presenter applies the resolved snapshot (`html { color-scheme }`, `body[data-ds-dark-theme]`, and inline alias tokens). Contract: api-contracts v3 §8.
|
||||
|
||||
`src/styles/` holds five sheets, all imported by the web shell's `base.css`: `base.css`, `design-platform.css`, `scrollbar.css`, `gradient-shadow-text.css`, and `shiki.css`. `scrollbar.css` is the sole consumer of the `--dsw-alias-scrollbar-*` tokens and must follow `design-platform.css`, which declares them.
|
||||
|
||||
Scrollbar rebinding contract: `scrollbar.css` binds `--dsh-scrollbar-thumb` and `--dsh-scrollbar-thumb-hover` on `body` to the l1 (base-surface) tokens, and both rendering paths read that pair. An elevated surface (menu, popover, dialog) sets `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` and `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)` on its own container; one rebind retints whichever path the engine took.
|
||||
|
||||
The two paths are mutually exclusive by construction. `scrollbar-width`/`scrollbar-color` sit inside `@supports not selector(::-webkit-scrollbar)` because a non-`auto` value of either makes Chromium and Safari discard every `::-webkit-scrollbar*` rule for that element, `::-webkit-scrollbar-thumb:hover` included — declaring both unconditionally leaves `--dsh-scrollbar-thumb-hover` with no rendering anywhere. Firefox therefore takes the standard properties and WebKit-based engines take the pseudo-elements, so the hover token only ever renders through the pseudo-element path. Reasoning and the measured computed values: [the scrollbar Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md).
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as the theme service manages a browser preference; nothing here reaches a model request.
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
|
||||
主题插件:基于 --dsw-* token 基础样式表(静态尺度 + 别名语义层)的 ThemeService。该服务拥有主题偏好(`light`/`dark`/`system`,以 `dsh.theme` 为键持久化),将 `system` 通过 `prefers-color-scheme` 解析为实际主题,并发布不可变的 `ThemeSnapshot`,通过 `theme/change` 事件通知变化;它绝不接触 DOM:ui-layout 的呈现器会应用解析后的快照(`html { color-scheme }`、`body[data-ds-dark-theme]`,以及主题的别名 token 内联变量)。契约:api-contracts v3 §8。
|
||||
|
||||
`src/styles/` 下有五张样式表,全部由 web 壳的 `base.css` 导入:`base.css`、`design-platform.css`、`scrollbar.css`、`gradient-shadow-text.css` 与 `shiki.css`。`scrollbar.css` 是 `--dsw-alias-scrollbar-*` token 的唯一消费方,必须排在声明这些 token 的 `design-platform.css` 之后。
|
||||
|
||||
滚动条重新绑定契约:`scrollbar.css` 在 `body` 上把 `--dsh-scrollbar-thumb` 与 `--dsh-scrollbar-thumb-hover` 绑定到 l1(基础表面)token,两条渲染路径都读取这一组变量。抬升表面(菜单、浮层、对话框)在自己的容器上设置 `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` 与 `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)`;一次重新绑定即可为引擎实际走的那条路径换色。
|
||||
|
||||
两条路径在构造上互斥。`scrollbar-width`/`scrollbar-color` 写在 `@supports not selector(::-webkit-scrollbar)` 之内,因为这两个属性只要取非 `auto` 值,Chromium 与 Safari 就会丢弃该元素上的全部 `::-webkit-scrollbar*` 规则,`::-webkit-scrollbar-thumb:hover` 也在其中——若无条件地同时声明,`--dsh-scrollbar-thumb-hover` 在任何引擎上都不会被渲染。因此 Firefox 走标准属性,WebKit 系引擎走伪元素,hover token 只经由伪元素这条路径渲染。推理过程与实测计算值见[滚动条 Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无。主题服务管理浏览器偏好;这里没有任何内容进入模型请求。
|
||||
|
||||
85
packages/client/ui-theme/src/styles/scrollbar.css
Normal file
85
packages/client/ui-theme/src/styles/scrollbar.css
Normal file
@@ -0,0 +1,85 @@
|
||||
/* Scrollbar skin: the sole consumer of the four --dsw-alias-scrollbar-*
|
||||
* tokens. Without it every scrolling region renders the UA scrollbar, which
|
||||
* ignores the theme — a light native bar over the dark palette.
|
||||
*
|
||||
* The rules sit on `body`, not `html`: design-platform.css declares the
|
||||
* --dsw-alias-* tokens on `body` (and the dark overrides on
|
||||
* `body[data-ds-dark-theme]`), and custom properties only inherit downward,
|
||||
* so an `html` rule resolves them to the guaranteed-invalid value and
|
||||
* `scrollbar-color` falls back to `auto`.
|
||||
*
|
||||
* Surfaces pick their elevation by rebinding --dsh-scrollbar-thumb{,-hover}:
|
||||
* the l1 pair here is the base-surface default, and an elevated surface
|
||||
* (menu, popover, dialog) rebinds to the l2 pair on its own container. Both
|
||||
* rendering paths below read the indirection, so one rebind reaches whichever
|
||||
* path the engine took. */
|
||||
|
||||
body {
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l1);
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l1);
|
||||
}
|
||||
|
||||
/* The two paths are mutually exclusive, and the gate is load-bearing rather
|
||||
than defensive. A non-`auto` `scrollbar-width` or `scrollbar-color` makes
|
||||
Chromium and Safari drop every `::-webkit-scrollbar*` rule for that
|
||||
element, including `::-webkit-scrollbar-thumb:hover` — measured in chromium
|
||||
as an 8px `::-webkit-scrollbar` width taking effect on its own and being
|
||||
ignored as soon as `scrollbar-width: thin` is added. Declaring both
|
||||
unconditionally therefore leaves the hover tokens with no rendering at all,
|
||||
because the engines that implement the hover pseudo-element are exactly the
|
||||
ones the standard properties silence, and Firefox has no hover
|
||||
pseudo-element to fall back on.
|
||||
|
||||
`not selector(::-webkit-scrollbar)` is true only where the pseudo-element
|
||||
is unimplemented, so Firefox takes the standard path and WebKit-based
|
||||
engines take the pseudo-element path. An engine too old for the
|
||||
`selector()` function makes the condition invalid, which evaluates false
|
||||
and selects the pseudo-element path — the correct side for the pre-16.4
|
||||
Safari that is the realistic case. */
|
||||
@supports not selector(::-webkit-scrollbar) {
|
||||
/* Declared on every element rather than inherited from `body`. Inheriting
|
||||
would pass down the COLOUR already substituted at `body`, so a descendant
|
||||
rebinding --dsh-scrollbar-thumb could not change it; re-declaring makes
|
||||
each element substitute the variable as it sees it, which is what gives
|
||||
an elevated surface a working rebind. `scrollbar-width` is not an
|
||||
inherited property at all, so it needs the per-element declaration
|
||||
regardless.
|
||||
|
||||
No hover counterpart exists on this path: `scrollbar-color` states one
|
||||
thumb colour and the engine derives its own hover treatment. */
|
||||
body,
|
||||
body * {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--dsh-scrollbar-thumb) transparent;
|
||||
}
|
||||
}
|
||||
|
||||
/* Not gated in turn: an engine that does not implement these pseudo-elements
|
||||
drops the rules as unknown selectors, so the gate would only restate what
|
||||
selector matching already does. Not inherited either, hence the unscoped
|
||||
selectors. */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
/* Track stays transparent so the thumb reads against whatever surface scrolls
|
||||
under it; only the thumb carries a token colour. */
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
border-radius: 4px;
|
||||
background: var(--dsh-scrollbar-thumb);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--dsh-scrollbar-thumb-hover);
|
||||
}
|
||||
|
||||
/* Both scrollbars meeting in a corner: no separate token, so the corner
|
||||
matches the transparent track rather than the UA's opaque default. */
|
||||
::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
506
packages/client/ui-theme/tests/scrollbar-styles.spec.ts
Normal file
506
packages/client/ui-theme/tests/scrollbar-styles.spec.ts
Normal file
@@ -0,0 +1,506 @@
|
||||
/**
|
||||
* Scrollbar stylesheet contract, asserted against the CSS text on disk: every
|
||||
* --dsw-alias-scrollbar-* token design-platform.css defines has a consumer,
|
||||
* scrollbar.css binds the base-surface pair through the rebindable
|
||||
* indirection, and elevated surfaces rebind that indirection in complete
|
||||
* pairs. The expected token set is scanned out of design-platform.css, so
|
||||
* adding, renaming, or dropping a scrollbar token moves these assertions with
|
||||
* it.
|
||||
*/
|
||||
import { readdirSync, readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
/** One flattened CSS rule: its comma-separated selector parts and its declarations in source order. */
|
||||
interface CssRule {
|
||||
selectors: string[]
|
||||
declarations: [property: string, value: string][]
|
||||
}
|
||||
|
||||
const STYLES = new URL('../src/styles/', import.meta.url)
|
||||
const PACKAGES_DIR = fileURLToPath(new URL('../../../', import.meta.url))
|
||||
const read = (name: string): string => readFileSync(fileURLToPath(new URL(name, STYLES)), 'utf8')
|
||||
|
||||
const platformCss = read('design-platform.css')
|
||||
const scrollbarCss = read('scrollbar.css')
|
||||
|
||||
/** Body attribute selecting the dark palette; ui-layout's ThemePresenter sets it. */
|
||||
const DARK_ATTRIBUTE = '[data-ds-dark-theme]'
|
||||
/** Alias tokens under test: the prefix the elevation pairs share. */
|
||||
const TOKEN_PREFIX = '--dsw-alias-scrollbar-'
|
||||
/** Prefix of the rebindable indirection scrollbar.css owns. */
|
||||
const INDIRECTION_PREFIX = '--dsh-scrollbar-'
|
||||
|
||||
/**
|
||||
* Flatten a stylesheet into rules. Whitespace, declaration order, and trailing
|
||||
* semicolons are normalized away; nesting and at-rules are not handled, which
|
||||
* no sheet under test uses for scrollbar declarations.
|
||||
* @param css - stylesheet text.
|
||||
* @returns one entry per rule, in source order.
|
||||
*/
|
||||
function parseRules(css: string): CssRule[] {
|
||||
const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
||||
const rules: CssRule[] = []
|
||||
// Destructuring defaults only satisfy noUncheckedIndexedAccess; both groups
|
||||
// are unconditional in the pattern.
|
||||
for (const [, selector = '', body = ''] of withoutComments.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
|
||||
const declarations = body
|
||||
.split(';')
|
||||
.map(part => part.trim())
|
||||
.filter(part => part.includes(':'))
|
||||
.map((part): [string, string] => {
|
||||
const colon = part.indexOf(':')
|
||||
return [part.slice(0, colon).trim(), part.slice(colon + 1).trim()]
|
||||
})
|
||||
rules.push({ selectors: selector.split(',').map(part => part.trim()), declarations })
|
||||
}
|
||||
return rules
|
||||
}
|
||||
|
||||
/**
|
||||
* Half-open source span of one at-rule's block, excluding its prelude.
|
||||
* @param css - stylesheet text.
|
||||
* @param prelude - exact at-rule prelude to locate, without the opening brace.
|
||||
* @returns the block's brace offsets, or undefined when the prelude is absent.
|
||||
*/
|
||||
function atRuleBlock(css: string, prelude: string): { start: number; end: number } | undefined {
|
||||
const opening = css.indexOf(`${prelude} {`)
|
||||
if (opening === -1) return undefined
|
||||
const start = css.indexOf('{', opening)
|
||||
let depth = 0
|
||||
for (let index = start; index < css.length; index += 1) {
|
||||
if (css[index] === '{') depth += 1
|
||||
else if (css[index] === '}') {
|
||||
depth -= 1
|
||||
if (depth === 0) return { start, end: index }
|
||||
}
|
||||
}
|
||||
throw new Error(`unbalanced braces after ${prelude}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom-property names a value reads.
|
||||
* @param value - declaration value, possibly with nested var() calls.
|
||||
* @returns every referenced custom-property name, in source order.
|
||||
*/
|
||||
function varReferences(value: string): string[] {
|
||||
return [...value.matchAll(/var\(\s*(--[\w-]+)/g)].map(([, name = '']) => name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every CSS file shipped as package source, excluding build output and
|
||||
* installed dependencies.
|
||||
* @returns absolute paths of the stylesheets under packages/.
|
||||
*/
|
||||
function packageStylesheets(): string[] {
|
||||
const found: string[] = []
|
||||
const walk = (dir: string): void => {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const path = join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name !== 'node_modules' && entry.name !== 'lib' && entry.name !== 'dist') walk(path)
|
||||
} else if (entry.name.endsWith('.css')) found.push(path)
|
||||
}
|
||||
}
|
||||
walk(PACKAGES_DIR)
|
||||
return found
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokens a stylesheet reads through its rendering declarations, following its
|
||||
* own custom-property definitions transitively so a token reached only through
|
||||
* an indirection counts. The walk starts from the standard-property
|
||||
* declarations, so a defined-but-unread indirection contributes nothing.
|
||||
* @param rules - parsed rules of one stylesheet.
|
||||
* @returns every `--dsw-*` token the sheet's rendering declarations depend on.
|
||||
*/
|
||||
function tokensRendered(rules: CssRule[]): Set<string> {
|
||||
const definitions = new Map<string, string>()
|
||||
const pending: string[] = []
|
||||
for (const rule of rules) {
|
||||
for (const [property, value] of rule.declarations) {
|
||||
if (property.startsWith('--')) definitions.set(property, value)
|
||||
else pending.push(value)
|
||||
}
|
||||
}
|
||||
const reached = new Set<string>()
|
||||
const visited = new Set<string>()
|
||||
while (pending.length > 0) {
|
||||
for (const name of varReferences(pending.pop()!)) {
|
||||
if (name.startsWith('--dsw-')) reached.add(name)
|
||||
if (visited.has(name)) continue
|
||||
visited.add(name)
|
||||
const definition = definitions.get(name)
|
||||
if (definition !== undefined) pending.push(definition)
|
||||
}
|
||||
}
|
||||
return reached
|
||||
}
|
||||
|
||||
const platformRules = parseRules(platformCss)
|
||||
const scrollbarRules = parseRules(scrollbarCss)
|
||||
const sorted = (names: Iterable<string>): string[] => [...names].sort()
|
||||
|
||||
/**
|
||||
* Scrollbar tokens defined by the rules whose selectors carry (or do not
|
||||
* carry) the dark palette attribute.
|
||||
* @param dark - true to scan the dark blocks, false to scan the light blocks.
|
||||
* @returns the scrollbar token names defined there.
|
||||
*/
|
||||
function definedTokens(dark: boolean): Set<string> {
|
||||
const names = new Set<string>()
|
||||
for (const rule of platformRules) {
|
||||
if (rule.selectors.every(selector => selector.includes(DARK_ATTRIBUTE)) !== dark) continue
|
||||
for (const [property] of rule.declarations) {
|
||||
if (property.startsWith(TOKEN_PREFIX)) names.add(property)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
const lightTokens = definedTokens(false)
|
||||
const darkTokens = definedTokens(true)
|
||||
const allTokens = new Set([...lightTokens, ...darkTokens])
|
||||
|
||||
/** Every scrollbar token any package stylesheet references, mapped to the files referencing it. */
|
||||
const referencedTokens = new Map<string, string[]>()
|
||||
/** Every indirection property any package stylesheet outside ui-theme declares, mapped to its declaring rules. */
|
||||
const rebindRules: { file: string; rule: CssRule }[] = []
|
||||
/**
|
||||
* What one stylesheet contributes to the elevated-surface question: which
|
||||
* elevated surfaces it paints, whether any rule scrolls, and whether it
|
||||
* rebinds. Kept per file rather than per rule because the elevated card and the
|
||||
* descendant that actually scrolls are separate rules in the same sheet, and
|
||||
* CSS text does not express which contains which.
|
||||
*/
|
||||
interface SheetSurfaces {
|
||||
/** Elevated surface tokens this sheet paints anywhere. */
|
||||
elevated: Set<string>
|
||||
/** True when some rule declares `overflow*: auto|scroll`. */
|
||||
scrolls: boolean
|
||||
/** True when some rule rebinds the indirection. */
|
||||
rebinds: boolean
|
||||
}
|
||||
const sheetSurfaces = new Map<string, SheetSurfaces>()
|
||||
|
||||
/** Properties whose `auto`/`scroll` value makes a rule a scroll container. */
|
||||
const OVERFLOW_PROPERTIES = ['overflow', 'overflow-x', 'overflow-y']
|
||||
/** Properties that paint a surface, and so identify the elevation a rule sits on. */
|
||||
const SURFACE_PROPERTIES = ['background', 'background-color']
|
||||
/**
|
||||
* Token families that name a SURFACE — a background an element is drawn on, and
|
||||
* so something a scrollbar can sit against. `--dsw-alias-button-*`,
|
||||
* `--dsw-alias-interactive-*`, and `--dsw-alias-markdown-*` reach the same dark
|
||||
* elevation rungs while naming a control or an inline span, which no scroll
|
||||
* container renders its bar against (ChatView's floating `.toBottom` pill,
|
||||
* CodeBlock's banner). Family, not geometry: a floating button legitimately
|
||||
* carries a radius, a shadow, and a fixed size, so shape cannot separate them.
|
||||
*/
|
||||
const SURFACE_TOKEN_PATTERN = /^--dsw-(?:alias-bg-|specific-)/
|
||||
|
||||
/**
|
||||
* The palette's own dark elevation ladder, resolved from `design-platform.css`:
|
||||
* `bg-layer-2` and `bg-layer-3` are the rungs above the base surfaces, and the
|
||||
* l1/l2 scrollbar split encodes exactly that step. Reading it from the palette
|
||||
* rather than from the sheets that happen to rebind is what lets the check flag
|
||||
* a surface NOBODY has rebound yet.
|
||||
* @returns surface tokens whose dark value sits on an elevated rung.
|
||||
*/
|
||||
function elevatedRungs(): Set<string> {
|
||||
const definitions = new Map<string, string>()
|
||||
for (const rule of platformRules) {
|
||||
// Dark declarations come later in the sheet and overwrite the light ones,
|
||||
// which is the palette this distinction exists in.
|
||||
for (const [property, value] of rule.declarations) definitions.set(property, value)
|
||||
}
|
||||
const resolve = (name: string): string => {
|
||||
const seen = new Set<string>()
|
||||
let current = name
|
||||
while (definitions.has(current) && !seen.has(current)) {
|
||||
seen.add(current)
|
||||
const value = definitions.get(current)!
|
||||
const [reference] = varReferences(value)
|
||||
if (reference === undefined) return value
|
||||
current = reference
|
||||
}
|
||||
return current
|
||||
}
|
||||
const rungs = new Set([resolve('--dsw-alias-bg-layer-2'), resolve('--dsw-alias-bg-layer-3')])
|
||||
const tokens = new Set<string>()
|
||||
for (const name of definitions.keys()) {
|
||||
if (SURFACE_TOKEN_PATTERN.test(name) && rungs.has(resolve(name))) tokens.add(name)
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
const elevatedSurfaces = elevatedRungs()
|
||||
|
||||
for (const file of packageStylesheets()) {
|
||||
const rules = parseRules(readFileSync(file, 'utf8'))
|
||||
const surfaces: SheetSurfaces = { elevated: new Set(), scrolls: false, rebinds: false }
|
||||
for (const rule of rules) {
|
||||
let rebinds = false
|
||||
const ruleSurfaces: string[] = []
|
||||
for (const [property, value] of rule.declarations) {
|
||||
if (property.startsWith(INDIRECTION_PREFIX) && file !== fileURLToPath(new URL('scrollbar.css', STYLES))) rebinds = true
|
||||
if (OVERFLOW_PROPERTIES.includes(property) && /\b(?:auto|scroll)\b/.test(value)) surfaces.scrolls = true
|
||||
if (SURFACE_PROPERTIES.includes(property)) ruleSurfaces.push(...varReferences(value))
|
||||
for (const token of varReferences(value)) {
|
||||
if (!token.startsWith(TOKEN_PREFIX)) continue
|
||||
referencedTokens.set(token, [...referencedTokens.get(token) ?? [], file])
|
||||
}
|
||||
}
|
||||
for (const token of ruleSurfaces) {
|
||||
if (elevatedSurfaces.has(token)) surfaces.elevated.add(token)
|
||||
}
|
||||
if (rebinds) {
|
||||
rebindRules.push({ file, rule })
|
||||
surfaces.rebinds = true
|
||||
}
|
||||
}
|
||||
sheetSurfaces.set(file, surfaces)
|
||||
}
|
||||
|
||||
describe('design-platform.css scrollbar tokens', () => {
|
||||
it('defines the same scrollbar token set in the light and the dark block', () => {
|
||||
// A token present only in the light block silently keeps its light value
|
||||
// under the dark palette, since the dark block only overrides.
|
||||
expect(allTokens.size).toBeGreaterThan(0)
|
||||
expect(sorted(lightTokens)).toEqual(sorted(allTokens))
|
||||
expect(sorted(darkTokens)).toEqual(sorted(allTokens))
|
||||
})
|
||||
|
||||
it('resolves every scrollbar token to a static scale value, not to another alias', () => {
|
||||
// The alias layer is the only indirection in the token sheet: an alias
|
||||
// pointing at a second alias makes the dark override order-dependent.
|
||||
for (const rule of platformRules) {
|
||||
for (const [property, value] of rule.declarations) {
|
||||
if (!property.startsWith(TOKEN_PREFIX)) continue
|
||||
for (const reference of varReferences(value)) {
|
||||
expect(reference, `${property}: ${value}`).toMatch(/^--dsw-static-/)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('scrollbar token consumers', () => {
|
||||
it('every defined scrollbar token is referenced by some package stylesheet', () => {
|
||||
// Before scrollbar.css existed these tokens had no consumer at all and
|
||||
// every scroll container rendered the unthemed UA bar. A fifth token, or a
|
||||
// rename on one side only, leaves the new name unreferenced here.
|
||||
expect(sorted(referencedTokens.keys())).toEqual(sorted(allTokens))
|
||||
})
|
||||
|
||||
it('every referenced scrollbar token is defined in design-platform.css', () => {
|
||||
// A dangling var() renders the UA default instead of failing loudly, so a
|
||||
// rename has to move the reference and the definition together.
|
||||
for (const [token, files] of referencedTokens) {
|
||||
expect(allTokens, files.join(', ')).toContain(token)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('scrollbar.css base-surface binding', () => {
|
||||
const rendered = tokensRendered(scrollbarRules)
|
||||
|
||||
it('renders the l1 pair through the rebindable indirection', () => {
|
||||
// l1 is the base-surface default the indirection resolves to; the
|
||||
// indirection only counts as bound when a rendering declaration reads it.
|
||||
expect(rendered).toContain(`${TOKEN_PREFIX}bg-l1`)
|
||||
expect(rendered).toContain(`${TOKEN_PREFIX}hover-l1`)
|
||||
})
|
||||
|
||||
it('routes the standard property and the WebKit thumb through the same indirection', () => {
|
||||
// A rebind on an elevated container has to move the Firefox and the WebKit
|
||||
// rendering together, which only holds while both read the same variable.
|
||||
const declaration = (property: string, selectorPart: string): string | undefined => scrollbarRules
|
||||
.filter(rule => rule.selectors.includes(selectorPart))
|
||||
.flatMap(rule => rule.declarations)
|
||||
.findLast(([name]) => name === property)?.[1]
|
||||
const thumbColor = declaration('scrollbar-color', 'body')
|
||||
expect(thumbColor).toBeDefined()
|
||||
const indirection = varReferences(thumbColor!)[0]
|
||||
expect(indirection).toBe(`${INDIRECTION_PREFIX}thumb`)
|
||||
expect(varReferences(declaration('background', '::-webkit-scrollbar-thumb')!)).toEqual([indirection])
|
||||
})
|
||||
})
|
||||
|
||||
describe('scrollbar.css selectors', () => {
|
||||
const scrollbarColorSelectors = scrollbarRules
|
||||
.filter(rule => rule.declarations.some(([property]) => property === 'scrollbar-color'))
|
||||
.flatMap(rule => rule.selectors)
|
||||
|
||||
it('declares scrollbar-color only where the body-scoped tokens are visible', () => {
|
||||
// design-platform.css defines the alias tokens on `body`, and custom
|
||||
// properties inherit downward only: the same declaration on `html` or
|
||||
// `:root` resolves to the guaranteed-invalid value, which computes
|
||||
// scrollbar-color to `auto` and drops the theming entirely.
|
||||
expect(scrollbarColorSelectors.length).toBeGreaterThan(0)
|
||||
for (const selector of scrollbarColorSelectors) {
|
||||
expect(selector, selector).toMatch(/^body\b/)
|
||||
}
|
||||
})
|
||||
|
||||
it('defines the indirection where the alias tokens are visible', () => {
|
||||
const definesIndirection = ([property, value]: [string, string]): boolean =>
|
||||
property.startsWith(INDIRECTION_PREFIX) && value.includes(TOKEN_PREFIX)
|
||||
const hosts = scrollbarRules
|
||||
.filter(rule => rule.declarations.some(definesIndirection))
|
||||
.flatMap(rule => rule.selectors)
|
||||
expect(hosts.length).toBeGreaterThan(0)
|
||||
for (const selector of hosts) expect(selector, selector).toMatch(/^body\b/)
|
||||
})
|
||||
|
||||
it('re-declares the scrollbar properties per element rather than inheriting them', () => {
|
||||
// scrollbar-width is not an inherited property, and an inherited
|
||||
// scrollbar-color carries the colour already substituted at `body`, which
|
||||
// a descendant rebinding the indirection could no longer change.
|
||||
expect(scrollbarColorSelectors).toContain('body *')
|
||||
const widthSelectors = scrollbarRules
|
||||
.filter(rule => rule.declarations.some(([property]) => property === 'scrollbar-width'))
|
||||
.flatMap(rule => rule.selectors)
|
||||
expect(widthSelectors).toContain('body *')
|
||||
})
|
||||
})
|
||||
|
||||
describe('scrollbar.css rendering paths', () => {
|
||||
/** The gate prelude, spelled exactly as the sheet must spell it for the split to exist. */
|
||||
const GATE = '@supports not selector(::-webkit-scrollbar)'
|
||||
const withoutComments = scrollbarCss.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
||||
const gate = atRuleBlock(withoutComments, GATE)
|
||||
/** Standard scrollbar properties, the ones whose non-`auto` values suppress the pseudo-elements. */
|
||||
const STANDARD_PROPERTIES = ['scrollbar-width', 'scrollbar-color']
|
||||
|
||||
it('gates the standard properties behind the absence of the WebKit pseudo-element', () => {
|
||||
// A non-`auto` scrollbar-width or scrollbar-color makes Chromium and
|
||||
// Safari discard every ::-webkit-scrollbar* rule for that element,
|
||||
// ::-webkit-scrollbar-thumb:hover included. Declaring both paths
|
||||
// unconditionally therefore renders the hover token nowhere: the engines
|
||||
// implementing the hover pseudo-element are exactly the ones the standard
|
||||
// properties silence, and Firefox has no hover pseudo-element at all.
|
||||
expect(gate, GATE).toBeDefined()
|
||||
for (const property of STANDARD_PROPERTIES) {
|
||||
const offsets = [...withoutComments.matchAll(new RegExp(String.raw`(^|[;{\s])${property}\s*:`, 'g'))]
|
||||
.map(match => match.index)
|
||||
expect(offsets.length, property).toBeGreaterThan(0)
|
||||
for (const offset of offsets) {
|
||||
expect(offset, `${property} outside ${GATE}`).toBeGreaterThan(gate!.start)
|
||||
expect(offset, `${property} outside ${GATE}`).toBeLessThan(gate!.end)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves the WebKit pseudo-element rules outside the gate', () => {
|
||||
// Gating these in turn would only restate selector matching: an engine
|
||||
// without the pseudo-elements drops the rules as unknown selectors. Inside
|
||||
// the gate they would be dropped by the engines that do implement them,
|
||||
// which is every engine that can render them.
|
||||
const offsets = [...withoutComments.matchAll(/::-webkit-scrollbar/g)]
|
||||
.map(match => match.index)
|
||||
.filter(offset => withoutComments.slice(offset).search(/^[\w:-]*\s*[,{]/) === 0)
|
||||
expect(offsets.length).toBeGreaterThan(0)
|
||||
for (const offset of offsets) {
|
||||
expect(offset > gate!.start && offset < gate!.end, `::-webkit-scrollbar rule inside ${GATE}`).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('renders the hover token only through the pseudo-element path', () => {
|
||||
// The standard path has no hover counterpart — scrollbar-color states one
|
||||
// thumb colour and the engine derives its own hover treatment — so the
|
||||
// hover indirection has to be read outside the gate or it renders nowhere.
|
||||
const hoverOffsets = [...withoutComments.matchAll(new RegExp(String.raw`var\(\s*${INDIRECTION_PREFIX}thumb-hover`, 'g'))]
|
||||
.map(match => match.index)
|
||||
expect(hoverOffsets.length).toBeGreaterThan(0)
|
||||
for (const offset of hoverOffsets) {
|
||||
expect(offset > gate!.start && offset < gate!.end, 'hover indirection read inside the gate').toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('elevated surface rebinds', () => {
|
||||
it('at least one surface rebinds the indirection', () => {
|
||||
expect(rebindRules.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('each rebinding rule sets the thumb and the hover variable together', () => {
|
||||
// A surface rebinding only the resting colour keeps the l1 hover colour,
|
||||
// so the elevation is wrong only while the pointer is over the thumb.
|
||||
for (const { file, rule } of rebindRules) {
|
||||
const properties = rule.declarations.map(([property]) => property).filter(property => property.startsWith(INDIRECTION_PREFIX))
|
||||
expect(sorted(properties), `${file} ${rule.selectors.join(', ')}`).toEqual([
|
||||
`${INDIRECTION_PREFIX}thumb-hover`, `${INDIRECTION_PREFIX}thumb`,
|
||||
].sort())
|
||||
}
|
||||
})
|
||||
|
||||
it('each rebinding rule binds the indirection names scrollbar.css renders', () => {
|
||||
// A misspelled property name declares an unused variable, and the surface
|
||||
// silently keeps the base-surface colour.
|
||||
const rendered = new Set(
|
||||
scrollbarRules
|
||||
.flatMap(rule => rule.declarations)
|
||||
.filter(([property]) => !property.startsWith('--'))
|
||||
.flatMap(([, value]) => varReferences(value))
|
||||
.filter(name => name.startsWith(INDIRECTION_PREFIX)),
|
||||
)
|
||||
for (const { file, rule } of rebindRules) {
|
||||
for (const [property] of rule.declarations) {
|
||||
if (property.startsWith(INDIRECTION_PREFIX)) expect(rendered, `${file}: ${property}`).toContain(property)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('every rebind targets the l2 elevation pair', () => {
|
||||
for (const { file, rule } of rebindRules) {
|
||||
for (const [property, value] of rule.declarations) {
|
||||
if (!property.startsWith(INDIRECTION_PREFIX)) continue
|
||||
for (const token of varReferences(value)) {
|
||||
expect(token, `${file}: ${property}`).toMatch(/-l2$/)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves the elevated surface set from the palette ladder', () => {
|
||||
// The set has to come from the palette, not from the sheets that happen to
|
||||
// rebind: derived from rebinds it can only confirm what someone already
|
||||
// remembered, and a surface nobody has rebound yet — the case the check
|
||||
// exists for — would define itself as unelevated. Anchoring it here means a
|
||||
// new palette token on an elevated rung is in scope the moment it is
|
||||
// defined. `--dsw-specific-tip` is the regression that proved the point: it
|
||||
// resolves to the same dark rung as the menu surface, and the Todo panel
|
||||
// scrolled on it unrebound while a rebind-derived set stayed green.
|
||||
expect(elevatedSurfaces).toContain('--dsw-alias-bg-layer-2')
|
||||
expect(elevatedSurfaces).toContain('--dsw-alias-bg-layer-3')
|
||||
expect(elevatedSurfaces).toContain('--dsw-specific-menu')
|
||||
expect(elevatedSurfaces).toContain('--dsw-specific-input-major')
|
||||
expect(elevatedSurfaces).toContain('--dsw-specific-tip')
|
||||
// Base surfaces stay out, or every scroll container would be in scope and
|
||||
// the check would say nothing.
|
||||
expect(elevatedSurfaces).not.toContain('--dsw-alias-bg-base')
|
||||
expect(elevatedSurfaces).not.toContain('--dsw-alias-bg-layer-1')
|
||||
})
|
||||
|
||||
it('every sheet that scrolls on an elevated surface rebinds', () => {
|
||||
// The failure this closes: a scroll container on an elevated surface that
|
||||
// nobody remembered to rebind renders the l1 thumb, which differs from l2
|
||||
// only in the dark palette and only for that one surface — invisible both in
|
||||
// review and in a light-palette screenshot. Four sheets shipped that way
|
||||
// (ui-primitives Menu, InputBar, QuestionComposer, TodoPanel) and review
|
||||
// caught them by hand, which is what this replaces.
|
||||
//
|
||||
// Surface-level, not element-level: the elevated card and the descendant
|
||||
// that scrolls are separate rules, and CSS text does not say which contains
|
||||
// which. What keeps that from over-reporting is the token FAMILY: only
|
||||
// `--dsw-alias-bg-*` and `--dsw-specific-*` name a surface, so a floating
|
||||
// button or an inline code span reaching the same rung is out of scope
|
||||
// (ChatView's `.toBottom`, CodeBlock's banner). Geometry cannot make that
|
||||
// call — a floating button carries a radius, a shadow, and a fixed size.
|
||||
for (const [file, surfaces] of sheetSurfaces) {
|
||||
if (!surfaces.scrolls || surfaces.rebinds) continue
|
||||
expect([...surfaces.elevated], `${file} scrolls on an elevated surface without rebinding`).toEqual([])
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -77,6 +77,7 @@ function standaloneProps(nodes: ConversationSnapshot['nodes']): ConvViewProps {
|
||||
useSession: fakeSession(nodes).useSession,
|
||||
useSessions: emptySessions(),
|
||||
useWorkspaces: emptyWorkspaces(),
|
||||
useProjection: (() => undefined) as never,
|
||||
} as unknown as ConvViewProps
|
||||
}
|
||||
|
||||
@@ -135,6 +136,7 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
|
||||
useSession={useSession}
|
||||
useSessions={emptySessions()}
|
||||
useWorkspaces={emptyWorkspaces()}
|
||||
useProjection={(() => undefined)}
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
renderSlot={renderSlot}
|
||||
@@ -330,6 +332,7 @@ describe('deriveSubSpans (waterfall lanes)', () => {
|
||||
useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot>,
|
||||
useSessions: emptySessions(),
|
||||
useWorkspaces: emptyWorkspaces(),
|
||||
useProjection: (() => undefined) as never,
|
||||
} as unknown as ConvViewProps
|
||||
const view = render(createElement(WaterfallView as FC<ConvViewProps>, props))
|
||||
const lane = view.container.querySelector('[data-subspan]')
|
||||
@@ -356,6 +359,7 @@ describe('deriveSubSpans (waterfall lanes)', () => {
|
||||
useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot>,
|
||||
useSessions: emptySessions(),
|
||||
useWorkspaces: emptyWorkspaces(),
|
||||
useProjection: (() => undefined) as never,
|
||||
} as unknown as ConvViewProps
|
||||
const view = render(createElement(WaterfallView as FC<ConvViewProps>, props))
|
||||
const bar = view.container.querySelector('[data-timing="unknown"]')
|
||||
|
||||
@@ -208,6 +208,13 @@
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 12px;
|
||||
/* Row trailing content (the relative time, and the hover action buttons
|
||||
that replace it) sits flush against the row's 8px right padding, so an
|
||||
overlaid scrollbar covers it. Reserving the gutter keeps the bar beside
|
||||
the rows instead of on top of them; `stable` holds the reservation when
|
||||
the list is short enough not to scroll, so expanding a group does not
|
||||
shift every row left. */
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
/* One workspace section: header row + expanded session run. Rows inside
|
||||
|
||||
48
packages/client/ui-workspace/tests/browser-styles.spec.ts
Normal file
48
packages/client/ui-workspace/tests/browser-styles.spec.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* WorkspaceBrowser scroll-region style contract, asserted against the CSS text
|
||||
* on disk: the session list reserves its scrollbar gutter so the scrollbar
|
||||
* cannot overlay row trailing content, and reserves it whether or not the list
|
||||
* currently overflows so expanding a group does not shift rows sideways.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const css = readFileSync(fileURLToPath(new URL('../src/client/WorkspaceBrowser.module.css', import.meta.url)), 'utf8')
|
||||
|
||||
/**
|
||||
* Declarations of one class rule, keyed by property with whitespace collapsed.
|
||||
* Declaration order and trailing semicolons are normalized away.
|
||||
* @param className - local class name, without the leading dot.
|
||||
* @returns the rule's declarations, or undefined when no such rule exists.
|
||||
*/
|
||||
function declarations(className: string): Map<string, string> | undefined {
|
||||
const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
||||
const match = new RegExp(String.raw`(^|[\s,}])\.${className}\s*\{([^{}]*)\}`).exec(withoutComments)
|
||||
if (match === null) return undefined
|
||||
const found = new Map<string, string>()
|
||||
// The body group is unconditional in the pattern; the fallback only satisfies
|
||||
// noUncheckedIndexedAccess.
|
||||
for (const part of (match[2] ?? '').split(';')) {
|
||||
const colon = part.indexOf(':')
|
||||
if (colon === -1) continue
|
||||
found.set(part.slice(0, colon).trim(), part.slice(colon + 1).trim().replace(/\s+/g, ' '))
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
describe('WorkspaceBrowser.module.css list', () => {
|
||||
const list = declarations('list')
|
||||
|
||||
it('is the scrolling region', () => {
|
||||
expect(list).toBeDefined()
|
||||
expect(list!.get('overflow-y')).toBe('auto')
|
||||
})
|
||||
|
||||
it('reserves the scrollbar gutter unconditionally', () => {
|
||||
// Row trailing content sits flush against the row's right padding, so an
|
||||
// overlay scrollbar covers it. `stable` keeps the reservation when the list
|
||||
// is short enough not to scroll, so expanding a group does not shift rows.
|
||||
expect(list!.get('scrollbar-gutter')).toBe('stable')
|
||||
})
|
||||
})
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import {
|
||||
HostContext, SessionMaybeProvider, SessionProvider, SlotAssemblyError, maybeObservableHook,
|
||||
observableHook, useHost, useSessionMaybeProvideInfo,
|
||||
observableHook, projectionHook, useHost, useSessionMaybeProvideInfo,
|
||||
} from './session-provider.tsx'
|
||||
|
||||
type InjectedProps = Record<string, unknown>
|
||||
@@ -238,6 +238,9 @@ function standardKit(
|
||||
}
|
||||
Object.assign(kit, info.props)
|
||||
kit['sessionId'] = info.sessionId
|
||||
// The useProjection seat (fifth framework hook): key-addressed cell
|
||||
// reader, bound per provide bundle (cached by info identity).
|
||||
kit['useProjection'] = projectionHook(info)
|
||||
}
|
||||
const store = scope === 'session-maybe' && info?.sessionId === undefined
|
||||
? undefined
|
||||
|
||||
@@ -83,6 +83,39 @@ function useAbsentSnapshot<S>(_selector: (snapshot: never) => S, _equal?: (a: S,
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The useProjection framework seat (session-projection RFC), one bound
|
||||
* function per provide bundle (cached by info identity — components may hold
|
||||
* it across renders). Key-addressed: the key resolves a per-session value
|
||||
* face off the projection store; the bound selector hook comes from the same
|
||||
* per-source cache as every other kit hook, so exactly one uSES subscription
|
||||
* runs per call and the subscribe reference stays stable per key. A key no
|
||||
* baseline or frame has carried (or a no-session bundle) reads `undefined` —
|
||||
* capability absence — keeping the hook order constant.
|
||||
*/
|
||||
export function projectionHook(info: SessionMaybeProvideInfo): (
|
||||
key: string, selector?: (value: unknown) => unknown, eq?: (a: unknown, b: unknown) => boolean,
|
||||
) => unknown {
|
||||
let hook = projectionHookCache.get(info)
|
||||
if (hook === undefined) {
|
||||
hook = (key, selector, eq) => {
|
||||
// The no-session (faceless) branch binds the shared absent source so
|
||||
// the caller's selector still runs over `undefined` (absence flows
|
||||
// through the selector) and the uSES call count stays constant.
|
||||
const useValue = observableHook(info.projections?.faceOf(key) ?? absentSource)
|
||||
// Whole values are finished wire payloads (reference changes only when
|
||||
// a frame or baseline lands), so the identity selector needs no
|
||||
// equality function.
|
||||
return useValue(selector ?? (value => value), eq)
|
||||
}
|
||||
projectionHookCache.set(info, hook)
|
||||
}
|
||||
return hook
|
||||
}
|
||||
const projectionHookCache = new WeakMap<SessionMaybeProvideInfo, (
|
||||
key: string, selector?: (value: unknown) => unknown, eq?: (a: unknown, b: unknown) => boolean,
|
||||
) => unknown>()
|
||||
|
||||
/**
|
||||
* Root-level binding provider. It follows current selection without a key, so
|
||||
* session-maybe entries retain their React identity while the context value
|
||||
|
||||
128
packages/client/web-react/tests/use-projection.spec.tsx
Normal file
128
packages/client/web-react/tests/use-projection.spec.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* useProjection standard-kit delivery (session-projection RFC): the fifth
|
||||
* framework hook seat rides the same provide channel as useSession — a
|
||||
* session slot component receives `useProjection` in its kit, key-addressed
|
||||
* over the bundle's projection face; unresolved keys (no value, no face, no
|
||||
* session) uniformly read `undefined`; live value changes re-render; the
|
||||
* selector overload runs over the whole value.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { act, render } from '@testing-library/react'
|
||||
import type { SessionMaybeProvideInfo, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { createSlotRenderer, type SlotRendererHost } from '@deepseek-ai/dsh-client-web-react'
|
||||
|
||||
function observable<T>(initial: T) {
|
||||
let value = initial
|
||||
const subs = new Set<() => void>()
|
||||
return {
|
||||
getSnapshot: () => value,
|
||||
subscribe: (fn: () => void) => { subs.add(fn); return () => { subs.delete(fn) } },
|
||||
set: (next: T) => { value = next; for (const fn of [...subs]) fn() },
|
||||
}
|
||||
}
|
||||
|
||||
type UseProjectionProp = (key: string, selector?: (v: unknown) => unknown) => unknown
|
||||
|
||||
function makeHost() {
|
||||
const absentInfo: SessionMaybeProvideInfo = { sessionId: undefined, hooks: { session: undefined }, props: {} }
|
||||
const provide = observable<SessionMaybeProvideInfo>(absentInfo)
|
||||
const cells = new Map<string, ReturnType<typeof observable<unknown>>>()
|
||||
/** Store-parallel face: always defined per key; an unseen key snapshots undefined. */
|
||||
const absent = { getSnapshot: () => undefined, subscribe: () => () => {} }
|
||||
const sessionEntries: StoredEntry[] = []
|
||||
let withFace = true
|
||||
const rootEntry: StoredEntry = {
|
||||
component: (props: { renderSlot: (key: string, owner: object) => React.ReactNode }) =>
|
||||
<>{props.renderSlot('k.session', {})}</>,
|
||||
options: {},
|
||||
children: { 'k.session': { kind: 'single', scope: 'session' } },
|
||||
}
|
||||
const info = (id: string): SessionMaybeProvideInfo => ({
|
||||
sessionId: id,
|
||||
hooks: { session: { getSnapshot: () => ({ sid: id }), subscribe: () => () => {} } },
|
||||
props: {},
|
||||
...(withFace ? { projections: { faceOf: (key: string) => cells.get(key) ?? absent } } : {}),
|
||||
})
|
||||
const host: SlotRendererHost = {
|
||||
subscribe: () => () => {},
|
||||
getVersion: () => 0,
|
||||
entriesOf: key => key === 'root' ? [rootEntry] : sessionEntries,
|
||||
specOf: key => key === 'k.session' ? { kind: 'single', scope: 'session' } : undefined,
|
||||
isLive: () => true,
|
||||
storeOf: () => undefined,
|
||||
sessions: {
|
||||
list: observable<unknown>({ ids: [] }),
|
||||
provideInfo: provide,
|
||||
},
|
||||
workspaces: { list: observable<unknown>({ items: [] }) },
|
||||
}
|
||||
return {
|
||||
host,
|
||||
cells,
|
||||
// Same driver surface as before the atomic provide source: set(id)
|
||||
// publishes the resolved bundle (or the absent projection) through it.
|
||||
current: { set: (id: string | undefined) => { provide.set(id === undefined ? absentInfo : info(id)) } },
|
||||
dropFace: () => { withFace = false },
|
||||
registerSession: (entry: StoredEntry) => { sessionEntries.push(entry) },
|
||||
}
|
||||
}
|
||||
|
||||
describe('useProjection standard-kit delivery', () => {
|
||||
it('reads the projected value through the kit, undefined for unresolved keys, and follows live changes', () => {
|
||||
const h = makeHost()
|
||||
const cell = observable<unknown>({ marks: ['a'] })
|
||||
h.cells.set('test/marks', cell)
|
||||
const reads: Record<string, unknown>[] = []
|
||||
h.registerSession({
|
||||
component: (props: { useProjection: UseProjectionProp }) => {
|
||||
reads.push({
|
||||
marks: props.useProjection('test/marks'),
|
||||
ghost: props.useProjection('test/ghost'),
|
||||
})
|
||||
return null
|
||||
},
|
||||
options: {},
|
||||
})
|
||||
render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
|
||||
act(() => { h.current.set('s1') })
|
||||
expect(reads.at(-1)).toEqual({ marks: { marks: ['a'] }, ghost: undefined })
|
||||
// Live change re-renders with the new whole value.
|
||||
act(() => { cell.set({ marks: ['a', 'b'] }) })
|
||||
expect(reads.at(-1)).toEqual({ marks: { marks: ['a', 'b'] }, ghost: undefined })
|
||||
})
|
||||
|
||||
it('runs the selector overload over the whole value (and over undefined when absent)', () => {
|
||||
const h = makeHost()
|
||||
h.cells.set('test/marks', observable<unknown>({ marks: ['x', 'y'] }))
|
||||
const reads: unknown[] = []
|
||||
h.registerSession({
|
||||
component: (props: { useProjection: UseProjectionProp }) => {
|
||||
reads.push(props.useProjection('test/marks', v => (v as { marks: string[] } | undefined)?.marks.length ?? -1))
|
||||
reads.push(props.useProjection('test/ghost', v => (v === undefined ? 'absent' : 'present')))
|
||||
return null
|
||||
},
|
||||
options: {},
|
||||
})
|
||||
render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
|
||||
act(() => { h.current.set('s1') })
|
||||
expect(reads.slice(-2)).toEqual([2, 'absent'])
|
||||
})
|
||||
|
||||
it('treats a bundle without the projections face as all-absent (capability absence)', () => {
|
||||
const h = makeHost()
|
||||
h.cells.set('test/marks', observable<unknown>({ marks: ['a'] }))
|
||||
h.dropFace()
|
||||
const reads: unknown[] = []
|
||||
h.registerSession({
|
||||
component: (props: { useProjection: UseProjectionProp }) => {
|
||||
reads.push(props.useProjection('test/marks'))
|
||||
return null
|
||||
},
|
||||
options: {},
|
||||
})
|
||||
render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
|
||||
act(() => { h.current.set('s1') })
|
||||
expect(reads.at(-1)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,10 @@
|
||||
/* Shell-owned global base: full-height mount plus the theme token sheets.
|
||||
* The four ui-theme sheets are the sole token source (--dsw-*); the shell
|
||||
* links them here so tokens exist before any plugin CSS lands. */
|
||||
* The five ui-theme sheets are the sole token source (--dsw-*); the shell
|
||||
* links them here so tokens exist before any plugin CSS lands. scrollbar.css
|
||||
* follows design-platform.css because it reads that sheet's tokens. */
|
||||
@import '@deepseek-ai/dsh-client-ui-theme/styles/base.css';
|
||||
@import '@deepseek-ai/dsh-client-ui-theme/styles/design-platform.css';
|
||||
@import '@deepseek-ai/dsh-client-ui-theme/styles/scrollbar.css';
|
||||
@import '@deepseek-ai/dsh-client-ui-theme/styles/gradient-shadow-text.css';
|
||||
@import '@deepseek-ai/dsh-client-ui-theme/styles/shiki.css';
|
||||
|
||||
|
||||
58
packages/client/web/tests/base-styles.spec.ts
Normal file
58
packages/client/web/tests/base-styles.spec.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Shell base sheet contract, asserted against the CSS text on disk: base.css is
|
||||
* where the ui-theme token sheets enter the bundle, every sheet it names exists,
|
||||
* and scrollbar.css follows design-platform.css because it reads that sheet's
|
||||
* tokens.
|
||||
*/
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const THEME_PACKAGE = '@deepseek-ai/dsh-client-ui-theme'
|
||||
const baseCss = readFileSync(fileURLToPath(new URL('../src/base.css', import.meta.url)), 'utf8')
|
||||
|
||||
/**
|
||||
* Import specifiers of the sheet, in source order. Quote style and surrounding
|
||||
* whitespace are normalized away.
|
||||
* @param css - stylesheet text.
|
||||
* @returns each `@import` target in the order the sheet lists it.
|
||||
*/
|
||||
function importOrder(css: string): string[] {
|
||||
// The destructuring default only satisfies noUncheckedIndexedAccess; the
|
||||
// group is unconditional in the pattern.
|
||||
return [...css.matchAll(/@import\s+['"]([^'"]+)['"]/g)].map(([, specifier = '']) => specifier)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a `<package>/styles/<file>` specifier to its path in the workspace.
|
||||
* The theme package maps `./styles/*` to `./src/styles/*`, so the sheets stay
|
||||
* on the source plane rather than needing a build.
|
||||
* @param specifier - import specifier from base.css.
|
||||
* @returns absolute path of the file the specifier names.
|
||||
*/
|
||||
function resolveThemeSheet(specifier: string): string {
|
||||
const name = specifier.slice(`${THEME_PACKAGE}/styles/`.length)
|
||||
return fileURLToPath(new URL(`../../ui-theme/src/styles/${name}`, import.meta.url))
|
||||
}
|
||||
|
||||
const imports = importOrder(baseCss)
|
||||
|
||||
describe('web shell base.css', () => {
|
||||
it('imports every sheet from the theme package and each one exists', () => {
|
||||
expect(imports.length).toBeGreaterThan(0)
|
||||
for (const specifier of imports) {
|
||||
expect(specifier.startsWith(`${THEME_PACKAGE}/styles/`), specifier).toBe(true)
|
||||
expect(existsSync(resolveThemeSheet(specifier)), specifier).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('imports the scrollbar sheet after the token sheet it reads', () => {
|
||||
// Both sheets bind on `body`, so with scrollbar.css first the alias tokens
|
||||
// would still resolve; the order encodes the dependency direction so a
|
||||
// later specificity or selector change cannot silently invert it.
|
||||
const platform = imports.indexOf(`${THEME_PACKAGE}/styles/design-platform.css`)
|
||||
const scrollbar = imports.indexOf(`${THEME_PACKAGE}/styles/scrollbar.css`)
|
||||
expect(platform).toBeGreaterThanOrEqual(0)
|
||||
expect(scrollbar).toBeGreaterThan(platform)
|
||||
})
|
||||
})
|
||||
@@ -241,8 +241,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
jsDoc: '/**\n * Resolve one effective command definition.\n * @param agent - exact receiving agent and scoped-layer key.\n * @param name - command name without a slash.\n * @returns the scoped shadow or global definition.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise<CommandResult | undefined>',
|
||||
jsDoc: '/**\n * Parse and execute a known command without sending it to the model.\n * @param agent - exact receiving agent.\n * @param line - complete slash-command line.\n * @param signal - cancellation signal owned by the UI request.\n * @returns a detached result, or `undefined` when syntax or name does not resolve.\n */',
|
||||
signature: 'async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise<CommandExecution | undefined>',
|
||||
jsDoc: '/**\n * Parse and execute a known command without sending it to the model.\n *\n * A resolved command\'s lifecycle is logged: `command/run` is appended\n * before the handler is invoked and `command/done` after settlement (a\n * thrown or aborted handler settles as `kind: \'error\'`). Both are direct\n * log-only appends — no turn wraps them, and persistence drains them at\n * ordinary checkpoints. Admission misses (syntax or unknown name) log\n * nothing — they never entered a handler. A `command/run` append failure\n * fails the execution loud; a `command/done` append failure on the\n * handler-failure path is contained so the handler\'s own error stays the\n * reported failure.\n *\n * @param agent - exact receiving agent.\n * @param line - complete slash-command line.\n * @param signal - cancellation signal owned by the UI request.\n * @returns the settled execution (result + lifecycle pairing id), or\n * `undefined` when syntax or name does not resolve.\n */',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -534,6 +534,24 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'sessionProjections',
|
||||
summary: '`ctx.sessionProjections`: the projection unit table and its drive.',
|
||||
methods: [
|
||||
{
|
||||
signature: 'register<K extends keyof SessionProjectionMap, S>(definition: ProjectionDefinition<K, S>): () => void',
|
||||
jsDoc: '/**\n * Register one domain\'s unit. The registration is an effect on the calling\n * context\'s fiber: disposing the fiber (or calling the returned disposer)\n * removes the key — and the unit\'s cached cells — from subsequent drives\n * and snapshots.\n * @param definition - key, boundary schema, pure unit functions, and stateVersion.\n * @returns the exact disposer that unregisters this unit.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'onChanged(listener: ProjectionChangeListener): () => void',
|
||||
jsDoc: '/**\n * Subscribe to the change feed. The registration is an effect on the\n * calling context\'s fiber.\n * @param listener - called once per unit whose state reference changed, per committed event.\n * @returns the exact disposer that unsubscribes.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'snapshot(session: Session): ProjectionSnapshot',
|
||||
jsDoc: '/**\n * One consistent cut over every registered unit for one session, read from\n * the watermark cache (missing cells fold lazily over the in-memory log).\n * Fully synchronous — every value and `asOfSeq` reflect the same log\n * position. Each value passes its unit\'s schema before leaving.\n * @param session - the session whose projection values are read.\n * @returns the snapshot; `values` is empty when no unit is registered.\n */',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'sessionQuery',
|
||||
summary: 'Unified live-preferred session query service.',
|
||||
@@ -1375,7 +1393,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'AgentOptions',
|
||||
declaration: 'export interface AgentOptions {\n provider?: string;\n model?: string;\n}',
|
||||
declaration: 'export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n}',
|
||||
},
|
||||
{
|
||||
name: 'AgentStatus',
|
||||
@@ -1517,6 +1535,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'CommandDescriptor',
|
||||
declaration: 'export interface CommandDescriptor {\n readonly name: string;\n readonly description: string;\n readonly input?: CommandInputDescriptor;\n}',
|
||||
},
|
||||
{
|
||||
name: 'CommandExecution',
|
||||
declaration: 'export interface CommandExecution {\n readonly commandId: CommandId;\n readonly result: CommandResult;\n}',
|
||||
},
|
||||
{
|
||||
name: 'CommandId',
|
||||
declaration: 'export type CommandId = Branded<\'CommandId\'>;',
|
||||
},
|
||||
{
|
||||
name: 'CommandInputDescriptor',
|
||||
declaration: 'export interface CommandInputDescriptor {\n readonly hint: string;\n}',
|
||||
@@ -1817,6 +1843,18 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'PresetSpec',
|
||||
declaration: 'export interface PresetSpec {\n sandbox: SandboxMode;\n approval: ApprovalPolicy;\n name?: string;\n description?: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ProjectionChangeListener',
|
||||
declaration: 'export type ProjectionChangeListener = (session: Session, key: Extract<keyof SessionProjectionMap, string>, value: unknown, seq: number) => void;',
|
||||
},
|
||||
{
|
||||
name: 'ProjectionDefinition',
|
||||
declaration: 'export interface ProjectionDefinition<K extends keyof SessionProjectionMap, S> {\n key: K;\n schema: ZodType<SessionProjectionMap[K]>;\n init(): S;\n apply(state: S, event: SessionEvent): S;\n view(state: S): SessionProjectionMap[K];\n stateVersion: number;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ProjectionSnapshot',
|
||||
declaration: 'export interface ProjectionSnapshot {\n asOfSeq: number;\n values: Partial<SessionProjectionMap>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'PromptAssembly',
|
||||
declaration: 'export interface PromptAssembly {\n sections: AssembledSection[];\n tools: ToolSchema[];\n variables: Record<string, string | undefined>;\n}',
|
||||
@@ -2085,6 +2123,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'SessionPersistenceSnapshot',
|
||||
declaration: 'export interface SessionPersistenceSnapshot {\n header: SessionHeader;\n revision: SessionPersistenceRevision;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SessionProjectionMap',
|
||||
declaration: 'export interface SessionProjectionMap {\n}',
|
||||
},
|
||||
{
|
||||
name: 'SessionRecord',
|
||||
declaration: 'export interface SessionRecord {\n header: SessionHeader;\n live: boolean;\n persisted: boolean;\n}',
|
||||
|
||||
@@ -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/core/agent-loop/README.md
|
||||
README.md: c12140f27aed400b0f7b4246700473e877d37632
|
||||
README.zh.md: 6394cd86f5f3241be07ef711c76079624bce1bfe
|
||||
README.md: cd8b76a0d4f181bdf4620a4a17c9725ff1132c3b
|
||||
README.zh.md: d145dc785da8d78d24bc6bc014fb04ba005149fb
|
||||
|
||||
@@ -42,13 +42,14 @@ interface Config {
|
||||
id: string // required
|
||||
provider?: string
|
||||
model?: string
|
||||
maxTokens?: number // positive per-request output-token cap
|
||||
resumeSessionId?: string // load this persisted session instead of creating one
|
||||
cwd?: string // optional workspace cwd for the fresh session
|
||||
}>
|
||||
}
|
||||
```
|
||||
|
||||
Configured agents start automatically. A model call requires both `provider` and `model`; `agent/request` may supply a missing pair before dispatch. `maxParallelToolCalls` bounds every agent's rolling pool for parallel-safe calls and defaults to `10`. `cwd` applies only to fresh sessions, while `resumeSessionId` retains persisted metadata. Configured agents use the deployment persona, and programmatic setup can shadow it per agent. This plugin supplies the per-agent `provider`, `model`, and `cwd` prompt variables; harness identity and deployment persona belong to `dsh-system-prompt`.
|
||||
Configured agents start automatically. A model call requires both `provider` and `model`; `agent/request` may supply a missing pair before dispatch. An optional positive `maxTokens` seeds each conversation request's output cap and is logged in its request header. `maxParallelToolCalls` bounds every agent's rolling pool for parallel-safe calls and defaults to `10`. `cwd` applies only to fresh sessions, while `resumeSessionId` retains persisted metadata. Configured agents use the deployment persona, and programmatic setup can shadow it per agent. This plugin supplies the per-agent `provider`, `model`, and `cwd` prompt variables; harness identity and deployment persona belong to `dsh-system-prompt`.
|
||||
|
||||
### Internal concrete driver
|
||||
|
||||
|
||||
@@ -42,13 +42,14 @@ interface Config {
|
||||
id: string // required
|
||||
provider?: string
|
||||
model?: string
|
||||
maxTokens?: number // positive per-request output-token cap
|
||||
resumeSessionId?: string // load this persisted session instead of creating one
|
||||
cwd?: string // optional workspace cwd for the fresh session
|
||||
}>
|
||||
}
|
||||
```
|
||||
|
||||
通过配置创建的 agent 会自动启动。模型调用同时需要 `provider` 和 `model`;`agent/request` 可以在分发前补齐缺失的这一对值。`maxParallelToolCalls` 限制每个 agent 针对并行安全调用使用的滚动池,默认值为 `10`。`cwd` 仅应用于全新会话,而 `resumeSessionId` 保留持久化元数据。通过配置创建的 agent 使用部署 persona;编程式 setup 可以按 agent 遮蔽它。该插件提供逐 agent 的 `provider`、`model` 和 `cwd` 提示词变量;harness 身份与部署 persona 属于 `dsh-system-prompt`。
|
||||
通过配置创建的 agent 会自动启动。模型调用同时需要 `provider` 和 `model`;`agent/request` 可以在分发前补齐缺失的这一对值。可选的正整数 `maxTokens` 会为每次对话请求提供初始输出上限,并记录在请求 header 中。`maxParallelToolCalls` 限制每个 agent 针对并行安全调用使用的滚动池,默认值为 `10`。`cwd` 仅应用于全新会话,而 `resumeSessionId` 保留持久化元数据。通过配置创建的 agent 使用部署 persona;编程式 setup 可以按 agent 遮蔽它。该插件提供逐 agent 的 `provider`、`model` 和 `cwd` 提示词变量;harness 身份与部署 persona 属于 `dsh-system-prompt`。
|
||||
|
||||
### 包内部实体驱动器
|
||||
|
||||
|
||||
@@ -577,11 +577,16 @@ export class ReactLoopAgent implements Agent {
|
||||
&& persistedConfig.model === route.model
|
||||
? persistedConfig.reasoningEffort
|
||||
: undefined
|
||||
const maxTokens = this.options.maxTokens
|
||||
const seedConfig = deepFreeze(structuredClone(
|
||||
this.requestHeaderLogged
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- the instance logged the header it now folds
|
||||
? persistedConfig!
|
||||
: { ...route, ...reasoningEffort === undefined ? {} : { reasoningEffort } },
|
||||
: {
|
||||
...route,
|
||||
...reasoningEffort === undefined ? {} : { reasoningEffort },
|
||||
...maxTokens === undefined ? {} : { maxTokens },
|
||||
},
|
||||
))
|
||||
const proposedConfig = await this.loopCtx.waterfall(
|
||||
agentCarrier(this), 'agent/request', this, turn, step, signal,
|
||||
|
||||
@@ -112,6 +112,14 @@ function resolveMaxParallelToolCalls(value: number | undefined): number {
|
||||
return maxParallelToolCalls
|
||||
}
|
||||
|
||||
/** Reject an output-token cap that cannot be represented exactly on the request wire. */
|
||||
function assertAgentOptions(options: AgentOptions): void {
|
||||
if (options.maxTokens !== undefined
|
||||
&& (!Number.isSafeInteger(options.maxTokens) || options.maxTokens <= 0)) {
|
||||
throw new TypeError('agent maxTokens must be a positive safe integer')
|
||||
}
|
||||
}
|
||||
|
||||
/** Prepared-but-unpublished agent resources sharing one memoized teardown. */
|
||||
interface PreparedAgent {
|
||||
agent: ReactLoopAgent
|
||||
@@ -196,6 +204,7 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
sessionId: z.string().min(1),
|
||||
provider: z.string(),
|
||||
model: z.string(),
|
||||
maxTokens: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER),
|
||||
cwd: z.string(),
|
||||
resumeSessionId: z.string(),
|
||||
})).default([]),
|
||||
@@ -327,6 +336,7 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
* fuses caller cancellation with lifecycle teardown for setup awaits.
|
||||
*/
|
||||
private prepare(ownerCtx: Context, id: SessionId, options: AgentOptions, session: Session, callerSignal?: AbortSignal): PreparedAgent {
|
||||
assertAgentOptions(options)
|
||||
ownerCtx.fiber.assertActive()
|
||||
// Every caller reaches prepare() synchronously from a service method
|
||||
// whose Cordis dispatch already requires the live factory fiber, or
|
||||
|
||||
@@ -46,6 +46,19 @@ function send(agent: Agent, text: string) {
|
||||
}
|
||||
|
||||
describe('agent loop', () => {
|
||||
it.each([0, -1, 1.5, Number.NaN, Number.MAX_SAFE_INTEGER + 1])(
|
||||
'rejects invalid AgentOptions.maxTokens %s before publication',
|
||||
async (maxTokens) => {
|
||||
const ctx = await harness(new MockAdapter([]))
|
||||
expect(() => ctx.agentLoop.create(
|
||||
SessionId('invalid-max-tokens'),
|
||||
{ provider: 'mock', model: 'mock', maxTokens },
|
||||
)).toThrow('agent maxTokens must be a positive safe integer')
|
||||
expect(ctx.agents.list()).toEqual([])
|
||||
expect(ctx.sessions.list()).toEqual([])
|
||||
},
|
||||
)
|
||||
|
||||
it('runs a simple turn: queued message → model → idle, with ordered events', async () => {
|
||||
const adapter = new MockAdapter([textResponse('hello there')])
|
||||
const ctx = await harness(adapter)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user