Merge remote-tracking branch 'github/master' into xtr/trajectory-inspection-ui
# Conflicts: # packages/client/runtime/src/client/sessions/fold-adapter.ts # packages/compact/compact-basic/src/summarizer.ts
This commit is contained in:
@@ -5,8 +5,24 @@
|
||||
// prompt triggers a chunked streaming replay; cancel stops the replay; resident pending
|
||||
// approval/question requests exercise replay and composer takeover with stable rpcIds.
|
||||
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { SessionEvent, SessionId, TodoItem } from '@deepseek-ai/dsh-session/types'
|
||||
import {
|
||||
createAssistantMessage,
|
||||
createToolResultMessage,
|
||||
createUserMessage,
|
||||
} from '@deepseek-ai/dsh-llm/message'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type {
|
||||
AssistantMessage,
|
||||
ContentBlock,
|
||||
MessageSource,
|
||||
ToolResultMessage,
|
||||
UserMessage,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
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'
|
||||
@@ -27,6 +43,21 @@ function text(t: string): ContentBlock[] {
|
||||
return [{ type: 'text', text: t }]
|
||||
}
|
||||
|
||||
function userMessage(content: ContentBlock[], source: MessageSource = { kind: 'user' }): UserMessage {
|
||||
return createUserMessage({ content, source })
|
||||
}
|
||||
|
||||
function assistantMessage(content: ContentBlock[]): AssistantMessage {
|
||||
return createAssistantMessage({
|
||||
content,
|
||||
source: { provider: 'fixture', model: 'fx-1' },
|
||||
})
|
||||
}
|
||||
|
||||
function toolResultMessage(callId: string, content: ContentBlock[], isError: boolean): ToolResultMessage {
|
||||
return createToolResultMessage({ callId: CallId(callId), content, isError })
|
||||
}
|
||||
|
||||
const MARKDOWN_FIXTURE = [
|
||||
'# Markdown fixture',
|
||||
'',
|
||||
@@ -86,10 +117,7 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
|
||||
const userSeq = push({
|
||||
type: 'user/message', surfaceOp: 'append',
|
||||
data: {
|
||||
content: text(turn === 59 ? USER_MARKDOWN_LITERAL : `问题 ${turn}:fixture 历史消息,用于翻页与渲染验收。`),
|
||||
source: { kind: 'user' },
|
||||
},
|
||||
data: userMessage(text(turn === 59 ? USER_MARKDOWN_LITERAL : `问题 ${turn}:fixture 历史消息,用于翻页与渲染验收。`)),
|
||||
})
|
||||
if (turn === 0) {
|
||||
push({
|
||||
@@ -98,7 +126,7 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
})
|
||||
}
|
||||
if (turn % 9 === 4) {
|
||||
push({ type: 'user/message', surfaceOp: 'append', data: { content: text(`[fixture] 上下文注入(turn ${turn})`), source: { kind: 'plugin', plugin: 'fixture' } } })
|
||||
push({ type: 'user/message', surfaceOp: 'append', data: userMessage(text(`[fixture] 上下文注入(turn ${turn})`), { kind: 'plugin', plugin: 'fixture' }) })
|
||||
}
|
||||
push({ type: 'step/start', data: { turn, step: 0 } })
|
||||
const withTool = turn % 5 === 2
|
||||
@@ -109,19 +137,19 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
if (withTool) {
|
||||
const callId = `fx-call-${turn}`
|
||||
blocks.push({ type: 'tool-call', id: callId, name: 'echo', arguments: `{"text":"turn ${turn}"}` } as ContentBlock)
|
||||
push({ type: 'assistant/message', surfaceOp: 'append', data: { turn, step: 0, content: blocks, provenance: { provider: 'fixture', model: 'fx-1' } } })
|
||||
push({ type: 'assistant/message', surfaceOp: 'append', data: { turn, step: 0, message: assistantMessage(blocks) } })
|
||||
push({ type: 'tool/call', data: { turn, step: 0, callId, name: 'echo', arguments: `{"text":"turn ${turn}"}` } })
|
||||
push({ type: 'tool/result', surfaceOp: 'append', data: { turn, step: 0, callId, content: text(`ECHO: TURN ${turn}`), isError: turn % 25 === 12 } })
|
||||
push({ type: 'tool/result', surfaceOp: 'append', data: { turn, step: 0, message: toolResultMessage(callId, text(`ECHO: TURN ${turn}`), turn % 25 === 12) } })
|
||||
push({ type: 'step/end', data: { turn, step: 0 } })
|
||||
push({ type: 'step/start', data: { turn, step: 1 } })
|
||||
push({ type: 'assistant/message', surfaceOp: 'append', data: { turn, step: 1, content: text(`工具结果已消化(turn ${turn})。`), provenance: { provider: 'fixture', model: 'fx-1' } } })
|
||||
push({ type: 'assistant/message', surfaceOp: 'append', data: { turn, step: 1, message: assistantMessage(text(`工具结果已消化(turn ${turn})。`)) } })
|
||||
push({ type: 'step/end', data: { turn, step: 1 } })
|
||||
} else {
|
||||
push({ type: 'assistant/message', surfaceOp: 'append', data: { turn, step: 0, content: blocks, provenance: { provider: 'fixture', model: 'fx-1' } } })
|
||||
push({ type: 'assistant/message', surfaceOp: 'append', data: { turn, step: 0, message: assistantMessage(blocks) } })
|
||||
push({ type: 'step/end', data: { turn, step: 0 } })
|
||||
}
|
||||
if (turn % 13 === 6) {
|
||||
push({ type: 'steering/message', surfaceOp: 'append', data: { turn, content: text(`插话 ${turn}:fixture steering 消息。`), source: { kind: 'user' } } })
|
||||
push({ type: 'steering/message', surfaceOp: 'append', data: { turn, message: userMessage(text(`插话 ${turn}:fixture steering 消息。`)) } })
|
||||
}
|
||||
push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
|
||||
}
|
||||
@@ -131,14 +159,14 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
const toolTurn = (turn: number, name: string, args: string, resultText: string): void => {
|
||||
const callId = `fx-call-${turn}`
|
||||
push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
|
||||
push({ type: 'user/message', surfaceOp: 'append', data: { content: text(`问题 ${turn}:${name} 样本。`), source: { kind: 'user' } } })
|
||||
push({ type: 'user/message', surfaceOp: 'append', data: userMessage(text(`问题 ${turn}:${name} 样本。`)) })
|
||||
push({ type: 'step/start', data: { turn, step: 0 } })
|
||||
push({
|
||||
type: 'assistant/message', surfaceOp: 'append',
|
||||
data: { turn, step: 0, content: [{ type: 'tool-call', id: callId, name, arguments: args } as ContentBlock], provenance: { provider: 'fixture', model: 'fx-1' } },
|
||||
data: { turn, step: 0, message: assistantMessage([{ type: 'tool-call', id: callId, name, arguments: args } as ContentBlock]) },
|
||||
})
|
||||
push({ type: 'tool/call', data: { turn, step: 0, callId, name, arguments: args } })
|
||||
push({ type: 'tool/result', surfaceOp: 'append', data: { turn, step: 0, callId, content: text(resultText), isError: false } })
|
||||
push({ type: 'tool/result', surfaceOp: 'append', data: { turn, step: 0, message: toolResultMessage(callId, text(resultText), false) } })
|
||||
push({ type: 'step/end', data: { turn, step: 0 } })
|
||||
push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
|
||||
}
|
||||
@@ -159,11 +187,11 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
+ 'return { listing, demo }'
|
||||
const args = JSON.stringify({ code: program, description: 'Read the notes files and summarize' })
|
||||
push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
|
||||
push({ type: 'user/message', surfaceOp: 'append', data: { content: text(`问题 ${turn}:run_code 样本。`), source: { kind: 'user' } } })
|
||||
push({ type: 'user/message', surfaceOp: 'append', data: userMessage(text(`问题 ${turn}:run_code 样本。`)) })
|
||||
push({ type: 'step/start', data: { turn, step: 0 } })
|
||||
push({
|
||||
type: 'assistant/message', surfaceOp: 'append',
|
||||
data: { turn, step: 0, content: [{ type: 'tool-call', id: callId, name: 'run_code', arguments: args } as ContentBlock], provenance: { provider: 'fixture', model: 'fx-1' } },
|
||||
data: { turn, step: 0, message: assistantMessage([{ type: 'tool-call', id: callId, name: 'run_code', arguments: args } as ContentBlock]) },
|
||||
})
|
||||
push({ type: 'tool/call', data: { turn, step: 0, callId, name: 'run_code', arguments: args } })
|
||||
const dispatchPair = (n: number, name: string, dispatchArgs: Record<string, unknown>, resultText: string, isError = false): void => {
|
||||
@@ -184,7 +212,7 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
dispatchPair(3, 'read', { path: 'notes/missing.txt' }, 'Error: ENOENT: notes/missing.txt not found', true)
|
||||
push({
|
||||
type: 'tool/result', surfaceOp: 'append',
|
||||
data: { turn, step: 0, callId, content: text('{"listing":"demo.txt\\nnew-demo.txt","demo":"hello fixture\\n"}'), isError: false },
|
||||
data: { turn, step: 0, message: toolResultMessage(callId, text('{"listing":"demo.txt\\nnew-demo.txt","demo":"hello fixture\\n"}'), false) },
|
||||
})
|
||||
push({ type: 'step/end', data: { turn, step: 0 } })
|
||||
push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
|
||||
@@ -258,13 +286,13 @@ function viewFor(event: SessionEvent, log: readonly SessionEvent[]): ToolEventVi
|
||||
return view === undefined ? undefined : { for: 'call', view }
|
||||
}
|
||||
if (event.type === 'tool/result') {
|
||||
const callId = String(event.data.callId)
|
||||
const callId = String(event.data.message.source.callId)
|
||||
for (let i = log.length - 1; i >= 0; i--) {
|
||||
const candidate = log[i]
|
||||
/* v8 ignore next -- dense-array guard: i stays within [0, log.length),
|
||||
so the undefined arm needs a sparse log no code path builds. */
|
||||
if (candidate !== undefined && candidate.type === 'tool/call' && String(candidate.data.callId) === callId) {
|
||||
const resultText = event.data.content.map(b => (b.type === 'text' ? b.text : '')).join('')
|
||||
const resultText = event.data.message.content[0].content.map(b => (b.type === 'text' ? b.text : '')).join('')
|
||||
const view = presentResult(candidate.data.name, candidate.data.arguments, resultText)
|
||||
return view === undefined ? undefined : { for: 'result', view }
|
||||
}
|
||||
@@ -281,20 +309,31 @@ function projectionValuesOf(log: readonly SessionEvent[]): Record<string, unknow
|
||||
if (titleEvent !== undefined) {
|
||||
values['title'] = (titleEvent as unknown as { data: { title: string } }).data.title
|
||||
}
|
||||
const todos = backscanTodos(log)
|
||||
if (todos !== undefined) values['todos'] = todos
|
||||
// Always present (tool-todo unit composed): null when no plan stands.
|
||||
values['todos'] = backscanTodos(log) ?? null
|
||||
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 }]
|
||||
if (type === 'session/title') {
|
||||
const values = projectionValuesOf(log)
|
||||
/* v8 ignore next -- the advancing title event is in the log, so the key is present. */
|
||||
if (!Object.hasOwn(values, 'title')) return []
|
||||
return [{ type: 'session/projection', sessionId: id, key: 'title', value: values['title'], seq: event.seq }]
|
||||
}
|
||||
// Standing-plan fold: writes replace the list; turn/start clears it (null).
|
||||
if (type === 'todo/write' || type === 'turn/start') {
|
||||
return [{
|
||||
type: 'session/projection',
|
||||
sessionId: id,
|
||||
key: 'todos',
|
||||
value: backscanTodos(log) ?? null,
|
||||
seq: event.seq,
|
||||
}]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -328,11 +367,16 @@ function pageOf(
|
||||
return { events, hasMore: start > 0 }
|
||||
}
|
||||
|
||||
/** Current todo projection over the full log (host parallel: latest todo/write, last write wins). */
|
||||
/**
|
||||
* Current plan projection over the full log (host parallel: latest todo/write
|
||||
* with no later turn/start; a new turn retires the previous plan).
|
||||
*/
|
||||
function backscanTodos(log: readonly SessionEvent[]): TodoItem[] | undefined {
|
||||
for (let i = log.length - 1; i >= 0; i--) {
|
||||
const event = log[i]
|
||||
if (event !== undefined && event.type === 'todo/write') return event.data.todos
|
||||
if (event === undefined) continue
|
||||
if (event.type === 'turn/start') return undefined
|
||||
if (event.type === 'todo/write') return event.data.todos
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@@ -552,7 +596,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
},
|
||||
/** Log append + mux emit (the normal live path). */
|
||||
appendUser(id: string, msg: string): void {
|
||||
append(sid(id), { type: 'user/message', surfaceOp: 'append', data: { content: text(msg), source: { kind: 'user' } } })
|
||||
append(sid(id), { type: 'user/message', surfaceOp: 'append', data: userMessage(text(msg)) })
|
||||
},
|
||||
/** Append a later durable title revision through the normal raw-event + control-frame path. */
|
||||
appendTitle(id: string, title: string): void {
|
||||
@@ -563,7 +607,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
/** Log append WITHOUT the mux emit: a frame lost in transit — history still serves it, the client must repull. */
|
||||
appendSilent(id: string, msg: string): void {
|
||||
const log = logOf(sid(id))
|
||||
log.push({ type: 'user/message', surfaceOp: 'append', seq: log.length, time: Date.now(), data: { content: text(msg), source: { kind: 'user' } } } as unknown as SessionEvent)
|
||||
log.push({ type: 'user/message', surfaceOp: 'append', seq: log.length, time: Date.now(), data: userMessage(text(msg)) } as unknown as SessionEvent)
|
||||
},
|
||||
/** End every open stream generator (client sees both streams close -> reconnect + resync path). */
|
||||
breakStreams(): void {
|
||||
@@ -584,7 +628,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
replays.delete(id)
|
||||
const done = pieces.slice(0, i).join('')
|
||||
append(id, { type: 'assistant/chunk', data: { turn, step, chunk: { type: 'block-end', index: 0, block: { type: 'text', text: done } } } })
|
||||
append(id, { type: 'assistant/message', surfaceOp: 'append', data: { turn, step, content: text(aborted ? `${done}(已中断)` : done), provenance: { provider: 'fixture', model: 'fx-1' } } })
|
||||
append(id, { type: 'assistant/message', surfaceOp: 'append', data: { turn, step, message: assistantMessage(text(aborted ? `${done}(已中断)` : done)) } })
|
||||
append(id, { type: 'step/end', data: { turn, step } })
|
||||
append(id, { type: 'turn/end', data: { turn, reason: { kind: aborted ? 'cancelled' : 'completed' } } })
|
||||
setRunning(id, false)
|
||||
@@ -753,14 +797,14 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
// Steering: insert a steering message into the current turn; the replay continues.
|
||||
/* v8 ignore next -- the ?? arm needs a missing counter, but a live replay implies a prior prompt already set it. */
|
||||
const turn = (nextTurn.get(id) ?? 1) - 1
|
||||
append(id, { type: 'steering/message', surfaceOp: 'append', data: { turn, content, source: { kind: 'user' } } })
|
||||
append(id, { type: 'steering/message', surfaceOp: 'append', data: { turn, message: userMessage(content) } })
|
||||
return ok(request, { accepted: true as const })
|
||||
}
|
||||
const turn = nextTurn.get(id) ?? 0
|
||||
nextTurn.set(id, turn + 1)
|
||||
setRunning(id, true)
|
||||
append(id, { type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
|
||||
append(id, { type: 'user/message', surfaceOp: 'append', data: { content, source: { kind: 'user' } } })
|
||||
append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(content) })
|
||||
startReply(
|
||||
id,
|
||||
turn,
|
||||
|
||||
@@ -69,7 +69,10 @@ describe('createFixtureApi', () => {
|
||||
// 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, projections: { asOfSeq: -1, values: {} } })
|
||||
// Fixture composes the todos unit (host parallel when tool-todo is mounted): null before any write.
|
||||
expect(empty.result.value).toEqual({
|
||||
events: [], hasMore: false, projections: { asOfSeq: -1, values: { todos: null } },
|
||||
})
|
||||
})
|
||||
|
||||
it('serves grouped models and keeps a selected target for later history and fixture requests', async () => {
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
|
||||
README.md: 16c1124ec812b9f030ce8266a16cdb8f5db0e6cc
|
||||
README.zh.md: a3d2a2dfdd1662afee65ec45e26b1ef1029f44b5
|
||||
README.md: 25eb60e2c95059ae918669c9f5169b6b8e9c6816
|
||||
README.zh.md: e3085f91750503aeaffda41d86c40c62943b4ba9
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list/scope/history state; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into both managers. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. `ConversationSnapshot` carries `todos` — the session's current todo projection: taken from the tail history page's full-log value (host-computed, independent of the page window), preserved across an older-page prepend, and overwritten by each live `todo/write` (last write wins). A tail response that omits the field means the log holds no `todo/write`, so the list resets to empty — a plan the log never kept (a write lost to a host crash) disappears on the next open or resync.
|
||||
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list/scope/history state; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into both managers. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`.
|
||||
|
||||
## Workspace and Session lists
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象、列表/scope/history 状态;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给两个 manager。客户端 Session 一律由 Host 出生(一次 `session.create` 同瞬产出 Session+Agent+cwd);客户端不持有任何实体化之前的会话状态——Agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时出生,随 prune 死亡。契约:api-contracts v3 §4。`ConversationSnapshot` 携带 `todos`——会话当前的 todo 投影:取自尾页 history 携带的全量 log 值(host 计算,独立于分页窗口),跨往前翻页保留,并被每次实时 `todo/write` 覆盖(后写胜出)。尾页响应省略该字段即表示 log 中没有任何 `todo/write`,因此列表复位为空——log 从未留下的计划(写入因 host 崩溃丢失)会在下一次打开或 resync 时消失。
|
||||
客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象、列表/scope/history 状态;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给两个 manager。客户端 Session 一律由 Host 出生(一次 `session.create` 同瞬产出 Session+Agent+cwd);客户端不持有任何实体化之前的会话状态——Agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时出生,随 prune 死亡。契约:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史尾页的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。
|
||||
|
||||
## Workspace 与 Session 列表
|
||||
|
||||
|
||||
@@ -134,10 +134,10 @@ function materializeNode(
|
||||
return {
|
||||
kind: 'assistant', seq: event.seq, time: event.time,
|
||||
turn: event.data.turn, step: event.data.step,
|
||||
blocks: toAssistantBlocks(event.data.content), usage: event.data.usage,
|
||||
blocks: toAssistantBlocks(event.data.message.content), usage: event.data.usage,
|
||||
provenance: {
|
||||
provider: event.data.provenance.provider,
|
||||
model: event.data.provenance.model,
|
||||
provider: event.data.message.source.provider,
|
||||
model: event.data.message.source.model,
|
||||
},
|
||||
...(requestConfig === undefined ? {} : { requestConfig }),
|
||||
...(assistantTiming !== undefined ? { timing: assistantTiming } : {}),
|
||||
@@ -145,16 +145,18 @@ function materializeNode(
|
||||
case 'steering/message':
|
||||
return {
|
||||
kind: 'steering', seq: event.seq, time: event.time, turn: event.data.turn,
|
||||
content: event.data.content, source: event.data.source,
|
||||
content: event.data.message.content, source: event.data.message.source,
|
||||
}
|
||||
case 'tool/result': {
|
||||
const call = callIndex.get(String(event.data.callId))
|
||||
const result = event.data.message.content[0]
|
||||
const callId = String(event.data.message.source.callId)
|
||||
const call = callIndex.get(callId)
|
||||
return {
|
||||
kind: 'tool-result', seq: event.seq, time: event.time,
|
||||
callId: String(event.data.callId),
|
||||
callId,
|
||||
call: call ? { name: call.name, argsRaw: call.argsRaw } : null,
|
||||
callTime: call?.time ?? null,
|
||||
content: event.data.content, isError: event.data.isError,
|
||||
content: result.content, isError: result.isError === true,
|
||||
...(event.data.error !== undefined ? { error: event.data.error } : {}),
|
||||
meta: event.data.meta,
|
||||
callView: call?.callView ?? null,
|
||||
|
||||
@@ -249,8 +249,8 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
|
||||
status: 'complete',
|
||||
resultSeq: sourceEvent.seq,
|
||||
provenance: {
|
||||
provider: sourceEvent.data.provenance.provider,
|
||||
model: sourceEvent.data.provenance.model,
|
||||
provider: sourceEvent.data.message.source.provider,
|
||||
model: sourceEvent.data.message.source.model,
|
||||
},
|
||||
...(sourceEvent.data.usage === undefined ? {} : { usage: sourceEvent.data.usage }),
|
||||
})
|
||||
|
||||
@@ -401,13 +401,14 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
return
|
||||
}
|
||||
case 'session/queued': {
|
||||
const message = frame.message
|
||||
// Row key: the enqueueing prompt's rpcId when it rode this wire (the
|
||||
// provisional-echo reconciliation key); otherwise the frame envelope id.
|
||||
const key = 'rpcId' in frame.source ? String(frame.source.rpcId) : `f:${rpcId}`
|
||||
const key = 'rpcId' in message.source ? String(message.source.rpcId) : `f:${rpcId}`
|
||||
this.queued.push({
|
||||
row: { key, preview: queuePreviewOf(frame.content) },
|
||||
row: { key, preview: queuePreviewOf(message.content) },
|
||||
steering: frame.steering,
|
||||
sourceJson: JSON.stringify(frame.source),
|
||||
sourceJson: JSON.stringify(message.source),
|
||||
})
|
||||
this.queueRev++
|
||||
this.notifier.markDirty()
|
||||
@@ -646,7 +647,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
if (event.data.trigger.kind !== 'message') return
|
||||
index = this.queued.findIndex(entry => !entry.steering)
|
||||
} else if (event.type === 'steering/message') {
|
||||
const source = JSON.stringify(event.data.source)
|
||||
const source = JSON.stringify(event.data.message.source)
|
||||
index = this.queued.findIndex(entry => entry.steering && entry.sourceJson === source)
|
||||
} else {
|
||||
return
|
||||
@@ -744,7 +745,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
return
|
||||
}
|
||||
case 'tool/result': {
|
||||
if (this.openCalls.delete(String(event.data.callId))) this.callsRev++
|
||||
if (this.openCalls.delete(String(event.data.message.source.callId))) this.callsRev++
|
||||
return
|
||||
}
|
||||
case 'turn/end': {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createUserMessage, createMessage, createToolResultMessage, CallId } from '@deepseek-ai/dsh-llm'
|
||||
// Minimal SessionEvent builders for orchestration tests (shape mirrors what the
|
||||
// host emits; only the fields the object layer reads).
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
@@ -13,7 +14,9 @@ export const ev = {
|
||||
turnStart: (seq: number, turn: number): SessionEvent =>
|
||||
at(seq, { type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } }),
|
||||
user: (seq: number, body: string): SessionEvent =>
|
||||
at(seq, { type: 'user/message', surfaceOp: 'append', data: { content: text(body), source: { kind: 'user' } } }),
|
||||
at(seq, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
|
||||
content: text(body), source: { kind: 'user' },
|
||||
}) }),
|
||||
stepStart: (seq: number, turn: number, step = 0): SessionEvent =>
|
||||
at(seq, { type: 'step/start', data: { turn, step } }),
|
||||
chunkStart: (seq: number, turn: number, step = 0, index = 0): SessionEvent =>
|
||||
@@ -21,11 +24,33 @@ export const ev = {
|
||||
chunkText: (seq: number, turn: number, piece: string, step = 0, index = 0): SessionEvent =>
|
||||
at(seq, { type: 'assistant/chunk', data: { turn, step, chunk: { type: 'text-delta', index, text: piece } } }),
|
||||
assistant: (seq: number, turn: number, body: string, step = 0): SessionEvent =>
|
||||
at(seq, { type: 'assistant/message', surfaceOp: 'append', data: { turn, step, content: text(body), provenance: { provider: 'fake', model: 'fk-1' } } }),
|
||||
at(seq, { type: 'assistant/message', surfaceOp: 'append', data: {
|
||||
turn, step,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: text(body),
|
||||
source: {
|
||||
kind: 'model',
|
||||
...{ provider: 'fake', model: 'fk-1' },
|
||||
},
|
||||
}),
|
||||
} }),
|
||||
toolCall: (seq: number, turn: number, callId: string, name: string, args: string, step = 0): SessionEvent =>
|
||||
at(seq, { type: 'tool/call', data: { turn, step, callId, name, arguments: args } }),
|
||||
toolResult: (seq: number, turn: number, callId: string, body: string, step = 0): SessionEvent =>
|
||||
at(seq, { type: 'tool/result', surfaceOp: 'append', data: { turn, step, callId, content: text(body), isError: false } }),
|
||||
at(seq, {
|
||||
type: 'tool/result',
|
||||
surfaceOp: 'append',
|
||||
data: {
|
||||
turn,
|
||||
step,
|
||||
message: createToolResultMessage({
|
||||
callId: CallId(callId),
|
||||
content: text(body),
|
||||
isError: false,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
codeDispatchStart: (seq: number, parentCallId: string, n: number, name: string, args: unknown): SessionEvent =>
|
||||
at(seq, {
|
||||
type: 'tool/code-dispatch-start',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createUserMessage, CallId, createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm'
|
||||
/**
|
||||
* FoldAdapter over the real core SurfaceManager: padding sentinels for paged
|
||||
* windows, incremental append with node-cache identity, six-variant
|
||||
@@ -47,8 +48,11 @@ describe('FoldAdapter', () => {
|
||||
data: {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
content: [{ type: 'text', text: 'summary' }],
|
||||
provenance: { provider: 'fake', model: 'fake' },
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'summary' }],
|
||||
source: { kind: 'model', provider: 'fake', model: 'fake' },
|
||||
}),
|
||||
},
|
||||
}),
|
||||
at(3, {
|
||||
@@ -58,8 +62,11 @@ describe('FoldAdapter', () => {
|
||||
data: {
|
||||
turn: 1,
|
||||
step: 2,
|
||||
content: [{ type: 'text', text: 'summary 2' }],
|
||||
provenance: { provider: 'fake', model: 'fake' },
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'summary 2' }],
|
||||
source: { kind: 'model', provider: 'fake', model: 'fake' },
|
||||
}),
|
||||
},
|
||||
}),
|
||||
]
|
||||
@@ -81,8 +88,16 @@ describe('FoldAdapter', () => {
|
||||
const events = [
|
||||
ev.user(0, '用户'),
|
||||
ev.assistant(1, 0, '助手'),
|
||||
at(2, { type: 'steering/message', surfaceOp: 'append', data: { turn: 0, content: [{ type: 'text', text: '插话' }], source: { kind: 'user' } } }),
|
||||
at(3, { type: 'user/message', surfaceOp: 'append', data: { content: [{ type: 'text', text: '上下文' }], source: { kind: 'plugin', plugin: 'p' } } }),
|
||||
at(2, { type: 'steering/message', surfaceOp: 'append', data: {
|
||||
turn: 0,
|
||||
message: createUserMessage({
|
||||
content: [{ type: 'text', text: '插话' }],
|
||||
source: { kind: 'user' },
|
||||
}),
|
||||
} }),
|
||||
at(3, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
|
||||
content: [{ type: 'text', text: '上下文' }], source: { kind: 'plugin', plugin: 'p' },
|
||||
}) }),
|
||||
ev.toolCall(4, 0, 'c1', 'echo', '{"x":1}'),
|
||||
ev.toolResult(5, 0, 'c1', '结果'),
|
||||
]
|
||||
@@ -118,7 +133,17 @@ describe('FoldAdapter', () => {
|
||||
// An invalid surfaceOp on a surface-eligible event deterministically throws in the core fold.
|
||||
const window = [
|
||||
ev.user(10, '正常'),
|
||||
at(11, { type: 'assistant/message', surfaceOp: 'bogus-op', data: { turn: 0, step: 0, content: [{ type: 'text', text: '坏 op' }], provenance: { provider: 'x', model: 'y' } } }),
|
||||
at(11, { type: 'assistant/message', surfaceOp: 'bogus-op', data: {
|
||||
turn: 0, step: 0,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: '坏 op' }],
|
||||
source: {
|
||||
kind: 'model',
|
||||
...{ provider: 'x', model: 'y' },
|
||||
},
|
||||
}),
|
||||
} }),
|
||||
]
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
try {
|
||||
@@ -140,7 +165,15 @@ describe('FoldAdapter', () => {
|
||||
it('materializes a tool-result error field when present', () => {
|
||||
const adapter = new FoldAdapter()
|
||||
adapter.reset([
|
||||
at(0, { type: 'tool/result', surfaceOp: 'append', data: { turn: 0, step: 0, callId: 'c1', content: [], isError: true, error: { name: 'Boom', code: 'boom' } } }),
|
||||
at(0, { type: 'tool/result', surfaceOp: 'append', data: {
|
||||
turn: 0, step: 0,
|
||||
message: createToolResultMessage({
|
||||
callId: CallId('c1'),
|
||||
content: [],
|
||||
isError: true,
|
||||
}),
|
||||
error: { name: 'Boom', code: 'boom' },
|
||||
} }),
|
||||
], 0)
|
||||
expect(adapter.nodes().nodes[0]).toMatchObject({ kind: 'tool-result', isError: true, error: { code: 'boom' } })
|
||||
})
|
||||
@@ -245,7 +278,15 @@ describe('FoldAdapter', () => {
|
||||
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' } } }),
|
||||
at(2, { type: 'assistant/message', surfaceOp: 'bogus-op', data: {
|
||||
turn: 0,
|
||||
step: 0,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: '坏 op' }],
|
||||
source: { kind: 'model', provider: 'x', model: 'y' },
|
||||
}),
|
||||
} }),
|
||||
], 0)
|
||||
const { nodes, degraded } = adapter.nodes()
|
||||
expect(degraded).toBe(true)
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* pre-instantiation buffering, and snapshot reference stability.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { MuxFrame, RpcId, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { Session } from '../src/client/sessions/session.ts'
|
||||
@@ -19,8 +20,12 @@ const rid = (id: string): RpcId => id as RpcId
|
||||
/** session/queued frame with the wire-sourced rpcId key (the host prompt path). */
|
||||
function queuedFrame(body: string, rpcId: string, steering = false): MuxFrame {
|
||||
return {
|
||||
type: 'session/queued', sessionId: SID, content: text(body),
|
||||
source: { kind: 'user', rpcId: rid(rpcId) } as never,
|
||||
type: 'session/queued',
|
||||
sessionId: SID,
|
||||
message: createUserMessage({
|
||||
content: text(body),
|
||||
source: { kind: 'user', rpcId: rid(rpcId) } as never,
|
||||
}),
|
||||
steering,
|
||||
}
|
||||
}
|
||||
@@ -40,9 +45,12 @@ describe('queue intake', () => {
|
||||
it('falls back to the envelope rpcId when the source carries none, and tags non-text blocks', () => {
|
||||
const session = makeSession()
|
||||
session.handleMuxEnvelope(rid('env-2'), {
|
||||
type: 'session/queued', sessionId: SID,
|
||||
content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' } as never],
|
||||
source: { kind: 'plugin', plugin: 'loop' },
|
||||
type: 'session/queued',
|
||||
sessionId: SID,
|
||||
message: createUserMessage({
|
||||
content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' } as never],
|
||||
source: { kind: 'plugin', plugin: 'loop' },
|
||||
}),
|
||||
steering: false,
|
||||
})
|
||||
expect(session.getSnapshot().queue).toEqual([{ key: 'f:env-2', preview: 'hi [image]' }])
|
||||
@@ -93,14 +101,26 @@ describe('queue retirement (host queuedMirror rules)', () => {
|
||||
const foreignSteering = {
|
||||
seq: 0, time: 1,
|
||||
type: 'steering/message', surfaceOp: 'append',
|
||||
data: { turn: 0, content: text('loop'), source: { kind: 'plugin', plugin: 'loop' } },
|
||||
data: {
|
||||
turn: 0,
|
||||
message: createUserMessage({
|
||||
content: text('loop'),
|
||||
source: { kind: 'plugin', plugin: 'loop' },
|
||||
}),
|
||||
},
|
||||
} as never
|
||||
session.handleMuxEnvelope(rid('e4'), { type: 'session/event', sessionId: SID, event: foreignSteering })
|
||||
expect(session.getSnapshot().queue).toHaveLength(2)
|
||||
const matchedSteering = {
|
||||
seq: 1, time: 2,
|
||||
type: 'steering/message', surfaceOp: 'append',
|
||||
data: { turn: 0, content: text('插话'), source: { kind: 'user', rpcId: rid('p-2') } },
|
||||
data: {
|
||||
turn: 0,
|
||||
message: createUserMessage({
|
||||
content: text('插话'),
|
||||
source: { kind: 'user', rpcId: rid('p-2') },
|
||||
}),
|
||||
},
|
||||
} as never
|
||||
session.handleMuxEnvelope(rid('e5'), { type: 'session/event', sessionId: SID, event: matchedSteering })
|
||||
expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-1'])
|
||||
@@ -154,7 +174,13 @@ describe('queue reconnect semantics', () => {
|
||||
const committed = {
|
||||
seq: 6, time: 2,
|
||||
type: 'steering/message', surfaceOp: 'append',
|
||||
data: { turn: 1, content: text('重连插话'), source: { kind: 'user', rpcId: rid('p-steer') } },
|
||||
data: {
|
||||
turn: 1,
|
||||
message: createUserMessage({
|
||||
content: text('重连插话'),
|
||||
source: { kind: 'user', rpcId: rid('p-steer') },
|
||||
}),
|
||||
},
|
||||
} as never
|
||||
session.handleMuxEnvelope(rid('e3'), { type: 'session/event', sessionId: SID, event: committed })
|
||||
expect(session.getSnapshot().queue).toEqual([])
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import { inspectRequests } from '../src/client/sessions/request-inspection.ts'
|
||||
|
||||
@@ -35,8 +36,10 @@ describe('inspectRequests', () => {
|
||||
at(3, 'assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
content: [{ type: 'text', text: 'done' }],
|
||||
provenance: { provider: 'fake', model: 'model' },
|
||||
message: createAssistantMessage({
|
||||
content: [{ type: 'text', text: 'done' }],
|
||||
source: { provider: 'fake', model: 'model' },
|
||||
}),
|
||||
usage: { inputTokens: 5, outputTokens: 2 },
|
||||
}),
|
||||
at(4, 'step/end', { turn: 1, step: 1 }),
|
||||
@@ -51,10 +54,10 @@ describe('inspectRequests', () => {
|
||||
model: 'compact-model',
|
||||
usage: { inputTokens: 8, outputTokens: 3 },
|
||||
}),
|
||||
at(7, 'user/message', {
|
||||
at(7, 'user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'checkpoint' }],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
}),
|
||||
})),
|
||||
at(8, 'compact/end', { turn: 1 }),
|
||||
]
|
||||
const snapshot = inspectRequests(entriesOf(events))
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: a04c20f225c731581accbe8c12c52a5e7597029a
|
||||
README.zh.md: f9e6a635ea6090c87a66f029785af214025b9bda
|
||||
README.md: 51ddecf93240c2196483d3fb2bcfaca4104da31a
|
||||
README.zh.md: d98cbcc69b875d2f426d9bdd9f2fa81874ec614a
|
||||
|
||||
@@ -12,7 +12,7 @@ Generic tool rows classify the built-in bash, read, search, write, edit, and run
|
||||
|
||||
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.
|
||||
The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`<done>/<total> 已完成 · <active item>` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1` — above the queue rows — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and collapses to a header of title plus `"<done>/<total> tasks · <n> in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.
|
||||
|
||||
Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
工具行同样是 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,包括这条计划条。
|
||||
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: -1` 占用 `'conversation.input.dock'` 列表 slot(位于队列行之上),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏,折叠时收成标题加 `"<已完成>/<总数> tasks · <n> in progress"` 的表头(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。
|
||||
|
||||
逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// TodoPanel: persistent plan strip above the composer (the web counterpart
|
||||
// of the TUI plan panel). Renders the latest todo/write whole-list snapshot —
|
||||
// no data of its own, hidden while the list is empty. Mounted through the
|
||||
// 'conversation.input.dock' slot (QueueDock posture): the dock adapter does
|
||||
// the selecting, so the panel takes the plain list and stays framework-free.
|
||||
// Visual: figma 772:51905 (states) / 772:52972 (collapsed) / 772:53419 (expanded).
|
||||
// TodoPanel: plan strip above the composer (the web counterpart of the TUI
|
||||
// plan panel). Renders the standing todo/write whole-list snapshot (cleared on
|
||||
// the next turn/start) — no data of its own, hidden while the list is empty.
|
||||
// Mounted through the 'conversation.input.dock' slot (QueueDock posture): the
|
||||
// dock adapter does the selecting, so the panel takes the plain list and stays
|
||||
// framework-free. Visual: figma 772:51905 / 772:52972 / 772:53419.
|
||||
|
||||
import { useId, useState } from 'react'
|
||||
import type { Context } from 'cordis'
|
||||
|
||||
@@ -55,7 +55,11 @@ function makeSource(init?: Partial<ConversationSnapshot>) {
|
||||
}
|
||||
|
||||
const user = (seq: number, text: string): UserMessageNode => ({
|
||||
kind: 'user', seq, time: seq * 1_000, content: [{ type: 'text', text }] as never, source: null,
|
||||
kind: 'user',
|
||||
seq,
|
||||
time: seq * 1000,
|
||||
content: [{ type: 'text', text }] as never,
|
||||
source: null,
|
||||
})
|
||||
const assistant = (seq: number, text: string): AssistantMessageNode => ({
|
||||
kind: 'assistant', seq, time: seq * 1_000, turn: 1, step: 1, blocks: [{ kind: 'text', text }],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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([])
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,7 @@
|
||||
.split {
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
||||
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user