Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input

# Conflicts:
#	apps/cli/src/web.ts
#	apps/web/tests/smoke-fixture.e2e.ts
#	docs/architecture.i18n.yaml
#	packages/client/connection/src/client/fixture.ts
#	packages/client/runtime/src/client/sessions/conversation.ts
#	packages/client/runtime/src/client/sessions/session.ts
#	packages/client/ui-conversation/package.json
#	packages/client/ui-conversation/src/client/contract/slots.ts
#	packages/client/ui-conversation/src/client/index.ts
#	packages/client/ui-conversation/src/client/service.ts
#	packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
#	packages/client/ui-conversation/tests/apply-inject.spec.tsx
#	packages/client/ui-conversation/tests/service-orchestration.spec.ts
#	packages/host/runtime/src/api-proxy.ts
#	packages/host/runtime/src/boot.ts
#	packages/host/webserver/tests/webserver.spec.ts
#	pnpm-lock.yaml
This commit is contained in:
Yichen Jiang
2026-07-24 16:29:03 +08:00
503 changed files with 19038 additions and 3597 deletions

View File

@@ -15,7 +15,10 @@ export type {
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
} from '@deepseek-ai/dsh-host-apiproxy/api'
export { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api'
// transportError moved down to the apiproxy api layer (it belongs beside
// RpcResult, its subject); re-exported here so connection consumers keep one
// contract entry point.
export { RpcId, transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
export { AbstractApiClient } from '@deepseek-ai/dsh-host-apiproxy/client'
export type { IApiClient } from '@deepseek-ai/dsh-host-apiproxy/client'
export type { SessionId, SessionEvent } from '@deepseek-ai/dsh-session/types'
@@ -35,16 +38,3 @@ import type { RpcResponse, RpcResult } from '@deepseek-ai/dsh-host-apiproxy/api'
export function resultOf<T>(response: RpcResponse<T>): RpcResult<T> {
return response.result
}
/**
* Fold a transport exception into the RpcResult error branch (unified error
* surface; 'internal' as the catch-all code).
* @param error - the thrown value from the carrier.
* @returns the error branch of an RpcResult.
*/
export function transportError<T>(error: unknown): RpcResult<T> {
return {
ok: false,
error: { code: 'internal', message: error instanceof Error ? error.message : String(error), details: {} },
}
}

View File

@@ -2,8 +2,8 @@
// RpcRequest<P> and returns RpcResponse<T> (echoing the rpcId); streams yield RpcRequest<frame>
// (the fixture IS the fake server, so it mints frame rpcIds); root respond takes ClientResponse
// and returns RpcReceipt. fx-alpha carries a hand-built history script (60 turns, pageable);
// prompt triggers a chunked streaming replay; cancel stops the replay; one resident pending
// approval (placeholder-card material, subscribed-baseline-replay semantics: stable rpcId reuse).
// 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 { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
@@ -25,6 +25,28 @@ function text(t: string): ContentBlock[] {
return [{ type: 'text', text: t }]
}
const MARKDOWN_FIXTURE = [
'# Markdown fixture',
'',
'Assistant output renders **strong text**, *emphasis*, and `inline code`.',
'',
'- first item',
' - nested item',
'',
'| Surface | State |',
'| --- | --- |',
'| history | rendered |',
'| streaming | stable |',
'',
'[DeepSeek](https://www.deepseek.com)',
'',
'```ts',
'const markdown = true',
'```',
].join('\n')
const USER_MARKDOWN_LITERAL = '用户字面量:# 不渲染 `code` [link](https://example.com)'
function sid(id: string): SessionId {
return id as SessionId
}
@@ -51,7 +73,19 @@ function buildAlphaLog(): SessionEvent[] {
}
for (let turn = 0; turn < 60; turn++) {
push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
push({ type: 'user/message', surfaceOp: 'append', data: { content: text(`问题 ${turn}fixture 历史消息,用于翻页与渲染验收。`), source: { kind: 'user' } } })
const userSeq = push({
type: 'user/message', surfaceOp: 'append',
data: {
content: text(turn === 59 ? USER_MARKDOWN_LITERAL : `问题 ${turn}fixture 历史消息,用于翻页与渲染验收。`),
source: { kind: 'user' },
},
})
if (turn === 0) {
push({
type: 'session/title',
data: { title: 'Fixture 历史会话', messageSeqs: [userSeq], source: { kind: 'fallback' } },
})
}
if (turn % 9 === 4) {
push({ type: 'context/message', surfaceOp: 'append', data: { content: text(`[fixture] 上下文注入turn ${turn}`), source: { kind: 'plugin', plugin: 'fixture' } } })
}
@@ -60,7 +94,7 @@ function buildAlphaLog(): SessionEvent[] {
const withReasoning = turn % 3 === 1
const blocks: ContentBlock[] = []
if (withReasoning) blocks.push({ type: 'reasoning', text: `思考过程 ${turn}:这是一段可折叠的 reasoning 内容。` })
blocks.push({ type: 'text', text: `回答 ${turn}:这是 fixture 生成的历史回复正文。` })
blocks.push({ type: 'text', text: turn === 59 ? MARKDOWN_FIXTURE : `回答 ${turn}:这是 fixture 生成的历史回复正文。` })
if (withTool) {
const callId = `fx-call-${turn}`
blocks.push({ type: 'tool-call', id: callId, name: 'echo', arguments: `{"text":"turn ${turn}"}` } as ContentBlock)
@@ -102,9 +136,25 @@ function buildAlphaLog(): SessionEvent[] {
toolTurn(62, 'edit', '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}', '已编辑')
toolTurn(63, 'write', '{"file_path":"notes/new-demo.txt","content":"hello fixture\\n"}', '已写入')
push({ type: 'turn/start', data: { turn: 64, trigger: { kind: 'message', source: { kind: 'user' } } } })
push({ type: 'user/message', surfaceOp: 'append', data: { content: [{ type: 'image', attachment: FIXTURE_IMAGE_REF }, ...text('历史用户图片')], source: { kind: 'user' } } })
push({
type: 'user/message',
surfaceOp: 'append',
data: {
content: [{ type: 'image', attachment: FIXTURE_IMAGE_REF }, ...text('历史用户图片')],
source: { kind: 'user' },
},
})
push({ type: 'step/start', data: { turn: 64, step: 0 } })
push({ type: 'assistant/message', surfaceOp: 'append', data: { turn: 64, step: 0, content: [...text('结构化模型图片:'), { type: 'image', attachment: FIXTURE_IMAGE_REF }], provenance: { provider: 'fixture', model: 'fx-vision' } } })
push({
type: 'assistant/message',
surfaceOp: 'append',
data: {
turn: 64,
step: 0,
content: [...text('结构化模型图片:'), { type: 'image', attachment: FIXTURE_IMAGE_REF }],
provenance: { provider: 'fixture', model: 'fx-vision' },
},
})
push({ type: 'step/end', data: { turn: 64, step: 0 } })
push({ type: 'turn/end', data: { turn: 64, reason: { kind: 'completed' } } })
return events as unknown as SessionEvent[]
@@ -176,6 +226,20 @@ function viewFor(event: SessionEvent, log: readonly SessionEvent[]): ToolEventVi
return undefined
}
/** Fold the latest fixture title into the host's control-frame projection. */
function titleFrameOf(id: SessionId, log: readonly SessionEvent[]): Extract<MuxFrame, { type: 'session/title' }> | undefined {
const event = log.findLast(item => (item as { type: string }).type === 'session/title')
if (event === undefined) return undefined
const titleEvent = event as unknown as { seq: number; time: number; data: { title: string } }
return {
type: 'session/title',
sessionId: id,
title: titleEvent.data.title,
eventSeq: titleEvent.seq,
updatedAt: titleEvent.time,
}
}
/**
* Message-boundary paging (mirrors the host's paging contract): count
* maxMessages messages
@@ -286,6 +350,41 @@ export function createFixtureApi(): ApiProxy {
const mint = (): ReturnType<typeof RpcId> => RpcId(`fx-rpc-${nextRpc++}`)
/** Resident pending approval (stable rpcId: every mux open replays the same id, matching host replay semantics). */
const pendingApprovalRpcId = mint()
const pendingQuestionRpcId = mint()
let questionPending = true
const fixtureQuestions: Extract<MuxFrame, { type: 'question/requested' }>['questions'] = [
{
id: 'harness-profile',
header: '偏好',
question: '你现在更想招哪类 Agent/Harness 候选人?',
options: [
{ label: '工程落地型 (Recommended)', description: '更看重能直接做 runtime、tool executor、sandbox、trace 和线上问题排查。' },
{ label: '研究潜力型', description: '更看重 Agent 理解、训练评测思路和长期成长空间。' },
{ label: '均衡型', description: '同时要求工程能力和 Agent 认知,但可能筛选门槛更高。' },
],
},
{
id: 'work-mode',
header: '方式',
question: '你希望候选人优先展示哪种工作方式?',
options: [
{ label: '先做小型原型 (Recommended)', description: '用可运行结果尽快验证关键假设。' },
{ label: '先写完整设计', description: '先收敛边界、协议和风险,再开始实现。' },
],
},
{
id: 'signals',
header: '信号',
question: '哪些面试信号最重要?',
detail: '按当前招聘目标选择;跳过则视为不设偏好。',
multiSelect: true,
options: [
{ label: '系统设计' },
{ label: '代码质量' },
{ label: 'Agent 产品判断' },
],
},
]
const muxConns = new Set<StreamConn<MuxFrame>>()
const hostConns = new Set<StreamConn<HostFrame>>()
@@ -331,6 +430,10 @@ export function createFixtureApi(): ApiProxy {
emitMux(view === undefined
? { type: 'session/event', sessionId: id, event }
: { type: 'session/event', sessionId: id, event, view })
if ((event as { type: string }).type === 'session/title') {
// The raw title is already in this log, so the latest-title fold must find it.
emitMux(titleFrameOf(id, log) as Extract<MuxFrame, { type: 'session/title' }>)
}
}
/** At most one in-flight replay per session; cancel clears it. */
@@ -359,6 +462,12 @@ export function createFixtureApi(): ApiProxy {
appendUser(id: string, msg: string): void {
append(sid(id), { type: 'user/message', surfaceOp: 'append', data: { content: text(msg), source: { kind: 'user' } } })
},
/** Append a later durable title revision through the normal raw-event + control-frame path. */
appendTitle(id: string, title: string): void {
const log = logOf(sid(id))
const messageSeqs = log.filter(event => event.type === 'user/message').map(event => event.seq)
append(sid(id), { type: 'session/title', data: { title, messageSeqs, source: { kind: 'provider', provider: 'fixture' } } })
},
/** 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))
@@ -376,8 +485,8 @@ export function createFixtureApi(): ApiProxy {
const step = 0
append(id, { type: 'step/start', data: { turn, step } })
append(id, { type: 'assistant/chunk', data: { turn, step, chunk: { type: 'block-start', index: 0, blockType: 'text' } } })
/* v8 ignore next -- the ?? arm needs a null match, but replyText is never empty (prompt always prefixes 回声). */
const pieces = replyText.match(/.{1,6}/gu) ?? [replyText]
/* v8 ignore next -- the ?? arm needs a null match, but every fixture reply is non-empty. */
const pieces = replyText.match(/[\s\S]{1,6}/gu) ?? [replyText]
let i = 0
const finish = (aborted: boolean): void => {
replays.delete(id)
@@ -436,36 +545,68 @@ export function createFixtureApi(): ApiProxy {
const attachment: ImageAttachmentRef = {
attachmentId: `fixture:${crypto.randomUUID()}` as AttachmentIdType,
mediaType: block.mediaType,
bytes: Math.max(1, Math.floor(block.data.length * 3 / 4) - (block.data.endsWith('==') ? 2 : block.data.endsWith('=') ? 1 : 0)),
bytes: Math.max(
1,
Math.floor(block.data.length * 3 / 4)
- (block.data.endsWith('==') ? 2 : block.data.endsWith('=') ? 1 : 0),
),
width: 160,
height: 90,
...block.name === undefined ? {} : { name: block.name },
}
attachments.set(String(attachment.attachmentId), { attachment, data: block.data })
attachments.set(String(attachment.attachmentId), {
attachment,
data: block.data,
})
return { type: 'image', attachment }
})
if (mode === 'steer' && replays.has(id)) {
// 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: durable, source: { kind: 'user' } } })
append(id, {
type: 'steering/message',
surfaceOp: 'append',
data: { turn, content: durable, source: { kind: 'user' } },
})
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: durable, source: { kind: 'user' } } })
startReply(id, turn, `回声:${userText}。这是 fixture 的流式回复,用于验证打字机增长与定稿切换。`)
append(id, {
type: 'user/message',
surfaceOp: 'append',
data: { content: durable, source: { kind: 'user' } },
})
startReply(
id,
turn,
userText === 'render markdown'
? MARKDOWN_FIXTURE
: `回声:${userText}。这是 fixture 的流式回复,用于验证打字机增长与定稿切换。`,
)
return ok(request, { accepted: true as const })
},
attachment: (request) => {
const stored = attachments.get(String(request.payload.attachmentId))
if (stored === undefined) {
return err(request, { code: 'attachment-error', message: 'fixture attachment missing', details: { reason: 'ATTACHMENT_NOT_FOUND' } })
return err(request, {
code: 'attachment-error',
message: 'fixture attachment missing',
details: { reason: 'ATTACHMENT_NOT_FOUND' },
})
}
if (!logReferencesAttachment(logs.get(request.payload.sessionId) ?? [], String(request.payload.attachmentId))) {
return err(request, { code: 'attachment-error', message: 'fixture attachment is not referenced by this session', details: { reason: 'ATTACHMENT_NOT_REFERENCED' } })
if (!logReferencesAttachment(
logs.get(request.payload.sessionId) ?? [],
String(request.payload.attachmentId),
)) {
return err(request, {
code: 'attachment-error',
message: 'fixture attachment is not referenced by this session',
details: { reason: 'ATTACHMENT_NOT_REFERENCED' },
})
}
return ok(request, stored)
},
@@ -487,8 +628,11 @@ export function createFixtureApi(): ApiProxy {
provider: 'fixture',
model: 'fx-vision',
activeModel: {
provider: 'fixture', id: 'fx-vision', name: 'Fixture Vision',
inputModalities: ['text', 'image'], outputModalities: ['text', 'image'],
provider: 'fixture',
id: 'fx-vision',
name: 'Fixture Vision',
inputModalities: ['text', 'image'],
outputModalities: ['text', 'image'],
},
imageLimits: {
maxImageBytes: 5 * 1024 * 1024,
@@ -506,10 +650,12 @@ export function createFixtureApi(): ApiProxy {
muxConns.add(conn)
const breakNow = (): void => { conn.breakNow() }
streamBreakers.add(breakNow)
// Open baseline: subscribed for attached (running) sessions + pending approval replay (stable rpcId).
// Open baseline: subscribed sessions + pending interactions replayed with stable rpcIds.
for (const s of sessions) {
if (!s.running) continue
conn.push({ rpcId: mint(), payload: { type: 'session/subscribed', sessionId: s.sessionId, lastSeq: (logs.get(s.sessionId)?.length ?? 0) - 1 } })
const title = titleFrameOf(s.sessionId, logs.get(s.sessionId) ?? [])
if (title !== undefined) conn.push({ rpcId: mint(), payload: title })
}
conn.push({
rpcId: pendingApprovalRpcId,
@@ -519,6 +665,14 @@ export function createFixtureApi(): ApiProxy {
toolName: 'dangerous_tool', reason: 'fixture 常驻占位审批(可见不可答)',
},
})
if (questionPending) {
conn.push({
rpcId: pendingQuestionRpcId,
payload: {
type: 'question/requested', sessionId: sid('fx-alpha'), questions: fixtureQuestions,
},
})
}
try {
yield* conn.drain(signal)
} finally {
@@ -548,9 +702,16 @@ export function createFixtureApi(): ApiProxy {
},
},
respond(message: ClientResponse): Promise<RpcReceipt> {
// The v1 UI never answers (PendingCard is visible but not answerable); implemented for type completeness, always not-pending.
void message
return Promise.resolve({ accepted: false, reason: 'not-pending' })
if (!questionPending || message.rpcId !== pendingQuestionRpcId) {
return Promise.resolve({ accepted: false, reason: 'not-pending' })
}
questionPending = false
emitMux({
type: 'question/resolved', sessionId: sid('fx-alpha'),
questionRpcId: pendingQuestionRpcId,
outcome: message.result.ok ? 'answered' : 'cancelled',
})
return Promise.resolve({ accepted: true })
},
}
}

View File

@@ -18,6 +18,7 @@ interface TimingHooks {
setHistoryDelay(ms: number): void
failNextHistory(): void
appendUser(id: string, msg: string): void
appendTitle(id: string, title: string): void
appendSilent(id: string, msg: string): void
breakStreams(): void
}
@@ -113,7 +114,7 @@ describe('createFixtureApi', () => {
const missing = await api.sessions.prompt(req({ sessionId: sid('ghost'), mode: 'queue' as const, content: [{ type: 'text' as const, text: 'x' }] }))
expect(missing.result).toMatchObject({ ok: false, error: { code: 'session-not-found', details: { sessionId: 'ghost' } } })
// Real prompt: replay starts (running flips true), cancel freezes it.
const accepted = await api.sessions.prompt(req({ sessionId: id, mode: 'queue' as const, content: [{ type: 'text' as const, text: '取消我' }] }))
const accepted = await api.sessions.prompt(req({ sessionId: id, mode: 'queue' as const, content: [{ type: 'text' as const, text: 'render markdown' }] }))
expect(accepted.result).toMatchObject({ ok: true, value: { accepted: true } })
await new Promise(resolve => setTimeout(resolve, 120)) // a couple of typewriter ticks
await api.sessions.cancel(req({ sessionId: id }))
@@ -148,14 +149,14 @@ describe('createFixtureApi', () => {
expect(types.at(-1)).toBe('turn/end') // steer did not restart the turn
})
it('mux open replays the baseline: subscribed for running sessions + the resident approval with a stable rpcId', async () => {
it('mux open replays subscribed sessions and resident interactions with stable rpcIds', async () => {
const api = createFixtureApi()
const openOnce = async (): Promise<RpcRequest<MuxFrame>[]> => {
const abort = new AbortController()
const envelopes: RpcRequest<MuxFrame>[] = []
for await (const envelope of api.events.mux(req({}), abort.signal)) {
envelopes.push(envelope)
if (envelopes.length >= 2) abort.abort()
if (envelopes.length >= 4) abort.abort()
}
return envelopes
}
@@ -163,8 +164,11 @@ describe('createFixtureApi', () => {
const second = await openOnce()
expect(first[0]?.payload).toMatchObject({ type: 'session/subscribed', sessionId: 'fx-alpha' })
expect((first[0]?.payload as { lastSeq: number }).lastSeq).toBeGreaterThan(0)
expect(first[1]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
expect(second[1]?.rpcId).toBe(first[1]?.rpcId) // stable rpcId across replays (host replay semantics)
expect(first[1]?.payload).toMatchObject({ type: 'session/title', sessionId: 'fx-alpha', title: 'Fixture 历史会话' })
expect(first[2]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
expect(second[2]?.rpcId).toBe(first[2]?.rpcId) // stable rpcId across replays (host replay semantics)
expect(first[3]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' })
expect(second[3]?.rpcId).toBe(first[3]?.rpcId)
})
it('steer with no replay in flight promotes image bytes to a session-scoped reference', async () => {
@@ -264,9 +268,38 @@ describe('createFixtureApi', () => {
}
})
it('respond is a typed stub: always not-pending', async () => {
it('respond resolves the resident question once and rejects duplicate or unrelated ids', async () => {
const api = createFixtureApi()
expect(await api.respond({ type: 'client-response', rpcId: RpcId('x'), result: { ok: true, value: {} } })).toEqual({ accepted: false, reason: 'not-pending' })
const abort = new AbortController()
let question: RpcRequest<MuxFrame> | undefined
for await (const envelope of api.events.mux(req({}), abort.signal)) {
if (envelope.payload.type !== 'question/requested') continue
question = envelope
abort.abort()
}
if (question === undefined) throw new Error('fixture question missing')
const response = { type: 'client-response' as const, rpcId: question.rpcId, result: { ok: true as const, value: {} } }
expect(await api.respond(response)).toEqual({ accepted: true })
expect(await api.respond(response)).toEqual({ accepted: false, reason: 'not-pending' })
const replayAbort = new AbortController()
const replayed = await collect(api.events.mux(req({}), replayAbort.signal), replayAbort, frames => frames.length === 2)
expect(replayed.every(frame => frame.type !== 'question/requested')).toBe(true)
const cancelledApi = createFixtureApi()
const cancelAbort = new AbortController()
let cancelQuestion: RpcRequest<MuxFrame> | undefined
for await (const envelope of cancelledApi.events.mux(req({}), cancelAbort.signal)) {
if (envelope.payload.type !== 'question/requested') continue
cancelQuestion = envelope
cancelAbort.abort()
}
if (cancelQuestion === undefined) throw new Error('fixture cancellation question missing')
expect(await cancelledApi.respond({
type: 'client-response', rpcId: cancelQuestion.rpcId,
result: { ok: false, error: { code: 'cancelled', message: 'skip', details: {} } },
})).toEqual({ accepted: true })
})
it('describe answers the fixture identity', async () => {
@@ -295,10 +328,15 @@ describe('createFixtureApi', () => {
await new Promise(resolve => setTimeout(resolve, 10))
hooks.appendSilent('fx-alpha', '静默丢帧')
hooks.appendUser('fx-alpha', '正常直播')
hooks.appendTitle('fx-alpha', 'Fixture 修订标题')
await vi.waitFor(() => {
expect(seen.some(f => f.type === 'session/event' && JSON.stringify(f.event.data).includes('正常直播'))).toBe(true)
expect(seen.some(f => f.type === 'session/title' && f.title === 'Fixture 修订标题')).toBe(true)
})
expect(seen.some(f => f.type === 'session/event' && JSON.stringify(f.event.data).includes('静默丢帧'))).toBe(false)
const rawTitleIndex = seen.findIndex(f => f.type === 'session/event' && (f.event as { type: string }).type === 'session/title')
const titleControlIndex = seen.findIndex(f => f.type === 'session/title' && f.title === 'Fixture 修订标题')
expect(titleControlIndex).toBe(rawTitleIndex + 1)
// But history serves the silent event (the client's repull finds it).
const repull = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 5 }))
if (!repull.result.ok) throw new Error('repull failed')

View File

@@ -0,0 +1,19 @@
# @deepseek-ai/dsh-client-hmr
Hot reload for fetch-arrival client plugins. A static-arrival entry composed only into `--dev` graphs (`dsh web --dev`); production graphs omit the row, so the shell-bundled code stays inert.
The plugin subscribes to the webserver's system SSE channel (`GET /plugins/events`) and reloads one plugin per `rebuilt` frame, serialized through a queue (the bundle handoff slot is single). The sequence per frame — `prefetch` (fetch the new bundle before touching anything), `invalidate`, `registry.delete` (before the fiber: a bare fiber dispose trips the vendored Loader's self-dispose branch, which would mark the entry disabled), drain the old fiber, delete `entry.fiber`, remove owned `<style data-plugin>` tags, `entry.refresh()` re-imports and remounts, `fiber.await()` rethrows startup failures loud. Dependents reload through cordis itself: a fiber's activation epoch strings its service providers' uids, so replacing a provider's fiber cascades every dependent with zero client-side graph analysis. Rebuild detection lives on the webserver: in dev mode it stat-polls each plugin's built `lib/client.js` (`fs.watchFile`) and broadcasts the `rebuilt` frame when the bundle's rev changes, so any tsdown watch process producing the bundle triggers HMR with no builder→host channel.
## Model Experience
None, as the reload driver is browser-side machinery; nothing here reaches a model request.
#### KV Cache effect
None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Reload is coarse by design** — a fresh fiber and fresh components; React state inside the reloaded plugin is lost while the data layer (connection/runtime fibers, Session objects) is untouched. react-refresh-grade state preservation conflicts with "re-executing the bundle re-runs the factory" and is deliberately out.
- **No failure rollback** — a reload that fails leaves the entry FAILED and loud in the loader status projection; restoring the previous bundle automatically is deferred until a real need shows.
- **Graph rev is not refreshed by rebuilt frames** — the stale rev is harmless (the bundle endpoint serves no-cache); rev refresh lands with the reconnect-handshake mechanism.

View File

@@ -0,0 +1,51 @@
{
"name": "@deepseek-ai/dsh-client-hmr",
"description": "Dev-only hot-reload driver for fetch-arrival client entries: SSE rebuilt frames → prefetch/invalidate → fiber swap through the vendored Loader entry",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [],
"platform": "web",
"immediately": true
},
"license": "BSD-3-Clause",
"peerDependencies": {
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
"@deepseek-ai/dsh-client-modules": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-client-modules": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"cordis": "^4.0.0-rc.7"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
]
}

View File

@@ -0,0 +1,191 @@
/**
* client-hmr, browser half: hot-reload driver for client plugin entries.
*
* Listens on the host's system SSE channel (`GET /plugins/events`); on a
* `rebuilt` frame it re-fetches the entry's bundle and swaps the cordis
* fiber in place. Every graph entry is a plugin bundle under the web2 model
* — `immediately` rows differ only in stage-one prefetch (a boot
* optimization), so all nine plugin packages share these reload semantics;
* normal packages (react family, cordis, shell, pure libs) are not entries
* and shell changes still mean a page reload. Cascade is zero-touch:
* downstream fibers key their activation epoch on provider fiber uids
* (vendor/cordis/src/fiber.ts `_refresh`), so replacing a provider fiber
* re-cascades natively — reloading a data-layer plugin (connection/runtime)
* cascades into its UI dependents with no HMR-side bookkeeping.
*
* Reload order (lazy CJS table): invalidate (drop the stale factory and
* materialized record) → prefetch (fetch + execute + register the fresh
* factory) → registry-first teardown → drain old fiber unload → remove
* owned `<style data-plugin>` tags → `entry.refresh()` materializes the new
* factory. Invalidate MUST precede prefetch: a live factory makes prefetch
* a no-op, and re-executing a bundle over an undeleted registration is a
* loud duplicate. The swap is safe because execution is pure registration
* under the lazy model — every module side effect (CSS injection included)
* lives in the factory closure and runs at materialization, inside
* refresh(). That also keeps the CSS ordering guarantee: owned styles are
* removed after the old fiber's disposers drained (SlotCore one-owner
* unregister) and before materialization re-injects tags under the same
* stable tag ids.
*
* Failure window: if prefetch rejects after invalidate, the module is left
* unregistered while the OLD fiber keeps running untouched (teardown never
* started) — degraded but recoverable, the next rebuilt frame retries from
* scratch. Consistent with the v1 no-rollback policy below. Known dev-only
* race: a rebuilt frame overlapping a still-in-flight boot arrival shares
* that arrival's task and may materialize the pre-rebuild bytes; the next
* rebuilt frame self-heals.
*
* Why not the naive `entry.fiber.dispose()` → `entry.refresh()` path —
* confirmed against vendor sources:
* 1. `Entry.fiber` is never cleared on dispose (vendor/loader/src/config/
* entry.ts assigns it only in `_init`), so `refresh()` hits its
* `if (this.fiber) return` guard and no-ops.
* 2. A bare `fiber.dispose()` lands in Loader's self-dispose branch
* (vendor/loader/src/index.ts `internal/plugin` case 4: the registry
* still holds the runtime at emit time), which flags the entry
* `disabled: true` — permanently.
* vendor/hmr's reload skeleton documents the fix: delete the runtime record
* FIRST (`registry.delete` → case 4 returns early, the entry stays enabled),
* then rebuild. We additionally clear `entry.fiber` ourselves so
* `entry.refresh()` re-imports and re-plugins through the Loader's own
* `_init` (entry-resolved config, automatic `fiber.entry` rebinding) instead
* of hand-rolling `registry.plugin`. Client entries have exactly one fiber
* per runtime, so `registry.delete` never collaterally disposes siblings.
*
* Self-reload: this plugin is itself a graph entry, so a rebuilt frame may
* name it. The in-flight reload keeps running in the old bundle's closure
* (its EventSource closes with the old fiber's effects); the new bundle's
* apply opens a fresh channel. Frames arriving during the gap are lost —
* acceptable for the dev channel, the next rebuild renotifies.
*
* Failure policy (v1): no rollback. An import failure leaves the entry
* fiberless (the next rebuilt frame retries from scratch); an apply failure
* leaves a FAILED fiber for the shell's status projection. Both log loudly.
*/
import type { Context } from 'cordis'
import type { Entry, Loader } from '@cordisjs/plugin-loader'
import type { WebBootGraph } from '@deepseek-ai/dsh-client-modules'
/**
* Frames on the `GET /plugins/events` system SSE channel (owned host-side by
* dsh-host-webserver's PluginEventFrame). Mirrored here because this is a
* wire boundary: frames arrive as JSON text and are validated at the parse
* point, not shared as a same-process typed seam.
*/
export type PluginsEventFrame =
| { type: 'graph'; graph: WebBootGraph }
| { type: 'rebuilt'; id: string; rev: string }
/** System SSE endpoint pushing graph/rebuilt frames (wire protocol constant). */
export const EVENTS_ENDPOINT = '/plugins/events'
/** Cordis plugin name. */
export const name = 'client-hmr'
/** Required services: the vendored Loader (entry governance) and the client module system (boot provide, service name `modules`). */
export const inject = ['loader', 'modules']
/** Find the loader entry whose module specifier is `id` (entry tree ids are random; the package name lives in `options.name`). */
function findEntry(loader: Loader, id: string): Entry | undefined {
for (const entry of loader.entries()) {
if (entry.options.name === id) return entry
}
return undefined
}
/** Remove every `<style data-plugin>` tag owned by `id` (attribute compared verbatim — no CSS-selector escaping pitfalls). */
function removeOwnedStyles(id: string): void {
for (const el of document.querySelectorAll('style[data-plugin]')) {
if (el.getAttribute('data-plugin') === id) el.remove()
}
}
/**
* Mount the HMR driver: subscribe to the system SSE channel and hot-swap
* rebuilt entries.
* @param ctx - plugin context with `loader` and `modules` available.
*/
export function apply(ctx: Context): void {
// Both are declared injections (typed Context merges: `modules` from the
// client module loader package, `loader` from the vendored Loader).
const modLoader = ctx.modules
const loader: Loader = ctx.loader
async function reload(id: string): Promise<void> {
const entry = findEntry(loader, id)
if (entry === undefined) {
ctx.logger.warn(`client-hmr: rebuilt frame for unknown entry "${id}" (not in the loader tree)`)
return
}
// Invalidate first (drop stale factory + record — a live factory makes
// prefetch a no-op and re-registration a loud duplicate), then run the
// async half while the old fiber still serves: fetch + execute registers
// the fresh factory with zero side effects (lazy CJS — module bodies run
// at materialization, not execution).
modLoader.invalidate(id)
await modLoader.prefetch(id)
const oldFiber = entry.fiber
if (oldFiber !== undefined) {
// Registry-first teardown (see module comment): the runtime record must
// be gone before the fiber's disposer emits internal/plugin, or the
// Loader flags the entry disabled.
const runtime = oldFiber.runtime
if (runtime !== null) entry.ctx.registry.delete(runtime.callback)
// Drain the unload: effect disposers (slots, subscriptions) must finish
// before the new bundle executes and the new apply re-registers.
while (oldFiber.inertia !== undefined) await oldFiber.inertia
delete entry.fiber
}
// Old owned styles go before materialization re-injects them (the CSS
// idempotency guard keys on stable tag ids).
removeOwnedStyles(id)
// Re-init through the entry: fiber cleared above, so refresh() re-imports
// — materializing the prefetched factory (CSS injects here) — and
// re-plugins under the entry context. Import failures are logged by
// Entry._init and leave the entry fiberless (retryable).
await entry.refresh()
// Surface apply failures loudly (v1: no rollback, FAILED state stays).
await entry.fiber?.await()
}
// Serialize reloads: frames can arrive faster than a swap completes, and
// interleaved dispose/execute chains would corrupt the single-slot handoff.
let queue: Promise<void> = Promise.resolve()
const handle = (frame: PluginsEventFrame): void => {
switch (frame.type) {
case 'rebuilt':
queue = queue.then(() => reload(frame.id)).catch((error: unknown) => {
ctx.logger.error(`client-hmr: reload of "${frame.id}" failed`)
ctx.logger.error(error)
})
break
case 'graph':
// Connect-time snapshot, unused in v1. The loader's cached graph rev
// goes stale after rebuilds — harmless, since prefetch hits the
// network anyway (host serves bundles no-cache); graph rev refresh
// lands with the reconnect-handshake mechanism.
break
default:
// Merge-extensible frame union: unknown frame types from newer hosts
// are ignored by design.
break
}
}
ctx.effect(() => {
const source = new EventSource(EVENTS_ENDPOINT)
source.addEventListener('message', (event: MessageEvent<string>) => {
let frame: PluginsEventFrame
try {
frame = JSON.parse(event.data) as PluginsEventFrame
} catch {
// Wire boundary: a malformed dev-channel frame is dropped loudly.
ctx.logger.warn(`client-hmr: unparseable event frame: ${event.data}`)
return
}
handle(frame)
})
return () => { source.close() }
}, 'client-hmr: event source')
}

View File

@@ -0,0 +1,9 @@
/**
* HMR plugin, node half. The package IS a dshClient plugin (dev-only row in
* the host graph): the reload driver lives in its client half in full
* (src/client/); the empty apply exists so the plugin appears in the host
* Loader (lifecycle governance + dshClient discovery).
*/
/** Host plugin body — no host-side behavior for the HMR plugin. */
export function apply(): void {}

View File

@@ -0,0 +1,33 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-client-hmr`.
* @module @deepseek-ai/dsh-client-hmr/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-hmr'
/** Cordis companion plugin name. */
export const name = 'client-hmr-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: a dev-only reload driver — it consumes the loader
* entry tree and module cache but owns no events and no cross-plugin mutable
* state; reload correctness (dispose → style removal → re-execute ordering)
* is observable only through the assembled browser runtime, not a host-side
* event relation.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,14 @@
/**
* Node half of the HMR plugin: an empty apply placeholder (the reload driver
* lives in the client half) whose only contract is mounting and disposing
* cleanly in the host Loader.
*/
import { describe, expect, it } from 'vitest'
import { apply } from '@deepseek-ai/dsh-client-hmr'
describe('hmr node half', () => {
it('apply is a no-op host placeholder', () => {
apply()
expect(true).toBe(true) // reaching here without throw is the contract
})
})

View File

@@ -0,0 +1,30 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types",
"lib": [
"ES2024",
"DOM",
"DOM.Iterable"
],
"types": []
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/loader"
},
{
"path": "../modules"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -0,0 +1,3 @@
import { clientBundle } from '../tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-client-hmr', ['lib/types/index.js', 'lib/types/invariant.js'])

View File

@@ -27,10 +27,6 @@
"platform": "web",
"immediately": true
},
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
},
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-client-runtime": "workspace:^"

View File

@@ -0,0 +1,20 @@
# @deepseek-ai/dsh-client-modules
Client module system: the browser peer of Node's internal ESM loader, built as a lazy CJS table. The web shell mounts the vendored cordis Loader for entry governance (fiber lifecycle, inject waiting, update/refresh) and injects this package's `ClientModuleLoader` as its `internal` seam — the vendored side's only consumption point is `EntryTree.import`, so replacing `internal` replaces exactly "how plugin code arrives" and nothing else.
Lazy CJS model (web2): executing a plugin bundle only REGISTERS its factory (`window.__ModuleLoader__.load({id, factory})`); every module body side effect — CSS injection included — lives in the factory closure and runs at materialization (`factory(require)` → export surface, memoized in `loadCache`), not at script execution. A factory that requires another registered-but-unmaterialized module materializes it recursively, so load order needs no external sequencing; require cycles throw (factory-form CJS cannot deliver partial exports). `<id>/client` and the bare id name the same surface (a plugin bundle IS its package's client half).
Resolution branch order (`import(specifier)`): platform seed word → shell instance; memoized record → surface; shell-own static registry (`registerStatic`, app-shell) → module; registered factory → materialize; graph row (`window.__DSH_BOOT__`) → fetch + execute + materialize; anything else throws — the runtime mirror of the build-time bundle purity gate. The synchronous `require` handed to factories walks the same order minus the fetch branch and records observed edges into the module record. `prefetch` is the stage-one arrival hook (fetch + execute, registration only; concurrent calls share one in-flight task); `invalidate` drops the factory and the materialized record so the next prefetch/import refetches (the HMR hook).
## Model Experience
None, as the module loader is browser-side kernel machinery; nothing here reaches a model request.
#### KV Cache effect
None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Flat module graph by design** — every bundle is one module node whose edges point only at table leaves; the interface (loadCache/edges/invalidate) is shaped for a general module graph so the externalization granularity can change without an interface change.
- **No unload bookkeeping of its own** — style removal and fiber teardown ordering live with the HMR driver (`@deepseek-ai/dsh-client-hmr`); the loader only inventories owned style tag ids per record.

View File

@@ -0,0 +1,37 @@
{
"name": "@deepseek-ai/dsh-client-modules",
"description": "Client module loader: the browser peer of Node's internal ESM loader, consumed by the vendored cordis Loader as its internal seam (resolve/import/loadCache/invalidate over seed table, static registry and fetch bundles)",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"license": "BSD-3-Clause",
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"cordis": "^4.0.0-rc.7"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,175 @@
/**
* Client module system: the browser peer of Node's internal ESM loader, built
* as a lazy CJS table. The vendored cordis Loader consumes this object
* through its `internal` seam (the only call site is `EntryTree.import` →
* `internal.import`), which keeps entry governance (fiber lifecycle, inject
* waiting, update/refresh) entirely on the vendored side while this package
* owns code arrival.
*
* Lazy CJS model (web2 §0): executing a plugin bundle only REGISTERS its
* factory (`window.__ModuleLoader__.load({id, factory})`); every module body
* side effect — including CSS injection — lives inside the factory closure
* and runs at materialization, not at script execution. Materialization
* (factory(require) → export surface) happens on first import/require and is
* memoized in {@link ClientModuleLoader.loadCache}; a factory that requires
* another registered-but-unmaterialized module materializes it recursively,
* so load order needs no external sequencing.
*
* Resolution branch order (import): seed word → shell instance; memoized
* record → surface; static registry (shell-own modules, e.g. app-shell) →
* module; registered factory → materialize; graph row → fetch + execute +
* materialize; anything else → throw (loud — the runtime mirror of the
* build-time bundle purity gate). The synchronous `require` handed to
* factories walks the same order minus the fetch branch: fetching is async,
* so only already-executed bundles can be required — and cross-plugin value
* imports are a build error anyway.
* @module @deepseek-ai/dsh-client-modules
*/
import { ClientModuleLoaderImpl } from './loader.ts'
export { ClientModuleLoaderImpl }
declare module 'cordis' {
interface Context {
/** The client module system the web shell provides at boot (contract C5). */
modules: ClientModuleLoader
}
}
/**
* One composed client entry pushed by the host (web2 §0 graph row).
* `immediately` marks stage-one prefetch; `inject` is informational graph
* metadata (the authoritative edges live in each package's dshClient
* declaration and reach fibers through entry creation).
*
* Wire contract, held on both sides: the producing peer lives in
* `@deepseek-ai/dsh-host-webserver` (host packages keep zero workspace
* dependencies, so neither side imports the other's shape — drift between
* the two declarations is a bug against the web2 contract).
*/
export interface WebBootEntry {
/** Entry name == package name (or a shell-owned pseudo id, e.g. app-shell). */
id: string
/**
* Bundle endpoint, '/plugins/<id>/client.js?rev=<rev>'. Absent only on
* shell-owned pseudo rows (app-shell) whose module is statically registered
* — a row that is neither fetchable nor static-registered fails loud.
*/
url?: string
/** Bundle content hash (cache-busting consistency anchor); absent with url. */
rev?: string
/** Package-name dependency edges, informational (preflight display / HMR diffing). */
inject?: string[]
/** Stage-one prefetch mark: fetch + execute (factory registration) during module-face boot. */
immediately?: boolean
}
/** The composed client entry graph the host injects as `window.__DSH_BOOT__` (dual-held wire contract — see {@link WebBootEntry}). */
export interface WebBootGraph {
/** Consistency anchor over the whole graph (content + bundle hashes). */
rev: string
/** Composed entries; order carries no semantics (activation order is fiber inject waiting). */
entries: WebBootEntry[]
}
/** The shape a client bundle hands to `window.__ModuleLoader__.load` (registration handoff, contract C6). */
export interface ClientPluginHandoff {
/** Plugin id (package name) — the registration key; must match the graph row being executed. */
id: string
/**
* Closure factory holding the whole bundle body: receives the synchronous
* require bound to the module table and returns the bundle's export
* surface. Runs once, at materialization.
*/
factory: (require: (spec: string) => unknown) => Record<string, unknown>
}
/** Window surface this loader owns (bundle side of the handoff protocol) plus the host-injected graph. */
export interface DshWindow {
/** Host-composed entry graph, injected before the shell bundle runs. */
__DSH_BOOT__?: WebBootGraph
/** Bundle registration sink; installed once per page by {@link createClientModuleLoader} (contract C6). */
__ModuleLoader__?: { load(handoff: ClientPluginHandoff): void }
}
/** Per-module bookkeeping in {@link ClientModuleLoader.loadCache} (module-graph seam, flat today). */
export interface ClientModuleRecord {
/** Module id (entry name / package name). */
id: string
/** The materialized export surface (factory `module.exports`, or the shell module for static registrations). */
surface: unknown
/** Owned `<style data-plugin>` tag ids (`data-plugin-css` values) injected during materialization. */
styles: string[]
/** Observed `require()` edges (module-graph seam; only table words can appear today). */
edges: Set<string>
}
/**
* The internal-seam subset the vendored Loader and the client HMR plugin
* consume. Mounted on `ctx.loader.internal` by the shell boot and provided
* as `ctx.modules` (contract C5).
*/
export interface ClientModuleLoader {
/** Discriminant against Node's internal loader shapes ('v1'/'v2'). */
version: 'client'
/** Materialized-module registry: id → record. The governance-side read face for entry export surfaces. */
loadCache: Map<string, ClientModuleRecord>
/**
* Internal seam consumed by the vendored Loader's `tree.import`. Resolves
* `specifier` through the branch order documented on the module, fetching
* and executing a bundle when needed.
* @param specifier - module specifier (entry name or table word).
* @param parentURL - importer URL (unused — the client module graph is flat).
* @param attrs - import attributes (unused; interface parity with Node's seam).
* @returns the module's export surface.
*/
import(specifier: string, parentURL: string, attrs: Record<string, unknown>): Promise<unknown>
/**
* Register a shell-own module (app-shell — code that ships inside the shell
* bundle and never arrives as a plugin bundle).
* @param id - entry name (shell-owned pseudo id).
* @param module - the statically imported module namespace.
*/
registerStatic(id: string, module: unknown): void
/**
* Stage-one arrival: fetch the entry's bundle and execute it, registering
* its factory (no materialization — module side effects wait for import).
* No-op for static-registered ids and ids whose factory is already
* registered; concurrent calls share one in-flight task. To force a fresh
* fetch (HMR), {@link invalidate} first.
* @param id - graph entry name.
*/
prefetch(id: string): Promise<void>
/**
* Full reset of one module: drop its registered factory, its materialized
* record, and any consumed bundle text, so the next prefetch/import
* refetches and re-executes (the HMR invalidation hook).
* @param id - entry name to invalidate.
*/
invalidate(id: string): void
}
/** Options for {@link createClientModuleLoader} (assembled by the web shell at boot). */
export interface ClientModuleLoaderOptions {
/** Host-composed entry graph. */
graph: WebBootGraph
/** Module-table seed: platform-singleton specifier → shell instance. */
staticModules: Record<string, unknown>
/** Bundle fetch seam (parallelizable half). Defaults to same-origin fetch().text(). */
fetchBundle?: (url: string) => Promise<string>
/**
* Bundle execution seam (synchronously performs the load() registration).
* Defaults to a <script> element carrying the code.
*/
executeBundle?: (code: string, url: string) => void
}
/**
* Build the client module system.
* @param options - entry graph, module-table staticModules, fetch/execute seams.
* @returns the loader the shell mounts as `ctx.loader.internal` and provides as `ctx.modules`.
*/
export function createClientModuleLoader(options: ClientModuleLoaderOptions): ClientModuleLoader {
return new ClientModuleLoaderImpl(options)
}

View File

@@ -0,0 +1,34 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-client-modules`.
* @module @deepseek-ai/dsh-client-modules/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-modules'
/** Cordis companion plugin name. */
export const name = 'client-modules-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: the module loader is pre-plugin kernel machinery —
* it emits no cordis events (the vendored Loader owns entry lifecycle events)
* and its mutable state (loadCache, handoff slot) lives below the plugin
* layer where invariant observers cannot mount before it runs; resolve branch
* order and handoff discipline are asserted by the web boot specs against the
* real execution path.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,239 @@
/**
* ClientModuleLoaderImpl — the implementation behind the {@link ClientModuleLoader}
* seam. The conceptual contract (lazy CJS model, resolution branch order) is
* documented on the package module and the public interfaces in `./index.ts`;
* this file owns the state tables and the fetch/execute/materialize machinery.
*/
import type {
ClientModuleLoader, ClientModuleLoaderOptions, ClientModuleRecord,
ClientPluginHandoff, DshWindow, WebBootEntry,
} from './index.ts'
/** A registered-but-unmaterialized bundle: the factory plus its source URL (diagnostics). */
interface RegisteredFactory {
factory: ClientPluginHandoff['factory']
url: string
}
/** Default bundle fetch seam: same-origin fetch().text(). */
const defaultFetchBundle = async (url: string): Promise<string> => {
const res = await fetch(url)
if (!res.ok) throw new Error(`client-modules: bundle fetch ${url} answered ${String(res.status)}`)
return res.text()
}
/** Default bundle execution seam: a <script> element carrying the code. */
const defaultExecuteBundle = (code: string, url: string): void => {
const el = document.createElement('script')
// Inline execution (not src) so the fetch half stays parallelizable; the
// sourceURL comment keeps devtools stack frames attributed to the bundle.
el.textContent = `${code}\n//# sourceURL=${url}`
document.head.appendChild(el)
// Execution is synchronous for inline scripts: the factory is registered by
// now, so the node (and its source text) has no further job. Removing it
// keeps repeated HMR rebuilds from accumulating dead script nodes.
el.remove()
}
const urlOf = (row: WebBootEntry): string => {
// url is conditional on the wire (shell-own pseudo rows omit it); those
// ids resolve through the static registry and never reach a fetch.
if (row.url === undefined) throw new Error(`client-modules: entry "${row.id}" has no bundle url and no static registration`)
return row.url
}
/**
* A plugin bundle IS its package's client half: `<id>/client` (the exports
* subpath external bundles emit) and the bare graph id name the same
* surface, so table lookups normalize the suffix away.
*/
const stripClientSuffix = (spec: string): string =>
spec.endsWith('/client') ? spec.slice(0, -'/client'.length) : spec
/**
* Claim and inventory the <style> tags a factory injected during
* materialization: preset-emitted tags arrive pre-tagged with data-plugin;
* any untagged tag is claimed for the materializing plugin (HMR bookkeeping).
*/
const claimStyles = (id: string): string[] => {
if (typeof document === 'undefined') return []
for (const el of document.querySelectorAll('style:not([data-plugin])')) {
el.setAttribute('data-plugin', id)
}
const owned: string[] = []
for (const el of document.querySelectorAll(`style[data-plugin=${JSON.stringify(id)}]`)) {
owned.push(el.getAttribute('data-plugin-css') ?? id)
}
return owned
}
/**
* The client module system: state tables plus the arrival/materialization
* machinery implementing {@link ClientModuleLoader} (whose members carry the
* seam contract docs). Construction indexes the boot graph and installs the
* `window.__ModuleLoader__` registration sink (contract C6) — once per page.
*/
export class ClientModuleLoaderImpl implements ClientModuleLoader {
readonly version = 'client'
readonly loadCache = new Map<string, ClientModuleRecord>()
private readonly seed: Map<string, unknown>
private readonly statics = new Map<string, unknown>()
private readonly factories = new Map<string, RegisteredFactory>()
/** In-flight prefetch (fetch + execute) per id; concurrent callers share it. */
private readonly pendingArrival = new Map<string, Promise<void>>()
/** Materialization re-entrancy guard: factory-form CJS cannot deliver partial exports, so a cycle is fatal. */
private readonly materializing = new Set<string>()
private readonly graphRows = new Map<string, WebBootEntry>()
// Execution URL of the bundle currently being executed (bound into the
// factory registration so diagnostics can name the source).
private executingUrl = ''
// Graph id of the row currently being executed ('' outside arrive):
// the load sink cross-checks the handoff id against it so a mis-stamped
// bundle cannot register under another entry's identity.
private executingId = ''
private readonly fetchBundle: (url: string) => Promise<string>
private readonly executeBundle: (code: string, url: string) => void
/**
* Build the module system over the host graph.
* @param options - entry graph, module-table staticModules, fetch/execute seams.
*/
constructor(options: ClientModuleLoaderOptions) {
this.seed = new Map(Object.entries(options.staticModules))
this.fetchBundle = options.fetchBundle ?? defaultFetchBundle
this.executeBundle = options.executeBundle ?? defaultExecuteBundle
for (const entry of options.graph.entries) {
if (this.graphRows.has(entry.id)) throw new Error(`client-modules: duplicate graph entry "${entry.id}"`)
this.graphRows.set(entry.id, entry)
}
const win = globalThis as DshWindow
if (win.__ModuleLoader__ !== undefined) throw new Error('client-modules: window.__ModuleLoader__ already installed (double boot?)')
win.__ModuleLoader__ = {
load: (handoff: ClientPluginHandoff): void => {
// Registration is keyed by the handoff id; a duplicate means a bundle
// executed twice without an invalidate — always a bug, always loud.
if (this.factories.has(handoff.id)) throw new Error(`client-modules: duplicate factory registration for "${handoff.id}" (bundle executed twice without invalidate?)`)
// A fetched row's bundle must register the id its row names — a
// mis-stamped bundle registering under another entry's identity
// would let that entry silently materialize foreign exports.
if (this.executingId !== '' && handoff.id !== this.executingId) {
throw new Error(`client-modules: bundle ${this.executingUrl} registered "${handoff.id}" while arriving for "${this.executingId}" (mis-stamped bundle id)`)
}
this.factories.set(handoff.id, { factory: handoff.factory, url: this.executingUrl })
},
}
}
/** Fetch + execute one graph row so its factory is registered (idempotent per in-flight arrival). */
private arrive(row: WebBootEntry): Promise<void> {
const { id } = row
const pending = this.pendingArrival.get(id)
if (pending !== undefined) return pending
if (this.factories.has(id)) return Promise.resolve()
const task = (async (): Promise<void> => {
const url = urlOf(row)
const code = await this.fetchBundle(url)
this.executingUrl = url
this.executingId = id
try {
this.executeBundle(code, url)
} finally {
this.executingUrl = ''
this.executingId = ''
}
if (!this.factories.has(id)) {
throw new Error(`client-modules: bundle ${url} executed without registering "${id}" via __ModuleLoader__.load`)
}
})().finally(() => { this.pendingArrival.delete(id) })
this.pendingArrival.set(id, task)
return task
}
/** Materialize a registered factory (synchronous; memoized in loadCache). */
private materialize(id: string): ClientModuleRecord {
const existing = this.loadCache.get(id)
if (existing !== undefined) return existing
const registered = this.factories.get(id)
/* v8 ignore next -- callers check the factory branch before dispatching here. */
if (registered === undefined) throw new Error(`client-modules: no registered factory for "${id}"`)
if (this.materializing.has(id)) {
throw new Error(`client-modules: require cycle through "${id}" (factory-form CJS cannot deliver partial exports)`)
}
this.materializing.add(id)
try {
const edges = new Set<string>()
const surface = registered.factory(this.makeRequire(edges))
const record: ClientModuleRecord = { id, surface, styles: claimStyles(id), edges }
this.loadCache.set(id, record)
return record
} finally {
this.materializing.delete(id)
}
}
/**
* The synchronous require answered to factories: seed → static → memoized
* record → registered factory (recursive materialization — this is what
* makes load order self-resolving). Fetching is async and therefore
* unreachable from here; an unregistered plugin specifier is loud (and a
* cross-plugin value import is already a build error upstream).
*/
private makeRequire(edges: Set<string>): (spec: string) => unknown {
return (spec: string): unknown => {
edges.add(spec)
if (this.seed.has(spec)) return this.seed.get(spec)
if (this.statics.has(spec)) return this.statics.get(spec)
const id = stripClientSuffix(spec)
const record = this.loadCache.get(id)
if (record !== undefined) return record.surface
if (this.factories.has(id)) return this.materialize(id).surface
throw new Error(
`client-modules: require("${spec}") missed the module table — not a platform seed word, not a shell-own module, `
+ 'and no registered factory (a build-time externals drift, or a forbidden cross-plugin value import)',
)
}
}
async import(specifier: string): Promise<unknown> {
if (this.seed.has(specifier)) return this.seed.get(specifier)
const existing = this.loadCache.get(specifier)
if (existing !== undefined) return existing.surface
if (this.statics.has(specifier)) {
const surface = this.statics.get(specifier)
this.loadCache.set(specifier, { id: specifier, surface, styles: [], edges: new Set() })
return surface
}
if (!this.factories.has(specifier)) {
const row = this.graphRows.get(specifier)
if (row === undefined) {
throw new Error(
`client-modules: cannot resolve "${specifier}" — not a seed word, not a shell-own module, `
+ 'and not a row in the boot graph (the runtime mirror of the bundle purity gate)',
)
}
await this.arrive(row)
}
return this.materialize(specifier).surface
}
registerStatic(id: string, module: unknown): void {
if (this.statics.has(id)) throw new Error(`client-modules: shell-own module "${id}" registered twice`)
this.statics.set(id, module)
}
async prefetch(id: string): Promise<void> {
if (this.statics.has(id)) return
const row = this.graphRows.get(id)
if (row === undefined) throw new Error(`client-modules: prefetch("${id}") — not a graph entry`)
await this.arrive(row)
}
invalidate(id: string): void {
this.factories.delete(id)
this.loadCache.delete(id)
}
}

View File

@@ -0,0 +1,306 @@
// @vitest-environment jsdom
/**
* ClientModuleLoaderImpl behavior: lazy CJS arrival (bundle execution only
* registers the factory), materialization on first import/require with
* memoization and recursive self-sequencing, the resolution branch order,
* shared in-flight arrival, invalidate-refetch (HMR), style claiming, the
* default transport seams, and the loud failure modes (duplicate
* registration, cycles, table misses, double boot).
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
ClientModuleLoaderImpl, createClientModuleLoader,
type ClientModuleLoader, type ClientPluginHandoff, type DshWindow, type WebBootEntry,
} from '../src/index.ts'
const win = globalThis as DshWindow
type Factory = ClientPluginHandoff['factory']
afterEach(() => {
vi.unstubAllGlobals()
delete win.__ModuleLoader__
delete (document as unknown as Record<string, unknown>).__realmBridge
for (const el of document.querySelectorAll('style, script')) el.remove()
})
const row = (id: string): WebBootEntry => ({ id, url: `/plugins/${id}/client.js?rev=0` })
interface Bench {
loader: ClientModuleLoader
fetched: string[]
gates: Map<string, () => void>
}
/**
* Loader over scripted bundles: fetch resolves to the row url (optionally
* gated on a release callback); execute registers the scripted factory
* through the window sink (`null` scripts a bundle that never calls load).
*/
function bench(
entries: WebBootEntry[],
bundles: Record<string, Factory | null> = {},
opts: { seed?: Record<string, unknown>; gated?: string[] } = {},
): Bench {
const fetched: string[] = []
const gates = new Map<string, () => void>()
const loader = createClientModuleLoader({
graph: { rev: 'test', entries },
staticModules: opts.seed ?? {},
fetchBundle: (url) => {
fetched.push(url)
if (opts.gated?.includes(url) === true) {
return new Promise((resolve) => { gates.set(url, () => { resolve(url) }) })
}
return Promise.resolve(url)
},
executeBundle: (code) => {
const id = /\/plugins\/(.+)\/client\.js/.exec(code)?.[1]
const factory = id === undefined ? undefined : bundles[id]
if (factory == null || id === undefined) return
win.__ModuleLoader__?.load({ id, factory })
},
})
return { loader, fetched, gates }
}
describe('lazy CJS arrival', () => {
it('prefetch fetches and executes but does not run the factory', async () => {
const ran: string[] = []
const b = bench([row('a')], { a: () => { ran.push('a'); return {} } })
await b.loader.prefetch('a')
expect(b.fetched).toEqual(['/plugins/a/client.js?rev=0'])
expect(ran).toEqual([])
expect(b.loader.loadCache.size).toBe(0)
})
it('import materializes once and memoizes the export surface', async () => {
const ran: string[] = []
const b = bench([row('a')], { a: () => { ran.push('a'); return { marker: 'a' } } })
const first = await b.loader.import('a', '', {})
const second = await b.loader.import('a', '', {})
expect(first).toBe(second)
expect((first as { marker: string }).marker).toBe('a')
expect(ran).toEqual(['a'])
expect(b.loader.loadCache.get('a')?.id).toBe('a')
})
it('import without prefetch fetches, executes, and materializes in one call', async () => {
const b = bench([row('a')], { a: () => ({ marker: 'direct' }) })
const surface = await b.loader.import('a', '', {})
expect((surface as { marker: string }).marker).toBe('direct')
expect(b.fetched).toHaveLength(1)
})
it('concurrent callers share one in-flight arrival and materialize once', async () => {
const ran: string[] = []
const url = '/plugins/a/client.js?rev=0'
const b = bench([row('a')], { a: () => { ran.push('a'); return { marker: 'a' } } }, { gated: [url] })
const first = b.loader.import('a', '', {})
const second = b.loader.import('a', '', {})
const third = b.loader.prefetch('a')
b.gates.get(url)?.()
const [s1, s2] = await Promise.all([first, second, third])
expect(s1).toBe(s2)
expect(b.fetched).toEqual([url])
expect(ran).toEqual(['a'])
})
it('prefetch after registration is a no-op without invalidate', async () => {
const b = bench([row('a')], { a: () => ({}) })
await b.loader.prefetch('a')
await b.loader.prefetch('a')
expect(b.fetched).toHaveLength(1)
})
})
describe('require resolution', () => {
it('a factory requiring a registered-but-unmaterialized module materializes it recursively', async () => {
const order: string[] = []
const b = bench([row('a'), row('b')], {
a: (req) => {
order.push('a')
const dep = req('b/client') as { helper: string }
return { got: dep.helper }
},
b: () => { order.push('b'); return { helper: 'from-b' } },
})
await b.loader.prefetch('a')
await b.loader.prefetch('b')
const surface = await b.loader.import('a', '', {})
expect((surface as { got: string }).got).toBe('from-b')
expect(order).toEqual(['a', 'b'])
expect(b.loader.loadCache.get('a')?.edges.has('b/client')).toBe(true)
expect(b.loader.loadCache.has('b')).toBe(true)
})
it('require prefers the platform seed word over the module table', async () => {
const react = { marker: 'react' }
const b = bench([row('a')], {
a: req => ({ dep: req('react') }),
}, { seed: { react } })
const surface = await b.loader.import('a', '', {})
expect((surface as { dep: unknown }).dep).toBe(react)
expect(await b.loader.import('react', '', {})).toBe(react)
expect(b.loader.loadCache.has('react')).toBe(false)
})
it('require answers an already-materialized module from the cache', async () => {
let built = 0
const b = bench([row('a'), row('c')], {
a: req => ({ dep: req('c') }),
c: () => { built += 1; return { marker: 'c' } },
})
const c = await b.loader.import('c', '', {})
const a = await b.loader.import('a', '', {})
expect((a as { dep: unknown }).dep).toBe(c)
expect(built).toBe(1)
})
it('a require that misses the module table is loud', async () => {
const b = bench([row('a')], { a: req => ({ dep: req('ghost') }) })
await expect(b.loader.import('a', '', {})).rejects.toThrow('require("ghost") missed the module table')
})
it('a require cycle is fatal', async () => {
const b = bench([row('a'), row('b')], {
a: req => ({ dep: req('b') }),
b: req => ({ dep: req('a') }),
})
await b.loader.prefetch('b')
await expect(b.loader.import('a', '', {})).rejects.toThrow('require cycle through "a"')
})
})
describe('static registry', () => {
it('serves shell-own modules to import and require without any fetch', async () => {
const shell = { marker: 'app-shell' }
const b = bench([row('a'), { id: 'app-shell' }], {
a: req => ({ dep: req('app-shell') }),
})
b.loader.registerStatic('app-shell', shell)
await b.loader.prefetch('app-shell')
expect(await b.loader.import('app-shell', '', {})).toBe(shell)
expect(b.loader.loadCache.get('app-shell')?.styles).toEqual([])
expect((await b.loader.import('a', '', {}) as { dep: unknown }).dep).toBe(shell)
expect(b.fetched).toEqual(['/plugins/a/client.js?rev=0'])
})
it('duplicate static registration is loud', () => {
const b = bench([])
b.loader.registerStatic('app-shell', {})
expect(() => { b.loader.registerStatic('app-shell', {}) }).toThrow('registered twice')
})
})
describe('failure modes', () => {
it('duplicate factory registration is loud', () => {
bench([])
win.__ModuleLoader__?.load({ id: 'x', factory: () => ({}) })
expect(() => win.__ModuleLoader__?.load({ id: 'x', factory: () => ({}) }))
.toThrow('duplicate factory registration for "x"')
})
it('a bundle that never registers its id is loud', async () => {
const b = bench([row('a')], { a: null })
await expect(b.loader.import('a', '', {})).rejects.toThrow('without registering "a"')
})
it('an unknown import specifier is loud', async () => {
const b = bench([])
await expect(b.loader.import('nope', '', {})).rejects.toThrow('cannot resolve "nope"')
})
it('an unknown prefetch id is loud', async () => {
const b = bench([])
await expect(b.loader.prefetch('nope')).rejects.toThrow('prefetch("nope") — not a graph entry')
})
it('a graph row with no url and no static registration is loud', async () => {
const b = bench([{ id: 'ghost' }])
await expect(b.loader.import('ghost', '', {})).rejects.toThrow('no bundle url and no static registration')
})
it('a duplicate graph entry is loud at construction', () => {
expect(() => bench([row('a'), row('a')])).toThrow('duplicate graph entry "a"')
})
it('double boot is loud', () => {
bench([])
expect(() => new ClientModuleLoaderImpl({ graph: { rev: 't', entries: [] }, staticModules: {} }))
.toThrow('already installed (double boot?)')
})
})
describe('HMR reset', () => {
it('invalidate drops the factory and record so the module refetches and re-registers', async () => {
let generation = 0
const b = bench([row('a')], { a: () => ({ generation: ++generation }) })
const first = await b.loader.import('a', '', {})
b.loader.invalidate('a')
expect(b.loader.loadCache.has('a')).toBe(false)
await b.loader.prefetch('a')
const second = await b.loader.import('a', '', {})
expect(b.fetched).toHaveLength(2)
expect((first as { generation: number }).generation).toBe(1)
expect((second as { generation: number }).generation).toBe(2)
})
})
describe('style claiming', () => {
it('claims untagged style tags for the materializing plugin and inventories owned css ids', async () => {
const foreign = document.createElement('style')
foreign.setAttribute('data-plugin', 'other')
document.head.appendChild(foreign)
const b = bench([row('a')], {
a: () => {
document.head.appendChild(document.createElement('style'))
const tagged = document.createElement('style')
tagged.setAttribute('data-plugin', 'a')
tagged.setAttribute('data-plugin-css', 'sheet-1')
document.head.appendChild(tagged)
return {}
},
})
await b.loader.import('a', '', {})
expect(b.loader.loadCache.get('a')?.styles).toEqual(['a', 'sheet-1'])
expect(document.querySelectorAll('style[data-plugin="a"]')).toHaveLength(2)
expect(foreign.getAttribute('data-plugin')).toBe('other')
})
it('materialization without a document skips the style inventory', async () => {
const b = bench([row('a')], { a: () => ({}) })
vi.stubGlobal('document', undefined)
try {
await b.loader.import('a', '', {})
} finally {
vi.unstubAllGlobals()
}
expect(b.loader.loadCache.get('a')?.styles).toEqual([])
})
})
describe('default transport seams', () => {
it('fetches same-origin and executes through an inline script tag', async () => {
// In a browser the loader's globalThis IS the page window; vitest's jsdom
// evaluates <script> in a separate realm that shares only the document,
// so the fixture bundle restores the sink from a document bridge before
// using the normal calling convention.
const code = 'window.__ModuleLoader__ = document.__realmBridge;\n'
+ 'window.__ModuleLoader__.load({ id: "dee", factory: function () { return { marker: "via-script" } } })'
vi.stubGlobal('fetch', async () => ({ ok: true, text: async () => code }))
const loader = createClientModuleLoader({ graph: { rev: 't', entries: [row('dee')] }, staticModules: {} })
;(document as unknown as Record<string, unknown>).__realmBridge = win.__ModuleLoader__
const surface = await loader.import('dee', '', {})
expect((surface as { marker: string }).marker).toBe('via-script')
// The script node is removed right after its synchronous execution —
// repeated HMR rebuilds must not accumulate dead script nodes.
expect([...document.querySelectorAll('script')]).toEqual([])
})
it('a non-ok bundle response is loud with the status', async () => {
vi.stubGlobal('fetch', async () => ({ ok: false, status: 404 }))
const loader = createClientModuleLoader({ graph: { rev: 't', entries: [row('dee')] }, staticModules: {} })
await expect(loader.prefetch('dee')).rejects.toThrow('answered 404')
})
})

View File

@@ -0,0 +1,24 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types",
"lib": [
"ES2024",
"DOM",
"DOM.Iterable"
],
"types": []
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -2,6 +2,10 @@
Client cordis boot + core services: SlotsService (Service wrapper over SlotCore + 'slots/changed' bridge), SessionsService (list store projection, scope tree, bindings, ancestry, and the latest successful host capability description), the Session object layer (exported as a type; instances are owned and handed out by SessionsService — the manager/paging internals stay package-internal, tests reach them via src), ClientLoader (`./loader` subpath, statically held by the shell). Contract: api-contracts v3 §4.
## Session title projection
`SessionManager` retains the latest validated `session/title` control snapshot independently of list and session-instance arrival. Newer event seqs replace older snapshots, title timestamps contribute to list recency, and a subscription baseline discards any retained title beyond its `lastSeq` before the optional folded title arrives. Explicit session removal also clears the retained title. The client-facing `SessionSummary.title` is therefore only the actual durable title; `displayTitle` is always present and falls back through the cwd basename and session id. A cold persisted session keeps that fallback until opening or resuming it causes the host to fold and project its log-backed title.
## Model Experience
None, as the client runtime hosts browser-side services and the session object layer; nothing here reaches a model request.
@@ -15,4 +19,3 @@ None; this package neither assembles nor sends a provider request.
- **`loader.unload` is a stub (throws not-implemented)** — the full chain (fiber dispose → registration cascade → style removal) lands with the HMR project.
- **Scope teardown is stage-driven, single-occupant today** — the staged session follows `list.current` exactly (staging is the open signal: the event window opens ⟺ the session is on stage); a removed-while-staged session's scope survives frozen until the stage moves on, not until true observer count reaches zero. Resolution (`cell()`/`binding()`/`scope()`) is pure addressing, render-safe. The staged state can widen to a multi-pane list when concurrent panes land.
- **Value imports of this package from plugin bundles must use the `/client` subpath** — the bare package name is not in the loader externals table and inlines a second module instance, whose private scope-tag Symbol never matches (the empty-state P0 postmortem).
- **`SessionSummary.title` is a display projection** — the wire summary carries no title yet; the cwd basename stands in, then the raw id.

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-client-runtime",
"description": "Client cordis boot and core services: SlotsService, SessionsService (scope tree + object layer), ClientLoader",
"description": "Client core services: SlotsService, SessionsService (scope tree + object layer)",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -15,10 +15,6 @@
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./loader": {
"types": "./lib/types/client/loader/index.d.ts",
"default": "./lib/loader.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
@@ -38,6 +34,7 @@
"@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"immer": "^10.1.1",
@@ -57,7 +54,6 @@
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/loader.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"

View File

@@ -2,17 +2,15 @@
* Browser half: the whole runtime contract surface (api-contracts v3 §4) —
* SlotsService (declaration ledger + renderer seam + store axis, built-in
* 'root'), SessionsService (list store + current selection + scope tree +
* object layer), the ClientLoader interface, and the cordis Context/Events
* merges. apply
* mounts ctx.slots + ctx.sessions and wires the connection stream loop into
* the object layer. The loader machinery implementation is NOT in the plugin
* bundle — it ships via the package's `./loader` subpath, statically held by
* the web shell (a loader cannot load itself).
* object layer), and the cordis Context/Events merges. apply mounts
* ctx.slots + ctx.sessions and wires the connection stream loop into the
* object layer. A static-arrival entry: the web shell bundles this module
* and mounts it through the host graph (module loading lives in
* @deepseek-ai/dsh-client-modules, entry governance in the vendored Loader).
*/
import type { Context } from 'cordis'
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import type { SnapshotStore } from './contract/store.ts'
import { SlotsService } from './slots.ts'
import { SessionsService } from './sessions/service.ts'
import type { SessionListState } from './sessions/service.ts'
@@ -34,9 +32,12 @@ export type {
} from './contract/store.ts'
export type {
AssistantBlock, AssistantMessageNode, ContextMessageNode, ConversationNode, ConversationSnapshot,
PendingInteraction, RunningToolCall, SteeringMessageNode,
RunningToolCall, SteeringMessageNode,
ToolResultNode, UnknownSurfaceNode, UserMessageNode,
} from './sessions/conversation.ts'
// PendingWait is a value export: tests construct fixture waits directly.
export { PendingWait } from './sessions/pending.ts'
export type { PendingInteraction, PendingKind, PendingPayloads } from './sessions/pending.ts'
export type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
// ---- Narrowed aliases (the single narrowing point of the slot type chain:
@@ -92,48 +93,9 @@ declare module 'cordis' {
interface Context {
slots: import('./slots.ts').SlotsService
sessions: import('./sessions/service.ts').SessionsService
loader: ClientLoader
}
}
/** One __DSH_BOOT__ manifest row. */
export interface BootPluginEntry { id: string; url: string; inject: string[]; immediately?: boolean }
/** Per-plugin load status store shape. */
export type LoaderStatus = Record<string, 'loading' | 'active' | 'failed'>
/**
* Client bundle loader. The immediately group loads first (parallel fetch,
* apply in inject topology order); remaining plugins follow in inject
* topology. Loaded bundle export surfaces are registered back into the
* require module table. Implementation lives in the `./loader` subpath
* (shell-held machinery).
*/
export interface ClientLoader {
/** Start loading from window.__DSH_BOOT__ (non-blocking). */
start(): void
/**
* Load one plugin bundle (script inject, factory handoff, ctx.plugin, style registration).
* @param id - plugin id (package name).
*/
load(id: string): Promise<void>
/**
* Unload a plugin. P-I: not implemented (full chain lands with HMR).
* @param id - plugin id.
*/
unload(id: string): Promise<void>
/** Resolves when every manifest plugin reached active (AppRoot gates the real UI on this). */
settled(): Promise<void>
/**
* Read a loaded module's export surface from the module table (same
* implementation the bundle-facing require uses; unknown spec throws).
* @param spec - module specifier (package name or seeded library id).
*/
requireModule(spec: string): unknown
/** Per-plugin status store. */
readonly status: SnapshotStore<LoaderStatus>
}
/** Required services: the wire handle mounted by the connection plugin. */
export const inject = ['connection']

View File

@@ -1,247 +0,0 @@
/**
* ClientLoader implementation (shell-held machinery — the loader cannot load
* itself, so the web shell imports this subpath statically and mounts the
* instance as ctx.loader; the runtime package's own client bundle never
* includes it).
*
* Load chain per plugin: fetch bundle text → execute (script injection) → the
* bundle calls window.DSHClientProxy.loadPlugin({id, factory}) (single-slot
* handoff, id reconciled) → factory(require) with require bound to the module
* table → ctx.plugin(exports.apply) → the export surface is registered into
* the module table under the plugin id (inject topology guarantees later
* loaders can require earlier ones) → <style data-plugin> ownership recorded.
*
* start(): the `immediately` group is fetched in parallel and executed in
* group-internal inject topology (execution is serial — the handoff slot is
* single); a full-group barrier precedes the remaining plugins, which then
* load one by one in inject topology.
*/
import type { Context } from 'cordis'
import { createSnapshotStore } from '../contract/store.ts'
import type { BootPluginEntry, ClientLoader, LoaderStatus } from '../index.ts'
export type { BootPluginEntry, ClientLoader, LoaderStatus } from '../index.ts'
/** The shape a client bundle hands to window.DSHClientProxy.loadPlugin. */
export interface ClientPluginHandoff {
/** Plugin id (package name) — must match the manifest row being loaded. */
id: string
/**
* Closure factory: receives the DI require and returns the module's export
* surface; an `apply` export is applied as a cordis plugin.
*/
factory: (require: (spec: string) => unknown) => Record<string, unknown>
}
/** Window surface the loader owns (bundle side of the handoff protocol). */
interface DshWindow {
__DSH_BOOT__?: { plugins: BootPluginEntry[] }
DSHClientProxy?: { loadPlugin(handoff: ClientPluginHandoff): void }
}
/** Options for createClientLoader (assembled by the web shell at boot). */
export interface ClientLoaderOptions {
/** Client root context: plugin applies mount under it. */
ctx: Context
/**
* Seeded module table: pure-library entities (react, react-dom, cordis,
* ui-slots, web-react, ui-primitives). The loader takes ownership and
* registers loaded bundle export surfaces alongside them.
*/
modules: Record<string, unknown>
/**
* Boot manifest; defaults to window.__DSH_BOOT__. Fixture pages inject the
* same protocol shape.
*/
boot?: { plugins: BootPluginEntry[] }
/** Bundle fetch seam (parallelizable half). Defaults to same-origin fetch().text(). */
fetchBundle?: (url: string) => Promise<string>
/**
* Bundle execution seam (serial half; execution synchronously performs the
* loadPlugin handoff). Defaults to a <script> element carrying the code.
*/
executeBundle?: (code: string, url: string) => void
}
/** Per-plugin bookkeeping across the load chain. */
interface PluginRecord {
entry: BootPluginEntry
state: 'idle' | 'loading' | 'active' | 'failed'
fetch?: Promise<string>
load?: Promise<void>
}
const NOT_LOADED = Symbol('dsh.loader.not-loaded')
/**
* Build the client bundle loader.
* @param options - ctx, seeded module table, boot manifest, fetch/execute seams.
* @returns the ClientLoader the shell mounts as ctx.loader.
*/
export function createClientLoader(options: ClientLoaderOptions): ClientLoader {
const { ctx } = options
const win = globalThis as DshWindow
const boot = options.boot ?? win.__DSH_BOOT__
if (boot === undefined) throw new Error('client-loader: no boot manifest (window.__DSH_BOOT__ missing)')
const modules = new Map<string, unknown>(Object.entries(options.modules))
const records = new Map<string, PluginRecord>()
for (const entry of boot.plugins) {
if (records.has(entry.id)) throw new Error(`client-loader: duplicate manifest id "${entry.id}"`)
records.set(entry.id, { entry, state: 'idle' })
}
const status = createSnapshotStore<LoaderStatus>({})
const publish = (id: string, state: 'loading' | 'active' | 'failed'): void => {
status.update((draft) => { draft[id] = state })
}
// Single-slot handoff: bundle execution synchronously calls loadPlugin;
// doLoad arms the slot before executing and reconciles the id after.
let slot: ClientPluginHandoff | typeof NOT_LOADED = NOT_LOADED
if (win.DSHClientProxy !== undefined) throw new Error('client-loader: window.DSHClientProxy already installed (double boot?)')
win.DSHClientProxy = {
loadPlugin: (handoff: ClientPluginHandoff): void => {
if (slot !== NOT_LOADED) {
throw new Error(`client-loader: overlapping loadPlugin handoff (got "${handoff.id}" while a previous handoff is unclaimed)`)
}
slot = handoff
},
}
const fetchBundle = options.fetchBundle ?? (async (url: string): Promise<string> => {
const res = await fetch(url)
if (!res.ok) throw new Error(`client-loader: bundle fetch ${url} answered ${String(res.status)}`)
return res.text()
})
const executeBundle = options.executeBundle ?? ((code: string, url: string): void => {
const el = document.createElement('script')
// Inline execution (not src) so the fetch half stays parallelizable; the
// sourceURL comment keeps devtools stack frames attributed to the bundle.
el.textContent = `${code}\n//# sourceURL=${url}`
document.head.appendChild(el)
})
const requireModule = (spec: string): unknown => {
if (!modules.has(spec)) {
throw new Error(`client-loader: module "${spec}" is not available — not a seeded library and no loaded plugin registered it (check dshClient.inject ordering)`)
}
return modules.get(spec)
}
/** Tag styles the bundle injected during execution (unload bookkeeping; plugin CSS lands untagged). */
const claimStyles = (id: string): void => {
if (typeof document === 'undefined') return
for (const el of document.querySelectorAll('style:not([data-plugin])')) {
el.setAttribute('data-plugin', id)
}
}
/** Start (or reuse) the parallelizable fetch half. */
const prefetch = (record: PluginRecord): Promise<string> =>
(record.fetch ??= fetchBundle(record.entry.url))
async function doLoad(record: PluginRecord): Promise<void> {
const { id } = record.entry
record.state = 'loading'
publish(id, 'loading')
try {
// Dependencies must already be active (start() sequences this; direct
// load() callers get the same fail-loud check).
for (const dep of record.entry.inject) {
const depRecord = records.get(dep)
if (depRecord === undefined) throw new Error(`client-loader: "${id}" injects unknown plugin "${dep}"`)
if (depRecord.state !== 'active') throw new Error(`client-loader: "${id}" loaded before its dependency "${dep}" is active`)
}
const code = await prefetch(record)
executeBundle(code, record.entry.url)
if (slot === NOT_LOADED) throw new Error(`client-loader: bundle ${record.entry.url} executed without calling DSHClientProxy.loadPlugin`)
const handoff = slot
slot = NOT_LOADED
if (handoff.id !== id) throw new Error(`client-loader: bundle id mismatch — manifest "${id}" vs handoff "${handoff.id}"`)
const exports = handoff.factory(requireModule)
if (typeof exports.apply !== 'function') throw new Error(`client-loader: plugin "${id}" exports no apply function`)
// The whole export surface is the plugin: cordis object-plugin form
// keeps the bundle's exported `inject`/`name` (an apply-only pass would
// silently drop the dependency declaration — postmortem 0001).
const fiber = ctx.plugin(exports as { apply: (ctx: Context) => void })
await fiber.await()
// Register under both specifier forms bundles emit: the bare package
// name (deep-import rewrites) and the /client subpath (CLIENT_EXTERNALS
// form) — the loaded surface IS the client half either way.
modules.set(id, exports)
modules.set(`${id}/client`, exports)
claimStyles(id)
record.state = 'active'
publish(id, 'active')
} catch (error) {
record.state = 'failed'
publish(id, 'failed')
throw error
}
}
const load = (id: string): Promise<void> => {
const record = records.get(id)
if (record === undefined) return Promise.reject(new Error(`client-loader: unknown plugin "${id}"`))
record.load ??= doLoad(record)
return record.load
}
/** Topologically order `ids` by inject (edges inside the set only — an early-group member never waits on a later-group one). */
const topo = (ids: string[]): string[] => {
const pool = new Set(ids)
const ordered: string[] = []
const done = new Set<string>()
const visiting = new Set<string>()
const visit = (id: string): void => {
if (done.has(id)) return
if (visiting.has(id)) throw new Error(`client-loader: inject cycle through "${id}"`)
visiting.add(id)
const record = records.get(id)
/* v8 ignore next -- ids come from records; unknown ids are caught per-dep below. */
if (record === undefined) throw new Error(`client-loader: manifest references unknown plugin "${id}"`)
for (const dep of record.entry.inject) {
if (!records.has(dep)) throw new Error(`client-loader: "${id}" injects unknown plugin "${dep}"`)
if (pool.has(dep)) visit(dep)
}
visiting.delete(id)
done.add(id)
ordered.push(id)
}
for (const id of ids) visit(id)
return ordered
}
let settledPromise: Promise<void> | undefined
async function run(): Promise<void> {
const all = [...records.values()]
const early = all.filter(r => r.entry.immediately === true)
const rest = all.filter(r => r.entry.immediately !== true)
// Early group: parallel fetch (all requests in flight at once), serial
// inject-topology execution, full-group barrier before anything else.
const earlyOrder = topo(early.map(r => r.entry.id))
for (const record of early) void prefetch(record).catch(() => {}) // surfaced by the awaited load below
for (const id of earlyOrder) await load(id)
// Remaining plugins: one by one in inject topology.
for (const id of topo(rest.map(r => r.entry.id))) await load(id)
}
return {
start: () => {
settledPromise ??= run()
// Failures surface through settled()/status — start() itself is fire-and-forget.
settledPromise.catch(() => {})
},
load,
unload: (id: string) => Promise.reject(new Error(`client-loader: unload("${id}") is not implemented (lands with HMR)`)),
settled: () => {
if (settledPromise === undefined) throw new Error('client-loader: settled() before start()')
return settledPromise
},
requireModule,
status,
}
}

View File

@@ -5,7 +5,8 @@
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type { RpcError, RpcId, SessionId, ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
import type { RpcError, SessionId, ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
import type { PendingInteraction } from './pending.ts'
/** Assistant content blocks sorted by what the UI cares about
* (text body / collapsible reasoning / tool-call card head / other fallback). */
@@ -35,7 +36,8 @@ export function toAssistantBlock(block: ContentBlock): AssistantBlock {
case 'text': return { kind: 'text', text: block.text }
case 'reasoning': return { kind: 'reasoning', text: block.text }
case 'image': return {
kind: 'image', attachment: block.attachment,
kind: 'image',
attachment: block.attachment,
...block.alt === undefined ? {} : { alt: block.alt },
}
case 'tool-call': return { kind: 'tool-call', callId: String(block.id), name: block.name, argsRaw: block.arguments }
@@ -127,11 +129,6 @@ export interface RunningToolCall {
callView: ToolCallView | null
}
/** Approval/question placeholder cards (visible, not answerable;
* rpcId = the requested frame's envelope id, the future respond backfill key). */
export type PendingInteraction =
| { kind: 'approval'; rpcId: RpcId; approvalId: string; toolName: string; callId?: string; reason?: string }
| { kind: 'question'; rpcId: RpcId; questions: readonly unknown[] }
/** In-progress assistant output (chunk accumulator product). */
export interface PartialAssistant {

View File

@@ -4,9 +4,15 @@
import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client'
/** Host list summary enriched with the latest mux-projected durable title. */
export interface TitledSessionSummary extends SessionSummary {
title?: string
}
/** One flattened session-list row (summary + lineage indent depth). */
export interface SessionListEntry {
sessionId: SessionId
title?: string
updatedAt: number
running: boolean
parentSessionId?: SessionId
@@ -21,12 +27,12 @@ export interface SessionListEntry {
* @param summaries - the host's session.list items.
* @returns display rows in render order.
*/
export function flattenLineage(summaries: readonly SessionSummary[]): SessionListEntry[] {
const byId = new Map<SessionId, SessionSummary>()
export function flattenLineage(summaries: readonly TitledSessionSummary[]): SessionListEntry[] {
const byId = new Map<SessionId, TitledSessionSummary>()
for (const s of summaries) byId.set(s.sessionId, s)
const children = new Map<SessionId, SessionSummary[]>()
const roots: SessionSummary[] = []
const children = new Map<SessionId, TitledSessionSummary[]>()
const roots: TitledSessionSummary[] = []
for (const s of summaries) {
if (s.parentSessionId !== undefined && byId.has(s.parentSessionId)) {
const list = children.get(s.parentSessionId) ?? []
@@ -37,12 +43,12 @@ export function flattenLineage(summaries: readonly SessionSummary[]): SessionLis
}
}
const byUpdatedDesc = (a: SessionSummary, b: SessionSummary): number => b.updatedAt - a.updatedAt
const byUpdatedDesc = (a: TitledSessionSummary, b: TitledSessionSummary): number => b.updatedAt - a.updatedAt
roots.sort(byUpdatedDesc)
const out: SessionListEntry[] = []
const visited = new Set<SessionId>()
const walk = (s: SessionSummary, depth: number): void => {
const walk = (s: TitledSessionSummary, depth: number): void => {
if (visited.has(s.sessionId)) {
console.warn(`[web-runtime] lineage cycle at ${s.sessionId}; emitting as root`)
return

View File

@@ -3,8 +3,10 @@
// List data never enters zustand; React connects via subscribe/getListSnapshot.
import type { IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client'
import { transportError } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionListEntry } from './lineage.ts'
// Value import from the inline-safe wire layer (not the connection plugin):
// plugin-to-plugin value imports are a bundle purity error.
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { SessionListEntry, TitledSessionSummary } from './lineage.ts'
import { flattenLineage } from './lineage.ts'
import { Notifier } from './notifier.ts'
import { Session } from './session.ts'
@@ -19,6 +21,13 @@ export interface SessionListSnapshot {
/** Per-session cap for pre-instantiation approval/question buffering (low-frequency frames; a few dozen covers any real backlog). */
const PENDING_BUFFER_CAP = 32
/** Latest title control snapshot retained independently of list/instance arrival. */
interface SessionTitleSnapshot {
title: string
eventSeq: number
updatedAt: number
}
/** Instance cluster + frame entry + the session list (see the web client architecture RFC). */
export class SessionManager {
private readonly sessions = new Map<SessionId, Session>()
@@ -27,6 +36,7 @@ export class SessionManager {
* drop-and-backfill path; replayed and cleared on instantiation. Bounded per session (these
* frames are low-frequency; overflow drops oldest) and dropped on session-removed (audit S7). */
private readonly pendingBuffers = new Map<SessionId, RpcRequest<MuxFrame>[]>()
private readonly titleSnapshots = new Map<SessionId, SessionTitleSnapshot>()
private summaries: SessionSummary[] = []
private listState: 'idle' | 'loading' | 'error' = 'idle'
private listError: RpcError | null = null
@@ -158,6 +168,24 @@ export class SessionManager {
handleMuxEnvelope(envelope: RpcRequest<MuxFrame>): void {
const frame = envelope.payload
if (frame.type === 'stream/error') return // Controller already treats this as stream failure
if (frame.type === 'session/title') {
const current = this.titleSnapshots.get(frame.sessionId)
if (current !== undefined && current.eventSeq >= frame.eventSeq) return
this.titleSnapshots.set(frame.sessionId, {
title: frame.title,
eventSeq: frame.eventSeq,
updatedAt: frame.updatedAt,
})
this.notifier.markDirty()
return
}
if (frame.type === 'session/subscribed') {
const current = this.titleSnapshots.get(frame.sessionId)
if (current !== undefined && current.eventSeq > frame.lastSeq) {
this.titleSnapshots.delete(frame.sessionId)
this.notifier.markDirty()
}
}
const session = this.sessions.get(frame.sessionId)
if (session === undefined) {
// Approval/question frames never hit history: buffer for replay on instantiation;
@@ -204,6 +232,7 @@ export class SessionManager {
this.summaries = this.summaries.filter(s => s.sessionId !== frame.sessionId)
this.sessions.get(frame.sessionId)?.handleRemoved() // instance survives (resident-instance rule), only flagged in the snapshot
this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation
this.titleSnapshots.delete(frame.sessionId)
this.notifier.markDirty()
return
}
@@ -230,12 +259,19 @@ export class SessionManager {
}
private buildListSnapshot(): SessionListSnapshot {
const fresh = flattenLineage(this.summaries)
const merged: TitledSessionSummary[] = this.summaries.map((summary) => {
const title = this.titleSnapshots.get(summary.sessionId)
return title === undefined
? summary
: { ...summary, title: title.title, updatedAt: Math.max(summary.updatedAt, title.updatedAt) }
})
const fresh = flattenLineage(merged)
const items = fresh.map((entry) => {
const prev = this.entryCache.get(entry.sessionId)
if (
prev !== undefined && prev.updatedAt === entry.updatedAt && prev.running === entry.running
&& prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd && prev.depth === entry.depth
&& prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd
&& prev.title === entry.title && prev.depth === entry.depth
) return prev
this.entryCache.set(entry.sessionId, entry)
return entry

View File

@@ -0,0 +1,79 @@
// PendingWait: the carrier-protocol half of a pending host interaction. The runtime owns only
// envelope knowledge (rpcId backfill into a client-response); domain result encoding belongs to
// the interaction's consumer package.
import type {
ClientResponse, MuxFrame, RpcId, RpcReceipt, SessionId,
} from '@deepseek-ai/dsh-client-connection/client'
/** Kind-keyed payload map: the requested frame's domain fields (envelope fields stripped). */
export interface PendingPayloads {
approval: Omit<Extract<MuxFrame, { type: 'approval/requested' }>, 'type' | 'sessionId'>
question: Omit<Extract<MuxFrame, { type: 'question/requested' }>, 'type' | 'sessionId'>
}
/** Pending-interaction discriminant (the keys of PendingPayloads). */
export type PendingKind = keyof PendingPayloads
/** Kind-discriminated union of concrete waits: narrowing on `kind` types `payload`. */
export type PendingInteraction = { [K in PendingKind]: PendingWait<K> }[PendingKind]
/** Key prefixes, one per kind (the key doubles as the Session pending-map key). */
const KEY_PREFIX: Record<PendingKind, string> = { approval: 'a', question: 'q' }
/**
* One pending host-owned interaction wait: an immutable render face
* (kind/key/sessionId/payload) plus the response carrier. respond() backfills
* the requested frame's rpcId into a client-response envelope — no consumer
* ever sees the raw rpcId. Settlement is expressed only by pending-list
* membership (the settled flag is a fail-loud guard, not a render input).
*/
export class PendingWait<K extends PendingKind = PendingKind> {
/** Interaction kind (union discriminant). */
readonly kind: K
/** Opaque render identity, `<prefix>:<rpcId>` — stable across baseline replay, usable as a React key. */
readonly key: string
/** Owning session. */
readonly sessionId: SessionId
/** The requested frame's domain fields, verbatim. */
readonly payload: PendingPayloads[K]
#settled = false
readonly #rpcId: RpcId
readonly #respond: (message: ClientResponse) => Promise<RpcReceipt>
/**
* Minted by Session on a requested frame (public construction is the test-fixture path).
* @param kind - interaction kind.
* @param rpcId - the requested frame's stable envelope id (kept private; respond echoes it).
* @param sessionId - owning session.
* @param payload - the requested frame's domain fields.
* @param respond - the client-response carrier (api.respond).
*/
constructor(
kind: K, rpcId: RpcId, sessionId: SessionId, payload: PendingPayloads[K],
respond: (message: ClientResponse) => Promise<RpcReceipt>,
) {
this.kind = kind
this.key = `${KEY_PREFIX[kind]}:${rpcId}`
this.sessionId = sessionId
this.payload = payload
this.#rpcId = rpcId
this.#respond = respond
}
/**
* Send a result for this wait: wraps it into the client-response envelope
* with the rpcId backfilled. Throws synchronously once settled.
* @param result - the result shell (ok value / error envelope), domain-encoded by the caller.
* @returns the carrier receipt.
*/
respond(result: ClientResponse['result']): Promise<RpcReceipt> {
if (this.#settled) throw new Error(`pending wait ${this.key} is already settled`)
return this.#respond({ type: 'client-response', rpcId: this.#rpcId, result })
}
/** Session-only settlement mark (the authoritative resolved frame arrived); respond() throws afterwards. */
markSettled(): void {
this.#settled = true
}
}

View File

@@ -25,7 +25,10 @@ import type { Session } from './session.ts'
/** Session list row projected from the host list RPC plus live stream increments. */
export interface SessionSummary {
id: SessionId
title: string
/** Latest durable log-backed title, absent until the host projects one. */
title?: string
/** Human-facing label: durable title, project basename, then session id. */
displayTitle: string
cwd?: string
parentId?: SessionId
running: boolean
@@ -62,10 +65,11 @@ export function scopeOf(ctx: Context): SessionId | undefined {
function sessionScope(): void {}
/**
* Display title projection. The wire summary carries no title yet (P-I
* ledger): the project directory's basename stands in, then the raw id.
* Display title projection: durable title, project directory basename, then
* the raw id.
*/
function titleOf(cwd: string | undefined, id: SessionId): string {
function displayTitleOf(title: string | undefined, cwd: string | undefined, id: SessionId): string {
if (title !== undefined) return title
if (cwd !== undefined && cwd !== '') {
const base = cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop()
if (base !== undefined && base !== '') return base
@@ -181,6 +185,18 @@ export class SessionsService {
return this.resolve(id)?.ctx
}
/**
* Read the session scope tag off a context. Service-method seam: fetch
* bundles must reach scope resolution through ctx.sessions — a cross-bundle
* value import of the standalone helper would inline a second module
* instance whose private tag Symbol never matches.
* @param ctx - any client context.
* @returns the session id, or undefined on root contexts.
*/
scopeOf(ctx: Context): SessionId | undefined {
return scopeOf(ctx)
}
/**
* Resolve the stable session binding (scope-addressed assembly feed). Pure
* resolution — no staging, no window side effects.
@@ -276,9 +292,10 @@ export class SessionsService {
ids.push(entry.sessionId)
byId[entry.sessionId] = {
id: entry.sessionId,
title: titleOf(entry.cwd, entry.sessionId),
displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId),
running: entry.running,
updatedAt: entry.updatedAt,
...(entry.title !== undefined ? { title: entry.title } : {}),
...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}),
...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}),
}

View File

@@ -5,12 +5,19 @@
import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type { HistoryEntry, IApiClient, MuxFrame, PromptContentPart, RpcError, RpcId, RpcResult, SessionId, ToolEventView } from '@deepseek-ai/dsh-client-connection/client'
import { transportError } from '@deepseek-ai/dsh-client-connection/client'
import type {
HistoryEntry, IApiClient, MuxFrame, PromptContentPart, RpcError, RpcId,
RpcResult, SessionId, ToolEventView,
} from '@deepseek-ai/dsh-client-connection/client'
// Value import from the inline-safe wire layer (not the connection plugin):
// plugin-to-plugin value imports are a bundle purity error.
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { ObservableSnapshot } from '../contract/store.ts'
import type {
ConversationNode, ConversationSnapshot, OpenState, PendingInteraction, PromptError, RunningToolCall,
ConversationNode, ConversationSnapshot, OpenState, PromptError, RunningToolCall,
} from './conversation.ts'
import type { PendingInteraction } from './pending.ts'
import { PendingWait } from './pending.ts'
import { FoldAdapter } from './fold-adapter.ts'
import { Notifier } from './notifier.ts'
import { PartialAccumulator } from './partial.ts'
@@ -108,9 +115,14 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
* @param attachmentId - opaque id found in the folded session log.
* @returns the authenticated reference and decoded bytes.
*/
async readAttachment(attachmentId: AttachmentIdType): Promise<RpcResult<{ attachment: ImageAttachmentRef; data: Uint8Array }>> {
async readAttachment(
attachmentId: AttachmentIdType,
): Promise<RpcResult<{ attachment: ImageAttachmentRef; data: Uint8Array }>> {
try {
const result = (await this.api.sessions.attachment({ sessionId: this.sessionId, attachmentId })).result
const result = (await this.api.sessions.attachment({
sessionId: this.sessionId,
attachmentId,
})).result
if (!result.ok) return result
const binary = atob(result.value.data)
const data = Uint8Array.from(binary, char => char.charCodeAt(0))
@@ -200,7 +212,9 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
this.events = []
this.views = []
this.baseSeq = 0
this.pending.clear() // the subscribed baseline replay re-sends still-pending requested frames verbatim
// Superseded, not settled: the baseline replay re-sends still-pending requested frames verbatim
// (same rpcId), re-minting fresh waits; a stale reference's respond() still reaches the host.
this.pending.clear()
this.pendingRev++
this.subscribedLastSeq = null
this.liveBuffer = []
@@ -246,33 +260,27 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
return // pure baseline bookkeeping, no visible change
}
case 'approval/requested': {
this.pending.set(`a:${rpcId}`, {
kind: 'approval', rpcId, approvalId: frame.approvalId, toolName: frame.toolName,
...(frame.callId !== undefined ? { callId: frame.callId } : {}),
...(frame.reason !== undefined ? { reason: frame.reason } : {}),
})
this.pendingRev++
const { type: _type, sessionId: _sid, ...payload } = frame
this.mint(new PendingWait('approval', rpcId, this.sessionId, payload, m => this.api.respond(m)))
this.notifier.markDirty()
return
}
case 'approval/resolved': {
for (const [key, item] of this.pending) {
if (item.kind === 'approval' && item.approvalId === frame.approvalId) {
this.pending.delete(key)
this.pendingRev++
}
for (const item of this.pending.values()) {
if (item.kind === 'approval' && item.payload.approvalId === frame.approvalId) this.settle(item)
}
this.notifier.markDirty()
return
}
case 'question/requested': {
this.pending.set(`q:${rpcId}`, { kind: 'question', rpcId, questions: frame.questions })
this.pendingRev++
const { type: _type, sessionId: _sid, ...payload } = frame
this.mint(new PendingWait('question', rpcId, this.sessionId, payload, m => this.api.respond(m)))
this.notifier.markDirty()
return
}
case 'question/resolved': {
if (this.pending.delete(`q:${frame.questionRpcId}`)) this.pendingRev++
const item = this.pending.get(`q:${frame.questionRpcId}`)
if (item !== undefined) this.settle(item)
this.notifier.markDirty()
return
}
@@ -312,6 +320,19 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
// ---- 私有 ----
/** Requested-frame arrival: the wait enters the pending map under its own key. */
private mint(wait: PendingInteraction): void {
this.pending.set(wait.key, wait)
this.pendingRev++
}
/** Authoritative resolved-frame settlement: mark, then drop from the pending map. */
private settle(wait: PendingInteraction): void {
wait.markSettled()
this.pending.delete(wait.key)
this.pendingRev++
}
/** @param generation - openGeneration at launch; every await re-checks it and a stale pass
* drops all writes (resync superseded this open — its outcome belongs to a dead connection). */
private async doOpen(generation: number): Promise<void> {

View File

@@ -66,6 +66,10 @@ interface ErasedRegisterOptions {
id?: string
order?: number
label?: string
/** Chain-slot routing selector (pure; the core validates presence for chain targets). */
select?: (owner: never) => unknown
/** Chain-slot explicit ordering override (ascending; registration order otherwise). */
priority?: number
registrant?: string
}

View File

@@ -1,289 +0,0 @@
/**
* ClientLoader: handoff protocol (single slot, id reconciliation), DI require
* with export-surface re-registration, immediately-group barrier (parallel
* fetch / topology execution / full-group barrier), status store, settled,
* failure modes (missing handoff, unknown dep, cycle, unload stub).
*/
import { Context } from 'cordis'
import { afterEach, describe, expect, it } from 'vitest'
import { createClientLoader } from '../src/client/loader/index.ts'
import type { BootPluginEntry, ClientPluginHandoff } from '../src/client/loader/index.ts'
type Win = { DSHClientProxy?: { loadPlugin(h: ClientPluginHandoff): void }; __DSH_BOOT__?: { plugins: BootPluginEntry[] } }
const win = globalThis as Win
afterEach(() => {
delete win.DSHClientProxy
delete win.__DSH_BOOT__
})
interface FakeBundle {
handoff: ClientPluginHandoff | null | ((require: (spec: string) => unknown) => Record<string, unknown>)
}
interface Bench {
loader: ReturnType<typeof createClientLoader>
fetched: string[]
executed: string[]
fetchGate: Map<string, () => void>
}
/** Build a loader over scripted fake bundles keyed by url; fetches resolve when released (or immediately). */
function bench(
plugins: BootPluginEntry[],
bundles: Record<string, FakeBundle>,
opts: { modules?: Record<string, unknown>; gated?: string[] } = {},
): Bench {
const ctx = new Context()
const fetched: string[] = []
const executed: string[] = []
const fetchGate = new Map<string, () => void>()
const loader = createClientLoader({
ctx,
modules: opts.modules ?? { react: { marker: 'react' } },
boot: { plugins },
fetchBundle: (url) => {
fetched.push(url)
if (opts.gated?.includes(url) === true) {
return new Promise<string>((resolve) => { fetchGate.set(url, () => { resolve(url) }) })
}
return Promise.resolve(url)
},
executeBundle: (code) => {
executed.push(code)
const bundle = bundles[code]
if (bundle === undefined) throw new Error(`no fake bundle for ${code}`)
if (bundle.handoff === null) return // simulates a bundle that never calls loadPlugin
if (typeof bundle.handoff === 'function') {
win.DSHClientProxy?.loadPlugin({ id: code.replace('/client.js', '').replace('/plugins/', ''), factory: bundle.handoff })
return
}
win.DSHClientProxy?.loadPlugin(bundle.handoff)
},
})
return { loader, fetched, executed, fetchGate }
}
const entry = (id: string, inject: string[] = [], immediately?: boolean): BootPluginEntry =>
({ id, url: `/plugins/${id}/client.js`, inject, ...(immediately === true ? { immediately: true } : {}) })
const okBundle = (applied?: string[], exports: Record<string, unknown> = {}): FakeBundle => ({
handoff: require => ({
apply: (pluginCtx: Context) => { void pluginCtx; applied?.push('applied') },
require,
...exports,
}),
})
describe('load chain', () => {
it('runs fetch→execute→handoff→factory(require)→apply→export re-registration→status active', async () => {
const applied: string[] = []
const b = bench(
[entry('fake-base', [], true), entry('feature', ['fake-base'])],
{
'/plugins/fake-base/client.js': { handoff: () => ({ apply: () => { applied.push('fake-base') }, helper: 'base-helper' }) },
'/plugins/feature/client.js': {
handoff: (require) => {
// Later loader requires the earlier one's export surface (inject topology guarantee).
const fakeBase = ['fake','base'].join('-') // assembled so knip's static require() scan skips the fake id
const base = require(fakeBase) as { helper: string }
expect(base.helper).toBe('base-helper')
expect((require('react') as { marker: string }).marker).toBe('react')
return { apply: () => { applied.push('feature') } }
},
},
},
)
b.loader.start()
await b.loader.settled()
expect(applied).toEqual(['fake-base', 'feature'])
expect(b.loader.status.getSnapshot()).toEqual({ 'fake-base': 'active', feature: 'active' })
expect((b.loader.requireModule('fake-base') as { helper: string }).helper).toBe('base-helper')
expect(() => b.loader.requireModule('ghost')).toThrow(/not available/)
})
it('fetches the immediately group in parallel and holds the barrier before the rest', async () => {
const b = bench(
[entry('a', [], true), entry('b', ['a'], true), entry('later')],
{
'/plugins/a/client.js': okBundle(),
'/plugins/b/client.js': okBundle(),
'/plugins/later/client.js': okBundle(),
},
{ gated: ['/plugins/a/client.js'] },
)
b.loader.start()
await Promise.resolve()
// Both early fetches are in flight before any execution; the late plugin is not fetched yet.
expect(b.fetched).toEqual(['/plugins/a/client.js', '/plugins/b/client.js'])
expect(b.executed).toEqual([])
b.fetchGate.get('/plugins/a/client.js')?.()
await b.loader.settled()
expect(b.executed).toEqual(['/plugins/a/client.js', '/plugins/b/client.js', '/plugins/later/client.js'])
})
it('orders execution by inject topology within each group', async () => {
const b = bench(
[entry('z-ui', ['a-base']), entry('a-base')],
{ '/plugins/a-base/client.js': okBundle(), '/plugins/z-ui/client.js': okBundle() },
)
b.loader.start()
await b.loader.settled()
expect(b.executed).toEqual(['/plugins/a-base/client.js', '/plugins/z-ui/client.js'])
})
})
describe('failure modes (fail loud)', () => {
it('rejects settled and marks failed when a bundle never calls loadPlugin', async () => {
const b = bench([entry('silent')], { '/plugins/silent/client.js': { handoff: null } })
b.loader.start()
await expect(b.loader.settled()).rejects.toThrow(/without calling DSHClientProxy.loadPlugin/)
expect(b.loader.status.getSnapshot().silent).toBe('failed')
})
it('rejects on manifest/handoff id mismatch', async () => {
const b = bench([entry('expected')], {
'/plugins/expected/client.js': { handoff: { id: 'imposter', factory: () => ({ apply: () => {} }) } },
})
b.loader.start()
await expect(b.loader.settled()).rejects.toThrow(/id mismatch/)
})
it('rejects unknown inject targets, cycles, missing apply, unknown load ids, duplicate manifest ids', async () => {
// Sequential benches: each loader owns the window proxy, so release it between them.
const fresh = <T>(build: () => T): T => {
delete win.DSHClientProxy
return build()
}
const missing = fresh(() => bench([entry('x', ['nope'])], { '/plugins/x/client.js': okBundle() }))
missing.loader.start()
await expect(missing.loader.settled()).rejects.toThrow(/injects unknown plugin "nope"/)
const cyclic = fresh(() => bench(
[entry('p', ['q']), entry('q', ['p'])],
{ '/plugins/p/client.js': okBundle(), '/plugins/q/client.js': okBundle() },
))
cyclic.loader.start()
await expect(cyclic.loader.settled()).rejects.toThrow(/inject cycle/)
const applyless = fresh(() => bench([entry('noap')], { '/plugins/noap/client.js': { handoff: { id: 'noap', factory: () => ({}) } } }))
applyless.loader.start()
await expect(applyless.loader.settled()).rejects.toThrow(/exports no apply/)
const b = fresh(() => bench([entry('a')], { '/plugins/a/client.js': okBundle() }))
await expect(b.loader.load('ghost')).rejects.toThrow(/unknown plugin "ghost"/)
expect(() => fresh(() => bench([entry('dup'), entry('dup')], {}))).toThrow(/duplicate manifest id/)
})
it('throws on missing boot manifest, double proxy install, and pre-start settled', () => {
expect(() => createClientLoader({ ctx: new Context(), modules: {} })).toThrow(/no boot manifest/)
const b = bench([], {})
expect(() => b.loader.settled()).toThrow(/settled\(\) before start\(\)/)
// First bench installed the proxy; a second loader must refuse.
expect(() => createClientLoader({ ctx: new Context(), modules: {}, boot: { plugins: [] } })).toThrow(/already installed/)
})
it('direct load() before a dependency is active fails loud (same check start() sequences)', async () => {
const b = bench(
[entry('dep', [], true), entry('needy', ['dep'])],
{ '/plugins/dep/client.js': okBundle(), '/plugins/needy/client.js': okBundle() },
)
await expect(b.loader.load('needy')).rejects.toThrow(/loaded before its dependency "dep" is active/)
})
it('direct load() naming an unknown inject target fails loud', async () => {
const b = bench([entry('solo', ['phantom'])], { '/plugins/solo/client.js': okBundle() })
await expect(b.loader.load('solo')).rejects.toThrow(/injects unknown plugin "phantom"/)
})
it('an immediately-group fetch failure surfaces through settled, not as an unhandled prefetch rejection', async () => {
// The fire-and-forget prefetch swallow arm must absorb the early
// rejection; the awaited load surfaces the same failure via settled().
const ctx = new Context()
delete win.DSHClientProxy
const loader = createClientLoader({
ctx,
modules: {},
boot: { plugins: [{ id: 'kaboom', url: '/plugins/kaboom/client.js', inject: [], immediately: true }] },
fetchBundle: () => Promise.reject(new Error('bundle fetch exploded')),
executeBundle: () => {},
})
loader.start()
await expect(loader.settled()).rejects.toThrow(/bundle fetch exploded/)
})
it('unload is the P-I stub', async () => {
const b = bench([], {})
await expect(b.loader.unload('x')).rejects.toThrow(/not implemented/)
})
})
describe('DOM default seams (stubbed globals)', () => {
it('default fetchBundle uses fetch, rejects non-OK; default executeBundle injects an inline script; claimStyles tags orphans', async () => {
const origFetch = globalThis.fetch
const appended: { textContent?: string | null }[] = []
const styleTag = {
attrs: {} as Record<string, string>,
setAttribute(k: string, v: string) { this.attrs[k] = v },
}
const fakeDoc = {
createElement: () => {
const el = { textContent: null as string | null }
return el
},
head: { appendChild: (el: { textContent?: string | null }) => { appended.push(el) } },
querySelectorAll: () => [styleTag],
}
const g = globalThis as { document?: unknown; fetch: typeof fetch }
g.document = fakeDoc
g.fetch = (url: URL | RequestInfo) => Promise.resolve(
(typeof url === 'string' ? url : url instanceof URL ? url.href : url.url).includes('bad')
? new Response('x', { status: 500 })
: new Response('window.DSHClientProxy.loadPlugin(globalThis.__seamHandoff)', { status: 200 }),
)
try {
delete win.DSHClientProxy
const ctx = new Context()
const loader = createClientLoader({
ctx,
modules: {},
boot: { plugins: [
{ id: 'seam-ok', url: '/plugins/seam-ok/client.js', inject: [] },
{ id: 'seam-bad', url: '/plugins/bad/client.js', inject: [] },
] },
// NO seams injected (keys omitted, not undefined — exactOptional):
// the DOM defaults are under test.
})
const seamHandoff: ClientPluginHandoff = {
id: 'seam-ok',
factory: () => ({ apply: () => {} }),
}
// Default executeBundle only APPENDS the script element (no execution in
// our fake DOM), so drive the handoff manually before load resolves it.
const loadOk = loader.load('seam-ok')
await Promise.resolve()
;(globalThis as Win).DSHClientProxy?.loadPlugin(seamHandoff)
await loadOk
expect(appended).toHaveLength(1)
expect(appended[0]?.textContent).toContain('sourceURL=/plugins/seam-ok/client.js')
expect(styleTag.attrs['data-plugin']).toBe('seam-ok')
await expect(loader.load('seam-bad')).rejects.toThrow(/answered 500/)
} finally {
g.fetch = origFetch
delete (globalThis as { document?: unknown }).document
}
})
})
describe('handoff slot protocol', () => {
it('rejects an overlapping loadPlugin before the loader claims the pending handoff', () => {
delete win.DSHClientProxy
createClientLoader({ ctx: new Context(), modules: {}, boot: { plugins: [] } })
const proxy = (globalThis as Win).DSHClientProxy
proxy?.loadPlugin({ id: 'first', factory: () => ({ apply: () => {} }) })
expect(() => proxy?.loadPlugin({ id: 'second', factory: () => ({ apply: () => {} }) }))
.toThrow(/overlapping loadPlugin handoff/)
})
})

View File

@@ -2,7 +2,7 @@
// data source on a real clock; behavior tests need per-case responses and
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
import type {
HostFrame, IApiClient, MuxFrame, RpcError, RpcRequest, RpcResponse, SessionId,
ClientResponse, HostFrame, IApiClient, MuxFrame, RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId,
} from '@deepseek-ai/dsh-client-connection/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
@@ -96,8 +96,10 @@ export class FakeApiClient implements IApiClient {
host: (_payload: unknown, signal: AbortSignal, onOpen?: () => void) => this.openStream(this.hostConns, signal, onOpen),
}
respond(): Promise<{ accepted: false; reason: 'not-pending' }> {
return Promise.resolve({ accepted: false, reason: 'not-pending' })
onRespond: (message: ClientResponse) => Promise<RpcReceipt> = () => Promise.resolve({ accepted: true })
respond(message: ClientResponse): Promise<RpcReceipt> {
return this.record('respond', message, this.onRespond(message))
}
/** Push one mux frame to every open mux stream (rpcId minted unless pinned by the case). */

View File

@@ -34,7 +34,7 @@ describe('instances', () => {
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
manager.handleMuxEnvelope({ rpcId: 're' as never, payload: { type: 'session/event', sessionId: S1, event: plainTurn(0, 0, 'x', 'y')[0] as never } })
const session = manager.get(S1)
expect(session.getSnapshot().pending).toMatchObject([{ kind: 'approval', approvalId: 'ap1' }])
expect(session.getSnapshot().pending).toMatchObject([{ kind: 'approval', payload: { approvalId: 'ap1' } }])
// Buffer cleared: a second instantiation of another id gets nothing.
expect(manager.get(S2).getSnapshot().pending).toEqual([])
})
@@ -48,7 +48,7 @@ describe('instances', () => {
}
const pending = manager.get(S1).getSnapshot().pending
expect(pending).toHaveLength(32)
expect(pending.map(p => p.rpcId)).toEqual(Array.from({ length: 32 }, (_, i) => `q${i + 8}`)) // oldest 8 dropped
expect(pending.map(p => p.key)).toEqual(Array.from({ length: 32 }, (_, i) => `q:q${i + 8}`)) // oldest 8 dropped
// Removed session: buffered frames must not replay on a future instantiation.
manager.handleMuxEnvelope({ rpcId: 'qz' as never, payload: { type: 'question/requested', sessionId: S2, questions: [] } })
manager.handleHostEnvelope({ rpcId: 'hz' as never, payload: { type: 'host/session-removed', sessionId: S2 } })
@@ -89,6 +89,66 @@ describe('list lifecycle', () => {
expect(result).toMatchObject({ ok: true, value: { sessionId: S2 } })
expect(manager.getListSnapshot().items.map(i => i.sessionId)).toEqual([S2])
})
it('retains monotonic title snapshots before list arrival, merges recency, and clears them on removal', async () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
manager.handleMuxEnvelope({
rpcId: 'title-new' as never,
payload: { type: 'session/title', sessionId: S1, title: 'Newest', eventSeq: 4, updatedAt: 300 },
})
manager.handleMuxEnvelope({
rpcId: 'title-stale' as never,
payload: { type: 'session/title', sessionId: S1, title: 'Stale', eventSeq: 3, updatedAt: 900 },
})
manager.handleMuxEnvelope({
rpcId: 'title-equal' as never,
payload: { type: 'session/title', sessionId: S1, title: 'Equal', eventSeq: 4, updatedAt: 901 },
})
api.onList = () => Promise.resolve(ok({
items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[],
}))
await manager.refreshList()
const titled = manager.getListSnapshot()
expect(titled.items.map(item => item.sessionId)).toEqual([S1, S2])
expect(titled.items[0]).toMatchObject({ title: 'Newest', updatedAt: 300 })
expect(titled.items[1]?.title).toBeUndefined()
manager.handleHostEnvelope({ rpcId: 'removed' as never, payload: { type: 'host/session-removed', sessionId: S1 } })
manager.handleHostEnvelope({ rpcId: 'readded' as never, payload: { type: 'host/session-added', sessionId: S1 } })
expect(manager.getListSnapshot().items.find(item => item.sessionId === S1)?.title).toBeUndefined()
})
it('drops a retained title beyond the subscription baseline before accepting its durable replay', async () => {
const api = new FakeApiClient()
api.onList = () => Promise.resolve(ok({ items: [summary(S1)] as never[] }))
const manager = new SessionManager(api)
await manager.refreshList()
manager.handleMuxEnvelope({
rpcId: 'title-unflushed' as never,
payload: { type: 'session/title', sessionId: S1, title: 'Unflushed', eventSeq: 4, updatedAt: 400 },
})
manager.handleMuxEnvelope({
rpcId: 'subscribed-recovered' as never,
payload: { type: 'session/subscribed', sessionId: S1, lastSeq: 2 },
})
expect(manager.getListSnapshot().items[0]?.title).toBeUndefined()
expect(manager.getListSnapshot().items[0]?.updatedAt).toBe(100)
manager.handleMuxEnvelope({
rpcId: 'title-durable' as never,
payload: { type: 'session/title', sessionId: S1, title: 'Durable', eventSeq: 2, updatedAt: 200 },
})
expect(manager.getListSnapshot().items[0]).toMatchObject({ title: 'Durable', updatedAt: 200 })
manager.handleMuxEnvelope({
rpcId: 'subscribed-current' as never,
payload: { type: 'session/subscribed', sessionId: S1, lastSeq: 2 },
})
expect(manager.getListSnapshot().items[0]).toMatchObject({ title: 'Durable', updatedAt: 200 })
})
})
describe('host frame routing', () => {

View File

@@ -266,6 +266,36 @@ describe('pending interactions', () => {
session.handleMuxEnvelope('ry' as never, { type: 'question/resolved', sessionId: SID, questionRpcId: 'rq' as never, outcome: 'answered' })
expect(session.getSnapshot().pending).toEqual([])
})
it('mints waits whose respond() backfills the requested rpcId into the client-response envelope', async () => {
const { api, session } = makeSession()
session.handleMuxEnvelope('rq-answer' as never, { type: 'question/requested', sessionId: SID, questions: [] })
const wait = session.getSnapshot().pending[0]!
expect(wait).toMatchObject({ kind: 'question', key: 'q:rq-answer', sessionId: SID, payload: { questions: [] } })
const receipt = await wait.respond({
ok: true,
value: { sessionId: SID, answer: { answers: [{ id: 'mode', selected: ['Fast'] }] } },
})
expect(receipt).toEqual({ accepted: true })
expect(api.callsOf('respond')).toEqual([{
type: 'client-response', rpcId: 'rq-answer',
result: {
ok: true,
value: { sessionId: SID, answer: { answers: [{ id: 'mode', selected: ['Fast'] }] } },
},
}])
})
it('settles the wait on the authoritative resolved frame: respond() then throws synchronously', async () => {
const { api, session } = makeSession()
session.handleMuxEnvelope('rq1' as never, { type: 'question/requested', sessionId: SID, questions: [] })
const wait = session.getSnapshot().pending[0]!
session.handleMuxEnvelope('ry' as never, { type: 'question/resolved', sessionId: SID, questionRpcId: 'rq1' as never, outcome: 'answered' })
expect(session.getSnapshot().pending).toEqual([])
expect(() => wait.respond({ ok: false, error: { code: 'internal', message: 'x', details: {} } }))
.toThrow('already settled')
expect(api.callsOf('respond')).toEqual([])
})
})
describe('remaining branches', () => {
@@ -370,7 +400,7 @@ describe('remaining branches', () => {
session.handleMuxEnvelope('ra' as never, {
type: 'approval/requested', sessionId: SID, approvalId: 'ap2' as never, toolName: 'rm', callId: 'c1' as never, reason: '危险',
})
expect(session.getSnapshot().pending[0]).toMatchObject({ kind: 'approval', callId: 'c1', reason: '危险' })
expect(session.getSnapshot().pending[0]).toMatchObject({ kind: 'approval', payload: { callId: 'c1', reason: '危险' } })
session.handleMuxEnvelope('rx' as never, { type: 'approval/resolved', sessionId: SID, approvalId: 'ap2' as never, outcome: 'approved' as never })
session.handleMuxEnvelope('rx2' as never, { type: 'approval/resolved', sessionId: SID, approvalId: 'ap2' as never, outcome: 'approved' as never })
session.handleMuxEnvelope('ry2' as never, { type: 'question/resolved', sessionId: SID, questionRpcId: 'never-was' as never, outcome: 'cancelled' })
@@ -583,6 +613,22 @@ describe('resync', () => {
expect(cold.api.calls).toEqual([]) // never opened: no traffic
})
it('re-mints a replayed requested frame as a fresh wait with the same key (old reference superseded)', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
await session.open()
session.handleMuxEnvelope('rq-replay' as never, { type: 'question/requested', sessionId: SID, questions: [] })
const before = session.getSnapshot().pending[0]!
await session.resync()
session.handleMuxEnvelope('rq-replay' as never, { type: 'question/requested', sessionId: SID, questions: [] })
const after = session.getSnapshot().pending[0]!
expect(after).not.toBe(before)
expect(after.key).toBe(before.key)
// Superseded ≠ settled: an in-flight respond on the stale reference still reaches the host.
await before.respond({ ok: false, error: { code: 'internal', message: 'x', details: {} } })
expect(api.callsOf('respond')).toMatchObject([{ rpcId: 'rq-replay' }])
})
it('drops a stale in-flight open superseded by resync (generation guard)', async () => {
const { api, session } = makeSession()
const stale = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()

View File

@@ -41,16 +41,21 @@ async function feedList(b: Bench, rows: { id: string; cwd?: string; parentId?: s
}
describe('list store projection', () => {
it('projects ids/byId with cwd-basename titles (id fallback) and parent links', async () => {
it('projects durable titles separately from cwd/id display fallbacks and parent links', async () => {
const b = bench()
b.svc.manager.handleMuxEnvelope({
rpcId: 'title' as never,
payload: { type: 'session/title', sessionId: sid('s1'), title: 'Durable title', eventSeq: 2, updatedAt: 3 },
})
await feedList(b, [
{ id: 's1', cwd: '/home/u/proj-a/' },
{ id: 's2', parentId: 's1', running: true },
])
const state = b.svc.list.getSnapshot()
expect(state.ids).toEqual(['s1', 's2'])
expect(state.byId[sid('s1')]).toMatchObject({ title: 'proj-a', cwd: '/home/u/proj-a/' })
expect(state.byId[sid('s2')]).toMatchObject({ title: 's2', parentId: 's1', running: true })
expect(state.byId[sid('s1')]).toMatchObject({ title: 'Durable title', displayTitle: 'Durable title', cwd: '/home/u/proj-a/' })
expect(state.byId[sid('s2')]).toMatchObject({ displayTitle: 's2', parentId: 's1', running: true })
expect(state.byId[sid('s2')]?.title).toBeUndefined()
})
it('reflects live increments (host stream via manager) into the store', async () => {
@@ -273,12 +278,13 @@ describe('create', () => {
})
describe('coverage tails (branch duals)', () => {
it('titleOf falls back to the id for empty and separator-only cwd', async () => {
it('displayTitleOf falls back to the id for empty and separator-only cwd', async () => {
const b = bench()
await feedList(b, [{ id: 'no-base', cwd: '///' }, { id: 'empty-cwd', cwd: '' }])
const { byId } = b.svc.list.getSnapshot()
expect(byId[sid('no-base')]?.title).toBe('no-base')
expect(byId[sid('empty-cwd')]?.title).toBe('empty-cwd')
expect(byId[sid('no-base')]?.displayTitle).toBe('no-base')
expect(byId[sid('empty-cwd')]?.displayTitle).toBe('empty-cwd')
expect(byId[sid('no-base')]?.title).toBeUndefined()
})
it('binding for an unknown session returns undefined and leaves the staged scope intact', async () => {

View File

@@ -23,6 +23,9 @@
{
"path": "../connection"
},
{
"path": "../../host/apiproxy"
},
{
"path": "../../llm/llm"
},

View File

@@ -1,23 +1,3 @@
import type { UserConfig } from 'tsdown'
import { clientBundle } from '../tsdown.client.ts'
/**
* Standard dual-entry shape plus the loader lib half: exports["./loader"]
* promises lib/loader.js (the web shell statically imports the machinery —
* a loader cannot load itself), and the shared preset only emits
* lib/{index,invariant}.js, so the extra config supplies it.
*/
const configs = clientBundle('@deepseek-ai/dsh-client-runtime', ['lib/types/index.js', 'lib/types/invariant.js'])
const loaderLib: UserConfig = {
entry: { loader: 'lib/types/client/loader/index.js' },
outDir: 'lib',
format: ['esm'],
platform: 'neutral',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
}
export default [...configs, loaderLib]
export default clientBundle('@deepseek-ai/dsh-client-runtime', ['lib/types/index.js', 'lib/types/invariant.js'])

View File

@@ -1,6 +1,6 @@
/**
* Shared tsdown preset for UI plugin client bundles. Emits a closure-factory
* artifact: the bundle calls window.DSHClientProxy.loadPlugin({id, factory})
* artifact: the bundle calls window.__ModuleLoader__.load({id, factory})
* and resolves externals through the injected require (loader module table —
* cordis DI entities, no globals, no import map). CSS Modules are compiled by
* lightningcss inside the bundle: importing `x.module.css` yields the
@@ -11,6 +11,7 @@ import { readFile } from 'node:fs/promises'
import { basename, dirname, resolve as resolvePath } from 'node:path'
import type { UserConfig } from 'tsdown'
import { transform } from 'lightningcss'
import { PLATFORM_MODULES } from './web/src/platform.ts'
/**
* Virtual-id wrapper keeping module CSS away from tsdown's own css pipeline
@@ -28,22 +29,20 @@ const CSS_VIRTUAL_SUFFIX = '.mjs'
*/
export const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|session|llm|tools|brand)(\/|$)/
/** Externals resolved from the loader module table (keep in sync with the shell's seeding list). */
export const CLIENT_EXTERNALS = [
'react',
'react-dom',
'react/jsx-runtime',
'cordis',
'@deepseek-ai/dsh-client-ui-slots',
'@deepseek-ai/dsh-client-web-react',
'@deepseek-ai/dsh-client-ui-primitives',
'@deepseek-ai/dsh-client-connection/client',
'@deepseek-ai/dsh-client-runtime/client',
'@deepseek-ai/dsh-client-ui-layout/client',
'@deepseek-ai/dsh-client-ui-conversation/client',
'@deepseek-ai/dsh-client-ui-theme/client',
'@deepseek-ai/dsh-client-i18n/client',
]
/**
* Documented TEMPORARY exemption, not a platform module (hence not in
* platform.ts): the snapshot-store engine (createSnapshotStore/defineStore/
* shallowEqual) lives in runtime pending its promotion-time rehoming, and
* five importers (i18n, ui-layout, ui-conversation ×3) ride this single
* exemption. At runtime the lazy CJS table answers the require natively:
* runtime is an immediately-tier row, its factory is registered before any
* dependent bundle materializes. TODO(webload/store-rehome): remove with the
* store-engine relocation follow-up.
*/
const RUNTIME_STORE_EXEMPTION = '@deepseek-ai/dsh-client-runtime/client'
/** Externals resolved from the loader module table: the platform seed entries plus the documented runtime exemption. */
export const CLIENT_EXTERNALS: readonly string[] = [...PLATFORM_MODULES, RUNTIME_STORE_EXEMPTION]
/**
* Build the tsdown config for one UI plugin package: the node-half lib build
@@ -51,8 +50,8 @@ export const CLIENT_EXTERNALS = [
* the root workspace shape, so the lib half must be restated here — dropping
* it leaves the package without lib/index.js and the host Loader cannot
* import its node half.
* @param id - plugin id (package name), stamped into the loadPlugin handoff
* and onto the injected style tags.
* @param id - plugin id (package name), stamped into the __ModuleLoader__.load
* handoff and onto the injected style tags.
* @param libEntry - node-half entries, spelled at the call site so the
* package-invariants gate can see `lib/types/invariant.js` in each package's
* own tsdown.config.ts (a preset-side glob hides it from the mechanical check).
@@ -79,7 +78,7 @@ export function clientBundle(id: string, libEntry: readonly string[]): UserConfi
// Types ship from lib/types (tsc); dts here would wrap the banner/footer into .d.cts and break parsing.
dts: false,
clean: false,
external: CLIENT_EXTERNALS,
external: [...CLIENT_EXTERNALS],
// Browser bundles inline node-idiom deps (zustand/immer read
// process.env.NODE_ENV; zustand's esm build also probes
// import.meta.env.MODE, which a CJS output cannot carry — rolldown flags
@@ -102,24 +101,20 @@ export function clientBundle(id: string, libEntry: readonly string[]): UserConfi
// opinion for table entries (external above wins), bundle everything else.
noExternal: (id: string) => (CLIENT_EXTERNALS.includes(id) ? undefined : true),
plugins: [{
// Bundle purity gate: a bare-name import of a module-table package would
// slip past CLIENT_EXTERNALS (which lists the /client form) and INLINE a
// second copy of that package — duplicate runtime identity (a second
// scope Symbol was tonight's white-screen root cause). Resolve-time is
// the earliest, most precise interception: rewrite bare table names to
// their /client form (the loader registers both specifiers), and reject
// any other @deepseek-ai/* leak that is not an inline-safe wire layer.
// Bundle purity gate (build-time mirror of the module-edge rules):
// platform seed entries stay external, inline-safe wire layers inline,
// and every other @deepseek-ai value import is a build error — a
// cross-plugin value import either inlines a duplicate runtime instance
// or requires a specifier the frozen module table cannot answer.
// Cross-plugin collaboration goes through cordis services instead.
name: 'dsh-client-bundle-purity',
resolveId(source: string) {
if (!source.startsWith('@deepseek-ai/')) return null
if (CLIENT_EXTERNALS.includes(source)) return null // external wins
if (CLIENT_EXTERNALS.includes(`${source}/client`)) {
return { id: `${source}/client`, external: true }
}
if (CLIENT_EXTERNALS.includes(source)) return null // platform module: external wins
if (INLINE_SAFE.test(source)) return null // wire/type layer: inline is the point
throw new Error(
`client bundle purity: "${source}" is not in CLIENT_EXTERNALS and not an inline-safe wire layer — `
+ 'import the /client form, add it to the module table, or it inlines a duplicate runtime instance',
`client bundle purity: "${source}" is not a platform module (CLIENT_EXTERNALS) and not an inline-safe wire layer — `
+ 'cross-plugin value imports are forbidden; collaborate through cordis services (type-only imports are erased and never reach this gate)',
)
},
}, {
@@ -158,7 +153,7 @@ export function clientBundle(id: string, libEntry: readonly string[]): UserConfi
}],
outputOptions: {
entryFileNames: 'client.js',
banner: `window.DSHClientProxy.loadPlugin({ id: ${JSON.stringify(id)}, factory: (require) => {`,
banner: `window.__ModuleLoader__.load({ id: ${JSON.stringify(id)}, factory: (require) => {`,
footer: `return module.exports; } });`,
intro: 'var module = { exports: {} }; var exports = module.exports;',
},

View File

@@ -28,4 +28,4 @@ None; this package neither assembles nor sends a provider request.
- **Details panel is the minimal form** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred.
- **Assistant footer extensions (IconActions row, per-message paging) are reserved slots** — drawn in the design, not implemented.
- **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export.
- **Approval/question cards are display-only placeholders** — web-side answering (composer takeover panel) is the P-II approvals project.
- **Approval cards are display-only placeholders** — question requests answer through the composer chain (ui-question), while web-side approval answering is the P-II approvals project.

View File

@@ -24,6 +24,8 @@
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-i18n",
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-layout"
],
"platform": "web"
@@ -35,21 +37,25 @@
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-attachment": "workspace:^",
"clsx": "^2.0.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"clsx": "^2.0.0",
"react": "^18.2.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"cordis": "^4.0.0-rc.7"
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"
},
"files": [
"lib/index.js",

View File

@@ -69,7 +69,12 @@ export function apply(ctx: Context): void {
// ConversationRoot is the only component authorized to render the ring.
slots.register({
name: 'conversation',
children: { 'conversation.view': { kind: 'list', scope: 'session' } },
// The composer chain rides the same declaration table: takeover plugins
// register selector-routed replacements of the InputBar.
children: {
'conversation.view': { kind: 'list', scope: 'session' },
'conversation.composer': { kind: 'chain', scope: 'session' },
},
store: chatStore,
inject: (sessionId: SessionId, actions: BoundActions<typeof chatStore>): ConversationInjected => {
// History pull is NOT triggered here: the runtime sessions service opens

View File

@@ -7,7 +7,7 @@
import { memo } from 'react'
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
import { IconThinkOutline14, JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconThinkOutline14, JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import { ToolRow } from './ToolRow.tsx'
import { ImageGallery, type ImageLoader } from './MessageImage.tsx'
import css from './AssistantMarkdown.module.css'
@@ -46,7 +46,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, strea
<div className={css.root} data-streaming={streaming || undefined}>
{blocks.map((block, i) => {
switch (block.kind) {
case 'text': return <MessageText key={i} text={block.text} />
case 'text': return <MarkdownText key={i} text={block.text} />
case 'reasoning': return <ThinkRow key={i} text={block.text} running={streaming && i === last} />
case 'image': return <ImageGallery key={i} images={[block]} load={loadImage} align="start" />
// Tool-call heads render as tool rows in the chat view's grouping pass.

View File

@@ -256,7 +256,7 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl
))}
</div>
)}
{pending.map((item) => <PendingCard key={item.rpcId} item={item} />)}
{pending.map((item) => <PendingCard key={item.key} item={item} />)}
</div>
</div>
<StatsLine useSession={useSession} />

View File

@@ -16,13 +16,13 @@ export const PendingCard = memo(function PendingCard({ item }: PendingCardProps)
<div className={css.card}>
{item.kind === 'approval' ? (
<>
<div className={css.title}><span className={css.mono}>{item.toolName}</span></div>
{item.reason !== undefined && <div className={css.reason}>{item.reason}</div>}
<div className={css.title}><span className={css.mono}>{item.payload.toolName}</span></div>
{item.payload.reason !== undefined && <div className={css.reason}>{item.payload.reason}</div>}
</>
) : (
<>
<div className={css.title}>{item.questions.length} </div>
<JsonBlock label="问题内容" payload={item.questions} />
<div className={css.title}>{item.payload.questions.length} </div>
<JsonBlock label="问题内容" payload={item.payload.questions} />
</>
)}
<div className={css.hint}>web </div>

View File

@@ -11,7 +11,7 @@
* here.
*/
import type { PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionId, ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
import type { PendingInteraction, SessionId, ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type { createChatStore } from '../stores.ts'
import type { CallId, SelectionTarget, ViewTab } from './views.ts'
@@ -42,6 +42,14 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
* `fallback` for unregistered tools.
*/
'conversation.chat.toolview': { kind: 'keyed'; scope: 'session'; owner: ToolRowOwnerProps }
/**
* The composer takeover chain: entries are selector-routed replacements
* of the default InputBar. Declared by this package's 'conversation'
* entry; the owner dispatches the {@link ComposerChainProps} currency and
* routing lives in entry selectors — new takeover kinds register with
* zero owner changes.
*/
'conversation.composer': { kind: 'chain'; scope: 'session'; owner: ComposerChainProps }
}
}
@@ -112,7 +120,7 @@ export interface ConversationInjected {
addImages(files: readonly File[], current: readonly ComposerAttachment[]): string | null
/** Release one browser preview and remove its id through the declared store action. */
removeImage(id: string): void
/** Resolve ordered store ids to the browser-owned draft attachments still available this runtime. */
/** Resolve ordered store ids to browser-owned draft attachments still available this runtime. */
draftImages(ids: readonly string[]): readonly ComposerAttachment[]
/** Release historical image URLs when this rendered session scope unmounts. */
releaseSessionImages(sessionId: SessionId): void
@@ -124,9 +132,22 @@ export interface ConversationInjected {
open(id: SessionId): void
}
/** Full conversation-slot component props: runtime share & view-slot render share & store share & injected share. */
/**
* Composer chain currency: what ConversationRoot dispatches at its
* renderSlotChain site. The owner declares the currency only — never a
* per-entry contract; takeover packages narrow it in their own selectors
* (`interactions.find(i => i.kind === ...)`), so new takeover kinds register
* with zero owner changes.
*/
export interface ComposerChainProps {
/** The session's live pending waits, in arrival order (snapshot reference). */
interactions: readonly PendingInteraction[]
}
/** Full conversation-slot component props: runtime & child-render (view ring + composer chain) & store & injected shares. */
export type ConversationSlotProps =
PropsRuntime<'conversation'> & PropsRenderSlots<'conversation.view'> & PropsStore<ChatStore> & ConversationInjected
PropsRuntime<'conversation'> & PropsRenderSlots<'conversation.view' | 'conversation.composer'>
& PropsStore<ChatStore> & ConversationInjected
/**
* Injected share of the chat view entry: the two callbacks whose targets live

View File

@@ -18,9 +18,10 @@ export type {
} from './contract/views.ts'
export type { ToolCallBlock } from './contract/tool-call-model.ts'
export type {
ChatStore, ChatViewInjected, ChatViewSlotProps, ComposerAttachment, ConversationInjected, ConversationSlotProps,
ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps,
EmptyStateInjected, EmptyStateSlotProps, ToolRowOwnerProps, ToolRowProps,
ChatStore, ChatViewInjected, ChatViewSlotProps, ComposerAttachment, ComposerChainProps,
ConversationInjected, ConversationSlotProps, ConvViewOwnerProps, ConvViewProps,
DetailsInjected, DetailsSlotProps, EmptyStateInjected, EmptyStateSlotProps,
ToolRowOwnerProps, ToolRowProps,
} from './contract/slots.ts'
// Export discipline: packages/client/AGENTS.md.

View File

@@ -15,13 +15,9 @@
*/
import { Service } from 'cordis'
import type { Context } from 'cordis'
// Value import MUST use the /client subpath: only that specifier is in the
// bundle externals (CLIENT_EXTERNALS), so it resolves to the shared runtime
// module at load time. A bare-specifier value import gets INLINED as a second
// module instance whose private scope-tag Symbol never matches the one
// SessionsService tags contexts with — scopeOf then always returns undefined
// in the browser while unit tests (single-instance path resolution) stay green.
import { scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
// Type-only imports: a plugin-to-plugin value import is a bundle purity
// error, so scope resolution goes through the sessions service (scopeOf
// method) instead of the standalone helper.
import type { Session, SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment'
import type { ComposerAttachment } from './contract/slots.ts'
@@ -160,14 +156,17 @@ export class ConversationService extends Service {
const cached = this.imageUrls.get(key)
if (cached !== undefined) return cached.pending
const generation = this.imageGenerations.get(sessionId) ?? 0
const pending = this.requireSessions().manager.get(sessionId).readAttachment(attachment.attachmentId)
const pending = this.requireSessions().manager.get(sessionId)
.readAttachment(attachment.attachmentId)
.then((result) => {
if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`)
if (typeof URL.createObjectURL !== 'function') {
return `data:${result.value.attachment.mediaType};base64,${bytesToBase64(result.value.data)}`
}
const bytes = Uint8Array.from(result.value.data)
const url = URL.createObjectURL(new Blob([bytes.buffer], { type: result.value.attachment.mediaType }))
const url = URL.createObjectURL(new Blob([bytes.buffer], {
type: result.value.attachment.mediaType,
}))
if ((this.imageGenerations.get(sessionId) ?? 0) !== generation) {
revokePreview(url)
throw new Error('historical image scope was released before loading completed')
@@ -242,11 +241,17 @@ export class ConversationService extends Service {
/** Resolve the caller scope's Session or throw on root contexts. */
private scopedSession(op: string): Session {
const id = scopeOf(this.ctx)
const id = this.scopeId(op)
return this.requireSessions().manager.get(id)
}
/** Read the caller's session scope tag via the sessions service; root contexts fail loud. */
private scopeId(op: string): SessionId {
const id = this.requireSessions().scopeOf(this.ctx)
if (id === undefined) {
throw new Error(`conversation.${op} requires a session scope — address one via ctx.sessions.scope(id).conversation`)
}
return this.requireSessions().manager.get(id)
return id
}
private requireSessions(): SessionsService {

View File

@@ -3,7 +3,8 @@
// props: the framework standard kit (useSession/sessionId/useSessions), the
// declared chat store's useStore/actions, the injected business face, and the
// renderSlot share for the declared 'conversation.view' child slot (views are
// slot entries; the active one renders via the list `only` filter).
// slot entries; the active one renders via the list `only` filter) plus the
// renderSlotChain share for the 'conversation.composer' takeover chain.
// Breadcrumbs derive from useSessions with a pure parentId walk; the active
// view id lives in the chat store's `view` field (per-session by store scope).
@@ -36,7 +37,7 @@ function deriveAncestry(list: SessionListState, id: SessionId): readonly Session
}
export function ConversationRoot({
sessionId, useSession, useSessions, useStore, actions, renderSlot,
sessionId, useSession, useSessions, useStore, actions, renderSlot, renderSlotChain,
views, addImages, removeImage, draftImages, releaseSessionImages,
send, stop, open,
}: ConversationRootProps) {
@@ -55,6 +56,7 @@ export function ConversationRoot({
const removed = useSession(s => s.removed)
const promptError = useSession(s => s.promptError)
const turns = useSession(s => countTurns(s))
const pending = useSession(s => s.pending)
// Browser File/object-URL values are runtime-only. A reload may rehydrate
// ids whose objects no longer exist; prune those ids after the first render.
@@ -72,6 +74,24 @@ export function ConversationRoot({
? null
: { op: promptError.op, message: `${promptError.error.message}${promptError.error.code}` }
// The default composer doubles as the chain's all-decline fallback: a
// pending wait with no registered takeover must still leave the input usable.
const composerBar = (
<InputBar
draft={draft}
attachments={attachments}
running={running}
disabled={removed}
error={error}
variant="composer"
onDraftChange={actions.setDraft}
onAddImages={files => addImages(files, attachments)}
onRemoveAttachment={removeImage}
onSend={(mode) => { send(draft, attachments, mode) }}
onStop={stop}
/>
)
return (
<div className={css.root}>
<header className={css.header}>
@@ -88,7 +108,7 @@ export function ConversationRoot({
disabled={last}
onClick={() => { open(s.id) }}
>
{s.title}
{s.displayTitle}
</button>
</span>
)
@@ -121,19 +141,7 @@ export function ConversationRoot({
{active !== undefined && renderSlot('conversation.view', {}, { only: active.id })}
</div>
<InputBar
draft={draft}
attachments={attachments}
running={running}
disabled={removed}
error={error}
variant="composer"
onDraftChange={actions.setDraft}
onAddImages={files => addImages(files, attachments)}
onRemoveAttachment={removeImage}
onSend={(mode) => { send(draft, attachments, mode) }}
onStop={stop}
/>
{renderSlotChain('conversation.composer', { interactions: pending }, { fallback: composerBar })}
</div>
)
}

View File

@@ -51,7 +51,7 @@ async function bench() {
const listStore = createSnapshotStore<SessionListState>({
ids: [ROOT],
byId: { [ROOT]: { id: ROOT, title: 'R', cwd: '/proj', running: false, updatedAt: 1 } },
byId: { [ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', cwd: '/proj', running: false, updatedAt: 1 } },
current: ROOT,
} as SessionListState)
const sessionFake = {
@@ -59,6 +59,7 @@ async function bench() {
loadOlder: vi.fn(() => Promise.resolve()),
prompt: vi.fn<() => Promise<{ ok: boolean; value?: object; error?: { code: string; message: string } }>>(
() => Promise.resolve({ ok: true, value: { accepted: true } })),
readAttachment: vi.fn(() => Promise.reject(new Error('attachment response not configured'))),
cancel: vi.fn<() => Promise<{ ok: boolean; value?: object; error?: { code: string; message: string } }>>(
() => Promise.resolve({ ok: true, value: { accepted: true } })),
}
@@ -76,6 +77,7 @@ async function bench() {
manager: { get: () => sessionFake },
scope: (id: SessionId) => mint(id),
cell: () => undefined,
scopeOf,
hostDescription: () => undefined,
create: vi.fn(() => Promise.resolve(ROOT)),
open: vi.fn(),

View File

@@ -26,8 +26,8 @@ async function bench() {
const listStore = createSnapshotStore<SessionListState>({
ids: [ROOT, CHILD],
byId: {
[ROOT]: { id: ROOT, title: 'R', running: false, updatedAt: 1 },
[CHILD]: { id: CHILD, title: 'C', parentId: ROOT, running: false, updatedAt: 2 },
[ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', running: false, updatedAt: 1 },
[CHILD]: { id: CHILD, title: 'C', displayTitle: 'C', parentId: ROOT, running: false, updatedAt: 2 },
},
current: undefined,
} as SessionListState)

View File

@@ -4,9 +4,11 @@
// single-line reasoning. (Tool-row dispatch tails live with the keyed-slot
// machinery specs since the tool ring dissolved into renderSlot.)
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import type { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { MessageItem } from '../src/client/chat/MessageItem.tsx'
import { PendingCard } from '../src/client/chat/PendingCard.tsx'
@@ -44,7 +46,7 @@ describe('MessageItem arms', () => {
describe('small branch tails', () => {
it('PendingCard approval reason renders when present', () => {
const view = render(
<PendingCard item={{ kind: 'approval', rpcId: 'r1' as RpcId, approvalId: 'a1', toolName: 'rm', reason: 'careful' }} />,
<PendingCard item={new PendingWait('approval', RpcId('r1'), 's1' as SessionId, { approvalId: 'a1', toolName: 'rm', reason: 'careful' } as PendingWait<'approval'>['payload'], vi.fn())} />,
)
expect(view.getByText('careful')).toBeTruthy()
})

View File

@@ -122,8 +122,8 @@ describe('bash sample row', () => {
return createSnapshotStore<SessionListState>({
ids: [ROOT, CHILD],
byId: {
[ROOT]: { id: ROOT, title: 'r', running: false, updatedAt: 0 },
[CHILD]: { id: CHILD, title: 'c', parentId: ROOT, running: false, updatedAt: 0 },
[ROOT]: { id: ROOT, title: 'r', displayTitle: 'r', running: false, updatedAt: 0 },
[CHILD]: { id: CHILD, title: 'c', displayTitle: 'c', parentId: ROOT, running: false, updatedAt: 0 },
},
current: undefined,
} as SessionListState)
@@ -157,7 +157,7 @@ describe('bash sample row', () => {
const orphan = 'late-child' as SessionId
store.update((d) => {
d.ids.push(orphan)
d.byId[orphan] = { id: orphan, title: 'l', running: false, updatedAt: 0 }
d.byId[orphan] = { id: orphan, title: 'l', displayTitle: 'l', running: false, updatedAt: 0 }
})
const view = render(<BashRow {...rowProps(orphan, { store })} />)
expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()

View File

@@ -10,7 +10,8 @@ import type {
AssistantMessageNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, UserMessageNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore, PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import type { ChatViewSlotProps, SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { createChatStore } from '../src/client/stores.ts'
import { ChatView } from '../src/client/chat/ChatView.tsx'
@@ -158,6 +159,44 @@ describe('ChatView', () => {
expect(view.getByText('run a')).toBeTruthy()
})
it('renders assistant Markdown across history, streaming, final, and interrupted states while user text stays literal', () => {
const markdown = '# Rendered\n\n- **one**\n- `two`'
const h = makeHarness({ nodes: [user(1, markdown), assistant(2, markdown)] })
const view = render(<h.ChatView {...h.props} />)
expect(view.container.querySelectorAll('h1')).toHaveLength(1)
const literal = view.getByText((_content, element) => (
element?.tagName === 'DIV' && element.childElementCount === 0 && element.textContent === markdown
))
expect(literal.querySelector('h1')).toBeNull()
act(() => {
h.set({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: markdown }] } })
})
expect(view.container.querySelectorAll('h1')).toHaveLength(2)
expect(view.container.querySelector('[data-streaming="true"] h1')?.textContent).toBe('Rendered')
act(() => {
h.set({
nodes: [user(1, markdown), assistant(2, markdown), assistant(3, markdown)],
partial: null,
})
})
expect(view.container.querySelectorAll('h1')).toHaveLength(2)
expect(view.container.querySelector('[data-streaming="true"]')).toBeNull()
act(() => {
h.set({
nodes: [
user(1, markdown),
assistant(2, markdown),
{ ...assistant(3, markdown), interrupted: true },
],
})
})
expect(view.getByText('已停止')).toBeTruthy()
expect(view.container.querySelectorAll('h1')).toHaveLength(2)
})
it('streaming partial frames re-render only the tail (Profiler count)', () => {
const h = makeHarness({
nodes: [user(1, 'q'), assistant(2, 'old answer'), toolResult(3, 'a')],
@@ -305,7 +344,8 @@ describe('ChatView', () => {
it('pending interactions render placeholder cards', () => {
const h = makeHarness({
pending: [{ kind: 'approval', rpcId: 'r1' as never, approvalId: 'ap1', toolName: 'bash' }],
pending: [new PendingWait('approval', RpcId('r1'), SID,
{ approvalId: 'ap1', toolName: 'bash' } as PendingWait<'approval'>['payload'], vi.fn())],
})
const view = render(<h.ChatView {...h.props} />)
expect(view.getByText(/等待审批/)).toBeTruthy()

View File

@@ -8,7 +8,8 @@ import { cleanup, render } from '@testing-library/react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { SessionId, SessionListState, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import type { ToolRowOwnerProps, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { apply as nodeApply } from '../src/index.ts'
import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
@@ -34,7 +35,7 @@ describe('tails', () => {
it('PendingCard renders the question arm with its count', () => {
const view = render(
<PendingCard item={{ kind: 'question', rpcId: 'r1' as RpcId, questions: [{}, {}] }} />,
<PendingCard item={new PendingWait('question', RpcId('r1'), 's1' as SessionId, { questions: [{}, {}] } as PendingWait<'question'>['payload'], vi.fn())} />,
)
expect(view.getByText(/等待回答2 题)/)).toBeTruthy()
})

View File

@@ -123,23 +123,25 @@ describe('selection survives on the store seat', () => {
expect(two.store.getSnapshot().selection).toEqual({ turnSeq: 9, callId: 'z' })
})
it('a title-upgrading list refresh keeps instance identity and the selection value', async () => {
it('a display-title-upgrading list refresh keeps instance identity and the selection value', async () => {
const b = bench()
// First-send shape: client-side create inserts the row without cwd (title = bare id).
b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s1') }))
const id = await b.sessions.create({})
await flush()
expect(b.sessions.list.getSnapshot().byId[id]?.title).toBe('s1')
expect(b.sessions.list.getSnapshot().byId[id]).toMatchObject({ displayTitle: 's1' })
expect(b.sessions.list.getSnapshot().byId[id]?.title).toBeUndefined()
const store = storeFor(b, 'conversation', id)
store.actions.select({ turnSeq: 3, callId: 'c1' })
store.actions.setDraft('half-typed')
// The late list refresh lands (host knows the cwd → formal title).
// The late list refresh lands (host knows the cwd → better fallback label).
feed(b, [{ id: 's1', cwd: '/w/proj-a' }])
await b.sessions.manager.refreshList()
await flush()
expect(b.sessions.list.getSnapshot().byId[id]?.title).toBe('proj-a')
expect(b.sessions.list.getSnapshot().byId[id]).toMatchObject({ displayTitle: 'proj-a' })
expect(b.sessions.list.getSnapshot().byId[id]?.title).toBeUndefined()
const after = storeFor(b, 'conversation', id)
expect(after).toBe(store)

View File

@@ -78,6 +78,7 @@ async function bench(opts?: {
create: createMock,
open: openMock,
scope: (id: SessionId) => (id === sid('new-1') ? mint(id) : scopes.get(id)),
scopeOf,
hostDescription: () => opts?.description,
} as unknown as SessionsService
if (opts?.sessions !== false) ctx.provide('sessions', sessionsFake)
@@ -126,7 +127,8 @@ describe('send / cancel', () => {
Object.defineProperty(file, 'arrayBuffer', {
value: () => Promise.resolve(Uint8Array.of(1).buffer),
})
await expect(b.scopedSvc(sid('s1')).send('', 'queue', [file])).rejects.toThrow(/不支持的图片格式/)
await expect(b.scopedSvc(sid('s1')).send('', 'queue', [file]))
.rejects.toThrow(/不支持的图片格式/)
expect(b.sessionDoubles.get(sid('s1'))?.prompt).not.toHaveBeenCalled()
})
@@ -177,7 +179,11 @@ describe('image admission and URL lifecycle', () => {
const second = new File([Uint8Array.of(4, 5)], 'second.png', { type: 'image/png' })
const attachments = b.svc.createDraftImages([first])
expect(attachments[0]).toMatchObject({ kind: 'image', file: first, previewUrl: 'blob:draft' })
expect(attachments[0]).toMatchObject({
kind: 'image',
file: first,
previewUrl: 'blob:draft',
})
expect(() => b.svc.createDraftImages([second], attachments)).toThrow(/总大小/)
expect(createObjectURL).toHaveBeenCalledTimes(1)
@@ -195,12 +201,15 @@ describe('image admission and URL lifecycle', () => {
},
})
const png = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' })
expect(() => textOnly.svc.createDraftImages([png], [], true)).toThrow(/当前模型不支持图片/)
expect(() => textOnly.svc.createDraftImages([png], [], true))
.toThrow(/当前模型不支持图片/)
const b = await bench({ description })
const video = new File([Uint8Array.of(1)], 'clip.mp4', { type: 'video/mp4' })
expect(() => b.svc.createDraftImages([video])).toThrow(/不支持的图片格式/)
const large = new File([Uint8Array.of(1, 2, 3, 4)], 'large.png', { type: 'image/png' })
const large = new File([Uint8Array.of(1, 2, 3, 4)], 'large.png', {
type: 'image/png',
})
expect(() => b.svc.createDraftImages([large])).toThrow(/单张大小限制/)
const existing = b.svc.createDraftImages([png, png])
expect(() => b.svc.createDraftImages([png], existing)).toThrow(/最多添加 2 张/)
@@ -235,7 +244,9 @@ describe('image admission and URL lifecycle', () => {
expect(session.readAttachment).toHaveBeenCalledTimes(1)
b.svc.releaseSessionImages(sid('s1'))
await vi.waitFor(() => { expect(revokeObjectURL).toHaveBeenCalledWith('blob:history-1') })
await vi.waitFor(() => {
expect(revokeObjectURL).toHaveBeenCalledWith('blob:history-1')
})
await expect(b.svc.resolveImage(sid('s1'), ref)).resolves.toBe('blob:history-2')
expect(session.readAttachment).toHaveBeenCalledTimes(2)
})

View File

@@ -21,6 +21,9 @@ import { EmptyState } from '../src/client/skeleton/EmptyState.tsx'
afterEach(cleanup)
const SID = 's1' as SessionId
/** Fallback-only chain stub (no takeover registered in these benches). */
const fallbackRenderSlotChain: ConversationRootProps['renderSlotChain'] =
(_key, _owner, opts) => opts?.fallback ?? null
function snapshotBase(): ConversationSnapshot {
return {
@@ -43,7 +46,7 @@ function listHook(rows: { id: string; title: string; cwd?: string; parentId?: st
const store = createSnapshotStore<SessionListState>({
ids: rows.map(r => r.id as SessionId),
byId: Object.fromEntries(rows.map(r => [r.id, {
id: r.id as SessionId, title: r.title, running: false, updatedAt: 1,
id: r.id as SessionId, title: `durable ${r.title}`, displayTitle: r.title, running: false, updatedAt: 1,
...(r.cwd !== undefined ? { cwd: r.cwd } : {}),
...(r.parentId !== undefined ? { parentId: r.parentId as SessionId } : {}),
}])),
@@ -73,6 +76,7 @@ describe('ConversationRoot branches', () => {
useStore={hookOf(chat)}
actions={chat.actions}
renderSlot={stubRenderSlot}
renderSlotChain={fallbackRenderSlotChain}
SessionProvider={SessionProviderStub}
views={{ list: () => [chatTab], subscribe: () => () => {}, version: () => 1 }}
addImages={vi.fn(() => null)}
@@ -135,6 +139,7 @@ describe('ConversationRoot branches', () => {
useStore={hookOf(chat)}
actions={chat.actions}
renderSlot={stubRenderSlot}
renderSlotChain={fallbackRenderSlotChain}
SessionProvider={SessionProviderStub}
views={{ list: () => [chatTab], subscribe: () => () => {}, version: () => 1 }}
addImages={vi.fn(() => null)}

View File

@@ -13,7 +13,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSnapshot, PendingInteraction, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import type { SelectionTarget, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ConversationRootProps } from '../src/client/skeleton/ConversationRoot.tsx'
// Export discipline: packages/client/AGENTS.md.
@@ -36,11 +38,12 @@ interface FakeSnapshot {
running: boolean
removed: boolean
promptError: { op: 'send' | 'stop'; error: { message: string; code: string } } | null
pending: readonly PendingInteraction[]
}
function fakeSession(init: Partial<FakeSnapshot> = {}) {
const store = createSnapshotStore<FakeSnapshot>({
nodes: [], runningCalls: [], running: false, removed: false, promptError: null, ...init,
nodes: [], runningCalls: [], running: false, removed: false, promptError: null, pending: [], ...init,
})
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot> }
}
@@ -50,7 +53,7 @@ function fakeSessions(rows: { id: string; title: string; cwd?: string; parentId?
const store = createSnapshotStore<SessionListState>({
ids: rows.map(r => sid(r.id)),
byId: Object.fromEntries(rows.map(r => [r.id, {
id: sid(r.id), title: r.title, running: false, updatedAt: 1,
id: sid(r.id), title: `durable ${r.title}`, displayTitle: r.title, running: false, updatedAt: 1,
...(r.cwd !== undefined ? { cwd: r.cwd } : {}),
...(r.parentId !== undefined ? { parentId: sid(r.parentId) } : {}),
}])),
@@ -154,8 +157,11 @@ describe('EmptyState', () => {
})
describe('ConversationRoot', () => {
function bench(tabs: ViewTab[], activeView?: string) {
const { useSession } = fakeSession({ nodes: [{ kind: 'user' }, { kind: 'user' }] })
function bench(
tabs: ViewTab[], activeView?: string, init: Partial<FakeSnapshot> = {},
renderSlotChain?: ConversationRootProps['renderSlotChain'],
) {
const { useSession } = fakeSession({ nodes: [{ kind: 'user' }, { kind: 'user' }], ...init })
const { useSessions } = fakeSessions([
{ id: 'root', title: 'proj' },
{ id: 's1', title: 'child', parentId: 'root' },
@@ -179,6 +185,7 @@ describe('ConversationRoot', () => {
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
renderSlot={renderSlot as unknown as ConversationRootProps['renderSlot']}
renderSlotChain={renderSlotChain ?? ((_key, _owner, opts) => opts?.fallback ?? null)}
SessionProvider={SessionProviderStub}
views={{
list: () => tabs,
@@ -238,6 +245,30 @@ describe('ConversationRoot', () => {
fireEvent.keyDown(box, { key: 'Enter' })
expect(send).toHaveBeenCalledWith('hi', [], 'queue')
})
it('dispatches the pending list to the composer chain; all-decline falls back to InputBar', () => {
const wait = new PendingWait('question', RpcId('rq'), sid('s1'),
{ questions: [{ id: 'mode', question: 'Choose?', options: [{ label: 'Fast' }] }] } as PendingWait<'question'>['payload'], vi.fn())
// A matching entry takes the composer over.
const renderSlotChain = vi.fn(() => <div>question takeover</div>) as unknown as ConversationRootProps['renderSlotChain']
bench([tab('chat', 'Chat')], undefined, { pending: [wait] }, renderSlotChain)
expect(screen.getByText('question takeover')).toBeTruthy()
expect(screen.queryByPlaceholderText(/输入消息/)).toBeNull()
// The owner dispatches the raw pending list (chain currency); routing
// lives in entry selectors, not here.
expect(renderSlotChain).toHaveBeenCalledWith(
'conversation.composer',
expect.objectContaining({
interactions: expect.arrayContaining([expect.objectContaining({ key: 'q:rq' })]),
}),
expect.objectContaining({ fallback: expect.anything() }),
)
cleanup()
// Zero registered entries (default all-decline stub): the fallback IS the
// default InputBar — behavior equals the pre-chain composer.
bench([tab('chat', 'Chat')], undefined, { pending: [wait] })
expect(screen.getByPlaceholderText(/输入消息/)).toBeTruthy()
})
})
describe('DetailsPanel', () => {

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-client-ui-layout
Shell plugin: three-column AppFrame (drag handles, concession chain) + ctx.layout viewing-state service (nav, panel widths, persist); defines the sidebar/conversation/details/conversation.empty slots. A closed sidebar retains a 56px control rail while details closes to zero width; collapse/expand animates the grid tracks on the deepsuite sider curve. Contract: api-contracts v3 §5.
Shell plugin: three-column AppFrame (drag handles, concession chain) + ctx.layout viewing-state service (nav, panel widths, persist); defines the sidebar/conversation/details/conversation.empty slots. The sidebar is fixed-width (it never concedes to viewport pressure — only details shrinks, then auto-closes); a closed sidebar retains a 56px control rail while details closes to zero width; collapse/expand animates the grid tracks on the deepsuite sider curve. Contract: api-contracts v3 §5.
Slot declarations use the composed-props entry form (`owner` share, no full `props`): the exported OwnerShare contracts are `SidebarOwnerProps` / `ConvOwnerProps` / `DetailsOwnerProps` / `EmptyOwnerProps` — registrants reference them via `OwnerOf<'sidebar' | ...>` and compose their own injected share locally. No entry declares `children` (declaring it requires the registered component to carry the slots face — reserved for future business slots): delegation authority is the component-side whitelist, i.e. AppFrame's `ScopedSlots<FrameSlotKey>` face over sidebar/conversation/details/conversation.empty. Since the root-slot rework the frame itself registers into 'root' and renders those child slots at its own render sites; the shell only renders 'root'.

View File

@@ -33,19 +33,20 @@
"watch": "tsdown --watch"
},
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"peerDependencies": {
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"cordis": "^4.0.0-rc.7"
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"
},
"files": [
"lib/index.js",

View File

@@ -86,11 +86,25 @@
height: 32px;
border-radius: 10px;
box-sizing: border-box;
background: var(--dsw-alias-bg-layer-2);
background: var(--dsw-alias-button-floating-fill);
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
/* Hover affordance: the pill hides until the pointer is over the owning
column (data-side pairs handle and column), the strip itself, or a drag. */
opacity: 0;
transition:
opacity var(--ds-transition-duration-slow) var(--ds-ease-in-out),
background var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
.sidebarCol:hover ~ .handle[data-side='sidebar']::after,
.detailsCol:hover ~ .handle[data-side='details']::after,
.handle:hover::after,
.handle[data-dragging='true']::after {
opacity: 1;
}
.handle:hover::after,
.handle[data-dragging='true']::after {
background: var(--dsw-alias-button-floating-hover);
border-color: var(--dsw-alias-border-l3);
}

View File

@@ -34,8 +34,8 @@ function DetailsColumn(props: { children?: ReactNode }) {
return <div className={css.detailsCol}>{props.children}</div>
}
/** One drag handle: pointer capture, rAF-throttled dx reports against the drag-start origin. */
function DragHandle(props: { left: number; onStart: () => void; onDrag: (dx: number) => void; onEnd: () => void }) {
/** One drag handle: pointer capture, rAF-throttled dx reports against the drag-start origin. `side` keys the hover-reveal CSS to the owning column. */
function DragHandle(props: { side: 'sidebar' | 'details'; left: number; onStart: () => void; onDrag: (dx: number) => void; onEnd: () => void }) {
const [dragging, setDragging] = useState(false)
const origin = useRef(0)
const latest = useRef(0)
@@ -72,6 +72,7 @@ function DragHandle(props: { left: number; onStart: () => void; onDrag: (dx: num
<div
className={css.handle}
style={{ left: props.left }}
data-side={props.side}
data-dragging={dragging || undefined}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
@@ -161,8 +162,8 @@ export function AppFrame({ useStore, actions, renderSlot, SessionProvider }: App
)}
</SessionProvider>
{/* The collapsed rail is fixed-width: no resize handle while closed. */}
{panels.sidebar > 0 && <DragHandle left={cols.sidebar} onStart={onSidebarStart} onDrag={onSidebarDrag} onEnd={onDragEnd} />}
{cols.details > 0 && <DragHandle left={viewport - cols.details} onStart={onDetailsStart} onDrag={onDetailsDrag} onEnd={onDragEnd} />}
{panels.sidebar > 0 && <DragHandle side="sidebar" left={cols.sidebar} onStart={onSidebarStart} onDrag={onSidebarDrag} onEnd={onDragEnd} />}
{cols.details > 0 && <DragHandle side="details" left={viewport - cols.details} onStart={onDetailsStart} onDrag={onDetailsDrag} onEnd={onDragEnd} />}
</div>
)
}

View File

@@ -1,12 +1,13 @@
/**
* Pure concession-chain column solver for the three-column AppFrame.
* Chain order is fixed by contract: keep center >= CENTER_MIN by shrinking
* details first, then sidebar, then auto-closing details (derived zero width —
* persisted width preferences are never rewritten, so widening the window
* restores them). Center absorbs any remaining deficit as the last resort.
* Inputs are the layout store's plain width preferences (0 = closed); a
* closed sidebar resolves to the fixed SIDEBAR_COLLAPSED control rail while
* closed details resolve to zero width.
* details, then auto-closing it (derived zero width — persisted width
* preferences are never rewritten, so widening the window restores them).
* The sidebar never concedes: its rendered width is always the drag
* preference (or the collapsed rail), and center absorbs any remaining
* deficit as the last resort. Inputs are the layout store's plain width
* preferences (0 = closed); a closed sidebar resolves to the fixed
* SIDEBAR_COLLAPSED control rail while closed details resolve to zero width.
*/
/** Resolved widths for one frame; center may drop below CENTER_MIN only at the final fallback. */
@@ -16,11 +17,11 @@ export interface Columns { sidebar: number; center: number; details: number }
/** Center column floor; only the final fallback may go below it. */
export const CENTER_MIN = 640
/** Sidebar drag clamp floor. */
export const SIDEBAR_MIN = 240
export const SIDEBAR_MIN = 280
/** Sidebar drag clamp ceiling. */
export const SIDEBAR_MAX = 420
/** Sidebar width before any user drag. */
export const SIDEBAR_DEFAULT = 300
/** Sidebar width before any user drag (= the drag floor). */
export const SIDEBAR_DEFAULT = 280
/** Closed-sidebar rail: a 24px icon column between 16px horizontal paddings. */
export const SIDEBAR_COLLAPSED = 56
/** Details drag clamp floor. */
@@ -44,38 +45,26 @@ export function clampWidth(px: number, min: number, max: number): number {
/**
* Solve the three column widths for one viewport frame. Pure: no hysteresis —
* the output is a function of (viewport, preferences) only, so recovery on
* re-widening is automatic. After the auto-close step the details pressure is
* gone, so the sidebar returns to its preferred width when it fits.
* Preferences re-clamp here because they cross a durable boundary
* (localStorage rehydration may carry stale ranges).
* re-widening is automatic. Preferences re-clamp here because they cross a
* durable boundary (localStorage rehydration may carry stale ranges).
* @param viewport - available frame width in px.
* @param sidebar - sidebar width preference in px (0 = closed).
* @param details - details width preference in px (0 = closed).
* @returns resolved widths; details 0 means visually closed (never unmounted), while a closed sidebar keeps its compact rail.
*/
export function computeColumns(viewport: number, sidebar: number, details: number): Columns {
const s0 = sidebar === 0 ? SIDEBAR_COLLAPSED : clampWidth(sidebar, SIDEBAR_MIN, SIDEBAR_MAX)
// The sidebar is fixed at its preference (or the rail) — it never concedes.
const s = sidebar === 0 ? SIDEBAR_COLLAPSED : clampWidth(sidebar, SIDEBAR_MIN, SIDEBAR_MAX)
const d0 = details === 0 ? 0 : clampWidth(details, DETAILS_MIN, DETAILS_MAX)
// Step 1: everything fits at preferred widths.
if (s0 + d0 + CENTER_MIN <= viewport) return { sidebar: s0, center: viewport - s0 - d0, details: d0 }
if (s + d0 + CENTER_MIN <= viewport) return { sidebar: s, center: viewport - s - d0, details: d0 }
// Step 2: shrink details toward its minimum.
const d1 = d0 === 0 ? 0 : Math.max(DETAILS_MIN, viewport - s0 - CENTER_MIN)
if (s0 + d1 + CENTER_MIN <= viewport) return { sidebar: s0, center: CENTER_MIN, details: d1 }
const d1 = d0 === 0 ? 0 : Math.max(DETAILS_MIN, viewport - s - CENTER_MIN)
if (s + d1 + CENTER_MIN <= viewport) return { sidebar: s, center: CENTER_MIN, details: d1 }
// Step 3: shrink sidebar toward its minimum (the collapsed rail never shrinks).
const s1 = sidebar === 0 ? SIDEBAR_COLLAPSED : Math.max(SIDEBAR_MIN, viewport - d1 - CENTER_MIN)
if (s1 + d1 + CENTER_MIN <= viewport) return { sidebar: s1, center: CENTER_MIN, details: d1 }
// Step 4: auto-close details (derived — preferences untouched). With the
// details pressure gone the sidebar concession is re-solved from preference.
if (d1 > 0) {
if (s0 + CENTER_MIN <= viewport) return { sidebar: s0, center: viewport - s0, details: 0 }
const s2 = sidebar === 0 ? SIDEBAR_COLLAPSED : Math.max(SIDEBAR_MIN, viewport - CENTER_MIN)
return { sidebar: s2, center: Math.max(0, viewport - s2), details: 0 }
}
// Step 5: center absorbs the deficit (may drop below CENTER_MIN).
return { sidebar: s1, center: Math.max(0, viewport - s1 - d1), details: d1 }
// Step 3: auto-close details (derived — preferences untouched); center
// absorbs any remaining deficit (may drop below CENTER_MIN).
return { sidebar: s, center: Math.max(0, viewport - s), details: 0 }
}

View File

@@ -50,7 +50,7 @@ function hookOf<T>(inst: { subscribe: (fn: () => void) => () => void; getSnapsho
function mountFrame() {
window.innerWidth = frameWidth // first-render viewport source before the observer fires
const instance = createLayoutStore().create()
instance.actions.openDetails() // seed: sidebar at default 300, details open at default 360
instance.actions.openDetails() // seed: sidebar at default 280, details open at default 360
const slotCalls: { key: string; props: unknown }[] = []
const renderSlot = ((key: string, owner: object) => {
slotCalls.push({ key, props: owner })
@@ -116,7 +116,7 @@ afterEach(() => {
describe('AppFrame', () => {
it('renders three tracks from store state', () => {
const { frame } = mountFrame()
expect(tracks(frame)).toEqual([300, 360])
expect(tracks(frame)).toEqual([280, 360])
})
it('renders the session pair with empty owner shares (sessionId is framework-standard)', () => {
@@ -142,13 +142,13 @@ describe('AppFrame', () => {
it('sidebar slot receives live concession output as owner props', () => {
const { slotCalls } = mountFrame()
expect(slotCalls.find((c) => c.key === 'sidebar')!.props).toEqual({ collapsed: false, width: 300 })
expect(slotCalls.find((c) => c.key === 'sidebar')!.props).toEqual({ collapsed: false, width: 280 })
})
it('sidebar drag widens through rAF-batched pointer moves', () => {
const { frame } = mountFrame()
const handles = frame.querySelectorAll('[class*="handle"]')
drag(handles[0]!, 300, 350)
drag(handles[0]!, 280, 350)
expect(tracks(frame)[0]).toBe(350)
})
@@ -160,18 +160,18 @@ describe('AppFrame', () => {
})
it('drag base is the rendered (concession-clamped) width, not the preference', () => {
frameWidth = 1250 // step-2 squeeze: details renders 310 while preference is 360
frameWidth = 1250 // step-2 squeeze: details renders 330 while preference is 360
const { frame, instance } = mountFrame()
expect(tracks(frame)).toEqual([300, 310])
expect(tracks(frame)).toEqual([280, 330])
const handles = frame.querySelectorAll('[class*="handle"]')
drag(handles[1]!, 940, 950) // shrink by 10 from the rendered width
expect(instance.getSnapshot().details).toBe(300)
drag(handles[1]!, 920, 930) // shrink by 10 from the rendered width
expect(instance.getSnapshot().details).toBe(320)
})
it('details column stays mounted at zero width', () => {
const { frame, instance, getByTestId } = mountFrame()
act(() => { instance.actions.closeDetails() })
expect(tracks(frame)).toEqual([300, 0])
expect(tracks(frame)).toEqual([280, 0])
expect(getByTestId('details-content')).toBeTruthy()
expect(frame.hasAttribute('data-details-collapsed')).toBe(true)
})
@@ -190,10 +190,10 @@ describe('AppFrame', () => {
const { frame } = mountFrame()
frameWidth = 1250
act(() => { fireResize?.(); vi.advanceTimersByTime(20) })
expect(tracks(frame)).toEqual([300, 310])
expect(tracks(frame)).toEqual([280, 330])
frameWidth = 1920
act(() => { fireResize?.(); vi.advanceTimersByTime(20) })
expect(tracks(frame)).toEqual([300, 360])
expect(tracks(frame)).toEqual([280, 360])
})
it('drag handles disappear for collapsed columns', () => {
@@ -223,7 +223,7 @@ describe('AppFrame — guard branches', () => {
it('two moves inside one frame coalesce through the pending rAF', () => {
const { frame, instance } = mountFrame()
const handle = frame.querySelectorAll('[class*="handle"]')[0]!
act(() => { handle.dispatchEvent(new PointerEvent('pointerdown', { pointerId: 1, clientX: 300, bubbles: true })) })
act(() => { handle.dispatchEvent(new PointerEvent('pointerdown', { pointerId: 1, clientX: 280, bubbles: true })) })
act(() => {
// Two moves before the frame flushes: the second must ride the pending
// rAF (frame.current ??= guard), and the flush sees the latest x.
@@ -238,7 +238,7 @@ describe('AppFrame — guard branches', () => {
it('pointerup with a pending rAF cancels it and commits the final position', () => {
const { frame, instance } = mountFrame()
const handle = frame.querySelectorAll('[class*="handle"]')[0]!
act(() => { handle.dispatchEvent(new PointerEvent('pointerdown', { pointerId: 1, clientX: 300, bubbles: true })) })
act(() => { handle.dispatchEvent(new PointerEvent('pointerdown', { pointerId: 1, clientX: 280, bubbles: true })) })
act(() => {
handle.dispatchEvent(new PointerEvent('pointermove', { pointerId: 1, clientX: 360, bubbles: true }))
// No timer advance: the rAF is still pending when pointerup arrives.
@@ -252,7 +252,7 @@ describe('AppFrame — guard branches', () => {
frameWidth = 0
act(() => { fireResize?.(); vi.advanceTimersByTime(20) })
// Track template still reflects the last non-zero viewport.
expect(tracks(frame)).toEqual([300, 360])
expect(tracks(frame)).toEqual([280, 360])
})
})
@@ -270,6 +270,6 @@ describe('AppFrame — unmount with an in-flight resize frame', () => {
const { frame } = mountFrame()
frameWidth = 1250
act(() => { fireResize?.(); fireResize?.(); vi.advanceTimersByTime(20) })
expect(tracks(frame)).toEqual([300, 310])
expect(tracks(frame)).toEqual([280, 330])
})
})

View File

@@ -19,7 +19,7 @@ describe('clampWidth', () => {
describe('computeColumns', () => {
it('step 1: everything fits at preferred widths', () => {
const cols = computeColumns(1920, open(SIDEBAR_DEFAULT), open(DETAILS_DEFAULT))
expect(cols).toEqual({ sidebar: 300, center: 1920 - 300 - 360, details: 360 })
expect(cols).toEqual({ sidebar: 280, center: 1920 - 280 - 360, details: 360 })
})
it('closed sidebar keeps its compact rail while closed details contribute zero width', () => {
@@ -31,12 +31,13 @@ describe('computeColumns', () => {
const cols = computeColumns(1920, open(9999), open(1))
expect(cols.sidebar).toBe(420)
expect(cols.details).toBe(300)
expect(computeColumns(1920, open(1), open(DETAILS_DEFAULT)).sidebar).toBe(SIDEBAR_MIN)
})
it('step 2: details shrinks first, center pinned at min', () => {
// 300 + 360 + 640 = 1300 > 1250; details concedes to 1250-300-640 = 310.
// 280 + 360 + 640 = 1280 > 1250; details concedes to 1250-280-640 = 330.
const cols = computeColumns(1250, open(SIDEBAR_DEFAULT), open(DETAILS_DEFAULT))
expect(cols).toEqual({ sidebar: 300, center: CENTER_MIN, details: 310 })
expect(cols).toEqual({ sidebar: 280, center: CENTER_MIN, details: 330 })
})
it('boundary: exactly at the step-1/step-2 seam', () => {
@@ -46,28 +47,16 @@ describe('computeColumns', () => {
expect(one).toEqual({ sidebar: 300, center: CENTER_MIN, details: 359 })
})
it('step 3: sidebar concedes after details hits its min', () => {
// details floor 300: sidebar = 1220-300-640 = 280.
const cols = computeColumns(1220, open(SIDEBAR_DEFAULT), open(DETAILS_DEFAULT))
expect(cols).toEqual({ sidebar: 280, center: CENTER_MIN, details: DETAILS_MIN })
it('step 3: details auto-closes when its min still starves center — sidebar holds its preference', () => {
// 280 + 300 + 640 = 1220 > 1210 → details 0; sidebar untouched: center = 1210-280 = 930.
const cols = computeColumns(1210, open(SIDEBAR_DEFAULT), open(DETAILS_DEFAULT))
expect(cols).toEqual({ sidebar: 280, center: 930, details: 0 })
})
it('step 4: details auto-closes when both panels are at min and center still starves', () => {
// 240 + 300 + 640 = 1180 > 1100 → details 0; sidebar preference (300) fits: 1100-300 = 800 center.
const cols = computeColumns(1100, open(SIDEBAR_DEFAULT), open(DETAILS_DEFAULT))
expect(cols).toEqual({ sidebar: 300, center: 800, details: 0 })
})
it('step 4 keeps squeezing sidebar when preference no longer fits', () => {
// 900 < 300+640: sidebar = max(240, 900-640) = 260.
const cols = computeColumns(900, open(SIDEBAR_DEFAULT), open(DETAILS_DEFAULT))
expect(cols).toEqual({ sidebar: 260, center: CENTER_MIN, details: 0 })
})
it('step 5: center absorbs the deficit as last resort (details closed)', () => {
// 700 < 240+640: sidebar floors at 240, center takes 460 < CENTER_MIN.
it('the sidebar never concedes: center absorbs the deficit below CENTER_MIN', () => {
// 700 < 280+640: sidebar keeps 280, center takes 420 < CENTER_MIN.
const cols = computeColumns(700, open(SIDEBAR_DEFAULT), closed(DETAILS_DEFAULT))
expect(cols).toEqual({ sidebar: SIDEBAR_MIN, center: 460, details: 0 })
expect(cols).toEqual({ sidebar: SIDEBAR_DEFAULT, center: 420, details: 0 })
})
it('sidebar-closed narrow window: details concedes then auto-closes', () => {
@@ -81,11 +70,11 @@ describe('computeColumns', () => {
})
})
it('tiny viewport: both panels yield everything to center', () => {
it('tiny viewport: details closes, sidebar holds, center takes the remainder', () => {
const cols = computeColumns(400, open(SIDEBAR_DEFAULT), open(DETAILS_DEFAULT))
expect(cols.details).toBe(0)
expect(cols.sidebar).toBe(SIDEBAR_MIN)
expect(cols.center).toBe(Math.max(0, 400 - SIDEBAR_MIN))
expect(cols.sidebar).toBe(SIDEBAR_DEFAULT)
expect(cols.center).toBe(Math.max(0, 400 - SIDEBAR_DEFAULT))
})
it('recovery is pure: re-widening restores preferred widths untouched', () => {
@@ -99,7 +88,7 @@ describe('computeColumns', () => {
describe('computeColumns — degenerate viewports', () => {
it('sidebar closed and viewport below CENTER_MIN: details auto-closes, center takes the rest', () => {
// Reaches step 4's re-solve with the compact rail as the sidebar floor.
// Reaches step 3's auto-close with the compact rail sidebar.
expect(computeColumns(500, closed(300), open(DETAILS_DEFAULT)))
.toEqual({ sidebar: SIDEBAR_COLLAPSED, center: 500 - SIDEBAR_COLLAPSED, details: 0 })
})

View File

@@ -1,6 +1,10 @@
# @deepseek-ai/dsh-client-ui-primitives
Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Input, markdown family (MessageText/JsonBlock). Contract: api-contracts v3 §8.
Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Input, markdown family (MessageText/MarkdownText/JsonBlock). Contract: api-contracts v3 §8.
## Markdown rendering
`MarkdownText` renders GFM from untrusted assistant output through React elements. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders image alt text without loading remote resources; `MessageText` remains the literal-text primitive for user-authored content.
## Model Experience
@@ -15,4 +19,3 @@ None; this package neither assembles nor sends a provider request.
- **Glyph-level icons are redrawn approximations** — the fish logo (and the sparkle held by ui-conversation) come from font glyphs whose vector geometry is not exportable from the local design data; hand-authored recreations stand in until an exact export path exists.
- **Pill and Input have no design source** — both atoms are self-defined; the sidebar search field and view-tab strip that resemble them are consumer-owned compositions, not these atoms.
- **StateDot `Active` variant is a hidden placeholder in the design** — not implemented; the four shipped states (done/warning/ongoing/error) are the complete P-I surface.
- **MessageText renders plain text** — markdown support swaps this component's internals later; consumers must not assume block structure.

View File

@@ -21,7 +21,9 @@
"license": "BSD-3-Clause",
"dependencies": {
"clsx": "^2.0.0",
"react": "^18.2.0"
"react": "^18.2.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",

View File

@@ -0,0 +1,56 @@
// DeepSeek Harness brand wordmark (figma 356:14644, exact extract): whale +
// "deepseek" letterforms + HARNESS badge plate in one svg. Native 182x24.
// Ink rides currentColor; the badge text is knocked out in the inverted
// label color so the plate stays legible in both themes.
import type { IconProps } from './icons/props.ts'
/**
* Render the full brand wordmark.
* @param props.size - height in px (default 24; width keeps the 182:24 ratio).
* @param props.className - extra class for layout placement.
* @returns the wordmark svg (aria-hidden decorative brand art).
*/
export function BrandWordmark({ size = 24, className }: IconProps) {
return (
<svg
width={(size * 182) / 24}
height={size}
className={className}
viewBox="0 0 182 24"
fill="none"
aria-hidden="true"
>
<path d="M68.416 18.2447H67.0501V16.1272H68.416C69.2619 16.1272 70.1166 15.9163 70.6671 15.3304C71.2181 14.7444 71.426 13.8455 71.426 12.9471C71.426 12.0487 71.2268 11.1498 70.6671 10.5643C70.1083 9.97831 69.2619 9.76744 68.416 9.76744C67.5701 9.76744 66.7154 9.97831 66.1639 10.5643C65.6129 11.1503 65.4049 12.0487 65.4049 12.9471V21.6435H63.009V7.6582H65.4049V8.54883H65.8442C65.8918 8.49393 65.9394 8.44728 65.9875 8.40064C66.5871 7.85353 67.5049 7.6582 68.4072 7.6582C69.8212 7.6582 71.2341 8.00998 72.1607 8.98662C73.0868 9.96325 73.4143 11.4632 73.4143 12.9558C73.4143 14.4485 73.0785 15.9406 72.1607 16.925C71.2424 17.9094 69.8212 18.2457 68.416 18.2457V18.2447Z" fill="currentColor"/>
<path d="M31.9551 8.03497H33.3204V10.1525H31.9551C31.1087 10.1525 30.2545 10.3633 29.7035 10.9493C29.1525 11.5353 28.945 12.4342 28.945 13.3326C28.945 14.231 29.1447 15.1294 29.7035 15.7154C30.2623 16.3014 31.1087 16.5122 31.9551 16.5122C32.8015 16.5122 33.6562 16.3014 34.2072 15.7154C34.7582 15.1294 34.9657 14.231 34.9657 13.3326V4.62842H37.3611V18.6219H34.9657V17.7313H34.5264C34.4783 17.7857 34.4307 17.8329 34.3826 17.8795C33.7835 18.4261 32.8652 18.6219 31.9629 18.6219C30.5494 18.6219 29.136 18.2707 28.2099 17.294C27.2838 16.3174 26.9563 14.817 26.9563 13.3248C26.9563 11.8327 27.2916 10.34 28.2099 9.35561C29.136 8.37898 30.5494 8.03497 31.9551 8.03497Z" fill="currentColor"/>
<path d="M49.3786 13.1431V13.9948H42.9984V12.2996H47.2305C47.1348 11.6825 46.9113 11.1043 46.5119 10.682C45.9371 10.0727 45.0503 9.85409 44.1723 9.85409C43.2943 9.85409 42.4076 10.0727 41.8328 10.682C41.258 11.2913 41.05 12.2213 41.05 13.1435C41.05 14.0658 41.2575 15.003 41.8328 15.6046C42.4076 16.2061 43.2939 16.433 44.1723 16.433C45.0508 16.433 45.9371 16.2143 46.5119 15.6046C46.5916 15.5186 46.6635 15.4248 46.7354 15.331H49.0992C48.8918 16.0657 48.5643 16.7299 48.0691 17.2454C47.111 18.2531 45.6339 18.6205 44.1723 18.6205C42.7108 18.6205 41.2337 18.2609 40.2755 17.2454C39.3174 16.2299 38.9661 14.6828 38.9661 13.1435C38.9661 11.6043 39.3096 10.0494 40.2755 9.04168C41.242 8.03396 42.7108 7.66663 44.1723 7.66663C45.6339 7.66663 47.111 8.02618 48.0691 9.04168C49.0351 10.0572 49.3786 11.6043 49.3786 13.1435V13.1431Z" fill="currentColor"/>
<path d="M61.4045 13.1431V13.9948H55.0243V12.2996H59.2564C59.1602 11.6825 58.9372 11.1043 58.5378 10.682C57.963 10.0727 57.0762 9.85409 56.1982 9.85409C55.3202 9.85409 54.4335 10.0727 53.8587 10.682C53.2839 11.2913 53.0759 12.2213 53.0759 13.1435C53.0759 14.0658 53.2834 15.003 53.8587 15.6046C54.4335 16.2061 55.3202 16.433 56.1982 16.433C57.0762 16.433 57.963 16.2143 58.5378 15.6046C58.6179 15.5186 58.6894 15.4248 58.7608 15.331H61.1251C60.9171 16.0657 60.5897 16.7299 60.0945 17.2454C59.1364 18.2531 57.6593 18.6205 56.1982 18.6205C54.7372 18.6205 53.2596 18.2609 52.3014 17.2454C51.3432 16.2299 50.9919 14.6828 50.9919 13.1435C50.9919 11.6043 51.3355 10.0494 52.3014 9.04168C53.2678 8.03396 54.7367 7.66663 56.1982 7.66663C57.6598 7.66663 59.1364 8.02618 60.0945 9.04168C61.061 10.0572 61.4045 11.6043 61.4045 13.1435V13.1431Z" fill="currentColor"/>
<path d="M80.242 18.6214C81.7035 18.6214 83.1801 18.4105 84.1383 17.809C85.0965 17.2075 85.4482 16.2931 85.4482 15.3869C85.4482 14.4807 85.1042 13.5585 84.1383 12.9647C83.1801 12.371 81.703 12.1518 80.242 12.1518C79.6186 12.1518 79.0438 12.0658 78.6366 11.8394C78.2294 11.6047 78.0778 11.2534 78.0778 10.9017C78.0778 10.5499 78.2216 10.1908 78.6366 9.9639C79.0438 9.72921 79.6749 9.65147 80.2973 9.65147C80.9198 9.65147 81.5509 9.73747 81.9591 9.9639C82.3663 10.1986 82.5179 10.5499 82.5179 10.9017H84.9531C84.9531 9.99499 84.6421 9.07327 83.7719 8.47951C82.9017 7.88576 81.5679 7.66663 80.2424 7.66663C78.9169 7.66663 77.5837 7.8775 76.713 8.47951C75.8427 9.08104 75.5308 9.99499 75.5308 10.9017C75.5308 11.8083 75.8423 12.73 76.713 13.3238C77.5832 13.9176 78.9165 14.1367 80.2424 14.1367C80.929 14.1367 81.688 14.2227 82.1428 14.4491C82.5985 14.676 82.7579 15.0351 82.7579 15.3869C82.7579 15.7387 82.5985 16.0977 82.1428 16.3246C81.688 16.5511 80.9931 16.6371 80.3066 16.6371C79.62 16.6371 78.9169 16.5511 78.4694 16.3246C78.0224 16.0982 77.8543 15.7387 77.8543 15.3869H75.0435C75.0435 16.2935 75.3865 17.2153 76.3534 17.809C77.3194 18.4028 78.7809 18.6214 80.2424 18.6214H80.242Z" fill="currentColor"/>
<path d="M97.4733 13.1431V13.9948H91.0932V12.2996H95.3252C95.23 11.6825 95.006 11.1043 94.6071 10.682C94.0313 10.0727 93.1456 9.85409 92.2666 9.85409C91.3876 9.85409 90.5018 10.0727 89.927 10.682C89.3522 11.2913 89.1452 12.2213 89.1452 13.1435C89.1452 14.0658 89.3522 15.003 89.927 15.6046C90.5018 16.2061 91.3886 16.433 92.2666 16.433C93.1446 16.433 94.0313 16.2143 94.6071 15.6046C94.6863 15.5186 94.7587 15.4248 94.8301 15.331H97.1935C96.9855 16.0657 96.6585 16.7299 96.1639 17.2454C95.2057 18.2531 93.7281 18.6205 92.2666 18.6205C90.805 18.6205 89.3284 18.2609 88.3703 17.2454C87.4121 16.2299 87.0613 14.6828 87.0613 13.1435C87.0613 11.6043 87.4043 10.0494 88.3703 9.04168C89.3367 8.03396 90.806 7.66663 92.2666 7.66663C93.7272 7.66663 95.2057 8.02618 96.1639 9.04168C97.1298 10.0572 97.4729 11.6043 97.4729 13.1435L97.4733 13.1431Z" fill="currentColor"/>
<path d="M109.499 13.1431V13.9948H103.119V12.2996H107.351C107.256 11.6825 107.032 11.1043 106.632 10.682C106.057 10.0727 105.172 9.85409 104.293 9.85409C103.414 9.85409 102.528 10.0727 101.953 10.682C101.378 11.2913 101.17 12.2213 101.17 13.1435C101.17 14.0658 101.378 15.003 101.953 15.6046C102.528 16.2061 103.415 16.433 104.293 16.433C105.171 16.433 106.057 16.2143 106.632 15.6046C106.712 15.5186 106.784 15.4248 106.856 15.331H109.22C109.012 16.0657 108.685 16.7299 108.19 17.2454C107.231 18.2531 105.754 18.6205 104.293 18.6205C102.831 18.6205 101.355 18.2609 100.396 17.2454C99.4382 16.2299 99.0864 14.6828 99.0864 13.1435C99.0864 11.6043 99.4295 10.0494 100.396 9.04168C101.362 8.03396 102.832 7.66663 104.293 7.66663C105.754 7.66663 107.231 8.02618 108.19 9.04168C109.156 10.0572 109.499 11.6043 109.499 13.1435V13.1431Z" fill="currentColor"/>
<path d="M113.5 4.62817H111.104V18.6217H113.5V4.62817Z" fill="currentColor"/>
<path d="M117.589 12.8154L121.517 18.6208H118.554L114.625 12.8154L118.554 8.15088H121.517L117.589 12.8154Z" fill="currentColor"/>
<g clipPath="url(#dsh-wordmark-whale-clip)">
<path d="M23.0584 4.95203C22.8129 4.83203 22.7074 5.06103 22.5639 5.17704C22.5149 5.21454 22.4734 5.26354 22.4319 5.30854C22.0734 5.69155 21.6543 5.94306 21.1073 5.91306C20.3073 5.86806 19.6243 6.11957 19.0203 6.73158C18.8918 5.97706 18.4652 5.52655 17.8162 5.23754C17.4767 5.08753 17.1332 4.93703 16.8952 4.61052C16.7292 4.37801 16.6837 4.11901 16.6007 3.8635C16.5477 3.70949 16.4952 3.55199 16.3177 3.52549C16.1252 3.49549 16.0497 3.65699 15.9742 3.792C15.6722 4.34401 15.5552 4.95203 15.5667 5.56805C15.5932 6.95359 16.1782 8.05712 17.3407 8.84215C17.4727 8.93215 17.5067 9.02215 17.4652 9.15366C17.3857 9.42416 17.2917 9.68667 17.2087 9.95718C17.1557 10.1297 17.0767 10.1677 16.8917 10.0922C16.2537 9.82568 15.7027 9.43117 15.2156 8.95465C14.3891 8.15513 13.6416 7.2726 12.7096 6.58158C12.4906 6.42007 12.2716 6.27007 12.045 6.12707C11.094 5.20354 12.1696 4.44502 12.4186 4.35501C12.6791 4.26101 12.5091 3.938 11.6675 3.942C10.826 3.9455 10.056 4.22751 9.07446 4.60302C8.93096 4.65952 8.77995 4.70052 8.62545 4.73452C7.73492 4.56552 6.80989 4.52802 5.84386 4.63702C4.02481 4.83953 2.57177 5.69955 1.50373 7.1676C0.220694 8.93215 -0.0813148 10.9372 0.288196 13.0283C0.676708 15.2323 1.80174 17.0569 3.53029 18.4834C5.32285 19.9625 7.38741 20.6875 9.74298 20.5485C11.1735 20.466 12.7661 20.2745 14.5626 18.7539C15.0156 18.9795 15.4912 19.0695 16.2797 19.137C16.8872 19.1935 17.4722 19.107 17.9252 19.013C18.6347 18.8629 18.5857 18.2059 18.3292 18.0854C16.2497 17.1169 16.7062 17.5109 16.2912 17.1919C17.3477 15.9419 18.9618 13.7198 19.4598 10.6942C19.5088 10.3602 19.5713 9.88968 19.5638 9.61917C19.5598 9.45417 19.5978 9.39016 19.7863 9.37116C20.3073 9.31116 20.8128 9.16866 21.2773 8.91315C22.6249 8.17713 23.1684 6.96809 23.2964 5.51905C23.3154 5.29754 23.2924 5.06853 23.0584 4.95203ZM11.3165 17.9954C9.30097 16.4109 8.32344 15.8894 7.91992 15.9119C7.54241 15.9344 7.61042 16.3664 7.69342 16.6479C7.78042 16.9259 7.89342 17.1174 8.05193 17.3614C8.16143 17.5229 8.23694 17.7629 7.94243 17.9434C7.29341 18.3449 6.16487 17.8084 6.11187 17.7819C4.79833 17.0084 3.7003 15.9874 2.92628 14.5908C2.17875 13.2468 1.74474 11.8047 1.67324 10.2657C1.65424 9.89418 1.76374 9.76267 2.13375 9.69517C2.62077 9.60517 3.12278 9.58617 3.6093 9.65767C5.66636 9.95818 7.41741 10.8777 8.88545 12.3348C9.72348 13.1643 10.3575 14.1558 11.0105 15.1243C11.705 16.1529 12.4521 17.1329 13.4036 17.9364C13.7396 18.2179 14.0076 18.4319 14.2641 18.5899C13.4906 18.6764 12.1996 18.6949 11.3165 17.9964V17.9954ZM12.2826 11.7817C12.2826 11.6167 12.4146 11.4852 12.5806 11.4852C12.6181 11.4852 12.6521 11.4927 12.6826 11.5037C12.7241 11.5187 12.7621 11.5412 12.7921 11.5752C12.8451 11.6277 12.8751 11.7027 12.8751 11.7817C12.8751 11.9467 12.7431 12.0782 12.5771 12.0782C12.4111 12.0782 12.2826 11.9467 12.2826 11.7817ZM15.2831 13.3208C15.0906 13.3998 14.8981 13.4673 14.7131 13.4748C14.4261 13.4898 14.1131 13.3733 13.9431 13.2308C13.6791 13.0093 13.4901 12.8853 13.4111 12.4988C13.3771 12.3338 13.3961 12.0782 13.4261 11.9317C13.4941 11.6162 13.4186 11.4137 13.1961 11.2297C13.0151 11.0797 12.7846 11.0382 12.5316 11.0382C12.4371 11.0382 12.3506 10.9967 12.2861 10.9632C12.1806 10.9107 12.0936 10.7792 12.1766 10.6177C12.2031 10.5652 12.3316 10.4377 12.3616 10.4152C12.7051 10.2197 13.1011 10.2837 13.4676 10.4302C13.8071 10.5692 14.0641 10.8242 14.4336 11.1847C14.8111 11.6202 14.8791 11.7402 15.0941 12.0672C15.2641 12.3228 15.4186 12.5853 15.5247 12.8858C15.5887 13.0733 15.5057 13.2268 15.2831 13.3208Z" fill="currentColor"/>
</g>
<rect x="129.348" y="5.5" width="52" height="14" rx="2" fill="currentColor"/>
<g clipPath="url(#dsh-wordmark-badge-clip)">
<path d="M132.848 8.93205H134.08V16.137H132.848V8.93205ZM136.5 8.93205H137.732V16.137H136.5V8.93205ZM133.365 13.024V11.99H137.193V13.024H133.365Z" fill="var(--dsw-alias-label-primary-inverted)"/>
<path d="M140.397 14.432L140.672 13.453H143.202L143.532 14.432H140.397ZM140.287 16.137H139.055L141.277 8.93205H142.201L142.146 9.74605L140.947 13.915H140.969L140.287 16.137ZM145.039 16.137H143.741L143.07 13.948L143.081 13.937L141.871 9.74605L141.926 8.93205H142.817L145.039 16.137Z" fill="var(--dsw-alias-label-primary-inverted)"/>
<path d="M146.846 8.93205H149.068C149.852 8.93205 150.443 9.11538 150.839 9.48205C151.235 9.84138 151.433 10.3327 151.433 10.956C151.433 11.22 151.396 11.4657 151.323 11.693C151.249 11.9204 151.125 12.1257 150.949 12.309C150.773 12.4924 150.531 12.65 150.223 12.782C149.922 12.9067 149.541 13.0057 149.079 13.079V13.321H146.846V12.639L148.023 12.485C148.631 12.4044 149.09 12.298 149.398 12.166C149.706 12.034 149.915 11.8764 150.025 11.693C150.135 11.5024 150.19 11.2934 150.19 11.066C150.19 10.6994 150.083 10.417 149.871 10.219C149.658 10.021 149.324 9.92205 148.87 9.92205H146.846V8.93205ZM146.395 8.93205H147.627V16.137H146.395V8.93205ZM151.917 16.093V16.137H150.366L149.024 14.322C148.87 14.1094 148.73 13.9407 148.606 13.816C148.481 13.684 148.345 13.5887 148.199 13.53C148.052 13.464 147.872 13.42 147.66 13.398C147.447 13.3687 147.176 13.3504 146.846 13.343V13.145H149.079C149.233 13.211 149.368 13.2844 149.486 13.365C149.61 13.4457 149.735 13.5447 149.86 13.662C149.992 13.7794 150.138 13.937 150.3 14.135L151.917 16.093Z" fill="var(--dsw-alias-label-primary-inverted)"/>
<path d="M153.58 9.57005L153.591 8.93205H154.46L157.584 15.51V16.137H156.704L153.58 9.57005ZM158.024 16.137H156.968L156.88 8.93205H158.024V16.137ZM154.24 16.137H153.096V8.93205H154.152L154.24 16.137Z" fill="var(--dsw-alias-label-primary-inverted)"/>
<path d="M159.963 8.93205H161.206V16.137H159.963V8.93205ZM160.095 9.96605V8.93205H164.858V9.96605H160.095ZM160.095 16.137V15.103H164.902V16.137H160.095ZM160.095 13.013V11.99H164.374V13.013H160.095Z" fill="var(--dsw-alias-label-primary-inverted)"/>
<path d="M169.052 15.257C169.543 15.257 169.895 15.1654 170.108 14.982C170.328 14.7987 170.438 14.5457 170.438 14.223C170.438 14.047 170.405 13.8967 170.339 13.772C170.273 13.6474 170.152 13.5337 169.976 13.431C169.807 13.321 169.558 13.2147 169.228 13.112L168.491 12.881C167.846 12.6757 167.38 12.4044 167.094 12.067C166.808 11.7297 166.665 11.3007 166.665 10.78C166.665 10.428 166.76 10.1017 166.951 9.80105C167.142 9.50038 167.428 9.25838 167.809 9.07505C168.19 8.89172 168.663 8.80005 169.228 8.80005C169.631 8.80005 169.998 8.82938 170.328 8.88805C170.665 8.93938 171.039 9.01638 171.45 9.11905L171.274 10.175C170.834 10.0504 170.442 9.96238 170.097 9.91105C169.76 9.85238 169.463 9.82305 169.206 9.82305C168.737 9.82305 168.403 9.90738 168.205 10.076C168.007 10.2374 167.908 10.439 167.908 10.681C167.908 10.857 167.941 11.0147 168.007 11.154C168.073 11.286 168.19 11.407 168.359 11.517C168.535 11.627 168.784 11.7334 169.107 11.836L169.866 12.078C170.526 12.276 170.995 12.5327 171.274 12.848C171.553 13.156 171.692 13.585 171.692 14.135C171.692 14.5604 171.589 14.9344 171.384 15.257C171.179 15.5797 170.878 15.8327 170.482 16.016C170.093 16.1994 169.609 16.291 169.03 16.291C168.627 16.291 168.212 16.247 167.787 16.159C167.362 16.071 166.9 15.9427 166.401 15.774L166.665 14.718C167.156 14.894 167.6 15.0297 167.996 15.125C168.399 15.213 168.751 15.257 169.052 15.257Z" fill="var(--dsw-alias-label-primary-inverted)"/>
<path d="M175.809 15.257C176.3 15.257 176.652 15.1654 176.865 14.982C177.085 14.7987 177.195 14.5457 177.195 14.223C177.195 14.047 177.162 13.8967 177.096 13.772C177.03 13.6474 176.909 13.5337 176.733 13.431C176.564 13.321 176.315 13.2147 175.985 13.112L175.248 12.881C174.603 12.6757 174.137 12.4044 173.851 12.067C173.565 11.7297 173.422 11.3007 173.422 10.78C173.422 10.428 173.517 10.1017 173.708 9.80105C173.899 9.50038 174.185 9.25838 174.566 9.07505C174.947 8.89172 175.42 8.80005 175.985 8.80005C176.388 8.80005 176.755 8.82938 177.085 8.88805C177.422 8.93938 177.796 9.01638 178.207 9.11905L178.031 10.175C177.591 10.0504 177.199 9.96238 176.854 9.91105C176.517 9.85238 176.22 9.82305 175.963 9.82305C175.494 9.82305 175.16 9.90738 174.962 10.076C174.764 10.2374 174.665 10.439 174.665 10.681C174.665 10.857 174.698 11.0147 174.764 11.154C174.83 11.286 174.947 11.407 175.116 11.517C175.292 11.627 175.541 11.7334 175.864 11.836L176.623 12.078C177.283 12.276 177.752 12.5327 178.031 12.848C178.31 13.156 178.449 13.585 178.449 14.135C178.449 14.5604 178.346 14.9344 178.141 15.257C177.936 15.5797 177.635 15.8327 177.239 16.016C176.85 16.1994 176.366 16.291 175.787 16.291C175.384 16.291 174.969 16.247 174.544 16.159C174.119 16.071 173.657 15.9427 173.158 15.774L173.422 14.718C173.913 14.894 174.357 15.0297 174.753 15.125C175.156 15.213 175.508 15.257 175.809 15.257Z" fill="var(--dsw-alias-label-primary-inverted)"/>
</g>
<defs>
<clipPath id="dsh-wordmark-whale-clip">
<rect width="23.16" height="17.0435" fill="white" transform="translate(0.141602 3.52185)"/>
</clipPath>
<clipPath id="dsh-wordmark-badge-clip">
<rect width="46" height="14" fill="white" transform="translate(132.348 5.5)"/>
</clipPath>
</defs>
</svg>
)
}

View File

@@ -0,0 +1,38 @@
/* Visual spec mirrors deepsuite @deepseek/ui Tooltip.css (size m, no arrow),
except padding tightened 6/12 -> 4/8 and radius 10 -> 8 by product ruling:
tooltip-bg plate,
one text color across both themes (the plate stays dark in light and dark
mode). Behavior (fixed positioning off the anchor rect) is local — the
upstream Floating stack is intentionally not vendored. */
.bubble {
position: fixed;
z-index: 100;
padding: 4px 8px;
border-radius: 8px;
background: var(--dsw-alias-tooltip-bg);
color: var(--dsw-static-neutral-bluish-00);
font-size: 14px;
line-height: 22px;
white-space: nowrap;
pointer-events: none;
animation: tooltip-in 150ms var(--ds-ease-in-out);
}
.bubble[data-side='right'] {
transform: translateY(-50%);
}
.bubble[data-side='bottom'] {
transform: translateX(-50%);
}
@keyframes tooltip-in {
from { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.bubble {
animation: none;
}
}

View File

@@ -0,0 +1,77 @@
// Hover/focus label bubble (figma tooltip pill: dark plate, white text).
// TODO: interaction is a placeholder (no show delay, no flip on viewport
// collision, no arrow) — visuals and behavior get a proper pass later.
// The anchor is the child element itself (cloneElement, no wrapper node), so
// attaching a tooltip never changes the anchor's layout context. The bubble is
// position:fixed and coordinates come from the anchor's rect at show time, so
// it escapes ancestor overflow clipping (the sidebar rail clips its column)
// without a portal.
import { cloneElement, useEffect, useRef, useState } from 'react'
import type { FocusEventHandler, MouseEventHandler, ReactElement, Ref } from 'react'
import css from './Tooltip.module.css'
/** Bubble placement relative to the anchor. */
export type TooltipSide = 'right' | 'bottom'
/** Props Tooltip injects into its anchor child; the child's own handlers are chained ahead of the tooltip's. */
interface AnchorProps {
ref?: Ref<HTMLElement> | undefined
onMouseEnter?: MouseEventHandler | undefined
onMouseLeave?: MouseEventHandler | undefined
onFocus?: FocusEventHandler | undefined
onBlur?: FocusEventHandler | undefined
}
/**
* Attach a hover/focus tooltip to an anchor element.
* @param props.label - bubble text.
* @param props.side - placement relative to the anchor (default 'right').
* @param props.disabled - suppress the bubble while true; the anchor renders identically so toggling never remounts it (which would cut its CSS transitions).
* @param props.children - a single anchor element. Tooltip owns its ref (no current consumer passes one).
* @returns the cloned anchor plus a fixed-position bubble while hovered/focused.
*/
export function Tooltip({ label, side = 'right', disabled = false, children }: { label: string; side?: TooltipSide; disabled?: boolean; children: ReactElement<AnchorProps> }) {
const anchor = useRef<HTMLElement | null>(null)
const [pos, setPos] = useState<{ x: number; y: number } | null>(null)
// Hover and focus are independent triggers: the bubble hides only after
// BOTH clear (hovering away from a focused anchor must not drop it).
const triggers = useRef({ hover: false, focus: false })
// Disabling mid-hover (e.g. clicking a rail control expands the sidebar)
// must drop an already-visible bubble: no mouseleave fires.
useEffect(() => {
if (disabled) { triggers.current = { hover: false, focus: false }; setPos(null) }
}, [disabled])
const show = () => {
if (disabled) return
const el = anchor.current
/* v8 ignore next -- the ref is attached by event time: events fire on the cloned anchor. */
if (el === null) return
const r = el.getBoundingClientRect()
setPos(side === 'right'
? { x: r.right + 10, y: r.top + r.height / 2 }
: { x: r.left + r.width / 2, y: r.bottom + 8 })
}
const hide = () => {
if (!triggers.current.hover && !triggers.current.focus) setPos(null)
}
return (
<>
{cloneElement(children, {
ref: anchor,
onMouseEnter: (e) => { children.props.onMouseEnter?.(e); triggers.current.hover = true; show() },
onMouseLeave: (e) => { children.props.onMouseLeave?.(e); triggers.current.hover = false; hide() },
onFocus: (e) => { children.props.onFocus?.(e); triggers.current.focus = true; show() },
onBlur: (e) => { children.props.onBlur?.(e); triggers.current.focus = false; hide() },
})}
{pos !== null && (
<span className={css.bubble} data-side={side} style={{ left: pos.x, top: pos.y }} role="tooltip">
{label}
</span>
)}
</>
)
}

View File

@@ -165,6 +165,16 @@ export const IconChevronRightOutline14 = ({ size = 14, className }: IconProps) =
</svg>
)
/** ic_ds_triangle_right_fill_14 — tree expand arrow; points right, consumers rotate it 90° for the open state. */
export const IconTriangleRightFill14 = ({ size = 14, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M4.25 2.82782L4.25 11.1722C4.25 11.6622 4.84243 11.9076 5.18891 11.5611L9.36109 7.38891C9.57588 7.17412 9.57588 6.82588 9.36109 6.61109L5.18891 2.43891C4.84243 2.09243 4.25 2.33782 4.25 2.82782Z"
fill="currentColor"
/>
</svg>
)
/** ic_ds_chevron_up_outline_14 */
export const IconChevronUpOutline14 = ({ size = 14, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -552,11 +562,11 @@ export const IconProjectAddOutline16 = ({ size = 16, className }: IconProps) =>
</svg>
)
/** folder_open_16 (figma extract) */
/** folder_open_16 (figma extract): outline at full ink + 20%-opacity inner fill riding the same currentColor. */
export const IconFolderOpen16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none">
<path transform="translate(0.5996 1.645)" d="M4.69624 0C5.3113 0.000140941 5.88623 0.307626 6.22749 0.819336L6.69917 1.52734C6.78449 1.65523 6.92823 1.7324 7.08198 1.73242L11.6699 1.73242C13.0038 1.73257 14.0859 2.81452 14.0859 4.14844L14.0859 5.05566C14.7693 5.4559 15.1595 6.2791 14.9374 7.11621L13.8837 11.0869C13.6026 12.1454 12.644 12.8818 11.5488 12.8818L2.41596 12.8818C1.01395 12.8816 -0.0511855 11.7074 0.00190073 10.376L0.00190073 2.41602C0.00190073 1.08201 1.08391 0 2.41792 0L4.69624 0ZM3.27827 6.18457C2.80902 6.18474 2.39772 6.50054 2.27729 6.9541L1.41499 10.2012C1.2407 10.8579 1.73653 11.5017 2.41596 11.502L11.5488 11.502C12.0182 11.502 12.4293 11.1861 12.5498 10.7324L13.6035 6.7627C13.681 6.47081 13.4611 6.18474 13.1591 6.18457L3.27827 6.18457ZM2.41792 1.38086C1.8462 1.38086 1.38276 1.8443 1.38276 2.41602L1.38276 5.72266C1.83056 5.15603 2.52166 4.80383 3.27827 4.80371L12.705 4.80371L12.705 4.14844C12.705 3.57681 12.2415 3.11342 11.6699 3.11328L7.08198 3.11328C6.46674 3.11326 5.89205 2.80484 5.55073 2.29297L5.07905 1.58496C4.99378 1.45723 4.84981 1.381 4.69624 1.38086L2.41792 1.38086Z" fill="currentColor"/>
<path transform="translate(1.979 3.026)" d="M11.7793 4.80371C12.0811 4.80388 12.3008 5.09009 12.2236 5.38184L11.1699 9.35156C11.0494 9.80525 10.6383 10.1211 10.1689 10.1211L1.03612 10.1211C0.356864 10.1206 -0.139141 9.47695 0.0351403 8.82031L0.897445 5.57324C1.01797 5.12 1.42946 4.80406 1.89842 4.80371L11.7793 4.80371ZM3.31639 0C3.46985 0.000107244 3.61388 0.0765707 3.6992 0.204102L4.17088 0.912109C4.51213 1.42391 5.08701 1.73228 5.70213 1.73242L10.29 1.73242C10.8616 1.73251 11.325 2.19605 11.3252 2.76758L11.3252 3.42285L1.89842 3.42285C1.14203 3.42309 0.450638 3.77535 0.00291371 4.3418L0.00291371 1.03516C0.00307753 0.463694 0.466614 0.000188756 1.03807 0L3.31639 0Z" fill="currentColor"/>
<path d="M5.19629 1.57104C5.81144 1.5711 6.38623 1.8786 6.72754 2.39038L7.19922 3.09839C7.28454 3.22635 7.42824 3.30344 7.58203 3.30347H12.1699C13.5039 3.30348 14.5859 4.38548 14.5859 5.71948V6.62671C15.2694 7.02689 15.6605 7.85012 15.4385 8.68726L14.3848 12.658C14.1037 13.7164 13.1449 14.4527 12.0498 14.4529H2.91699C1.51651 14.4529 0.451662 13.2814 0.501954 11.9519V3.98706C0.501954 2.65305 1.58396 1.57104 2.91797 1.57104H5.19629ZM3.7793 7.75562C3.30994 7.75562 2.89883 8.07153 2.77832 8.52515L1.91602 11.7722C1.74167 12.4291 2.23734 13.073 2.91699 13.073H12.0498C12.5191 13.0728 12.9304 12.757 13.0508 12.3035L14.1045 8.33374C14.1819 8.04202 13.9619 7.756 13.6602 7.75562H3.7793ZM2.91797 2.9519C2.34625 2.9519 1.88281 3.41534 1.88281 3.98706V7.2937C2.33068 6.7269 3.02249 6.37476 3.7793 6.37476H13.2051V5.71948C13.2051 5.14777 12.7416 4.68434 12.1699 4.68433H7.58203C6.96675 4.6843 6.39209 4.37595 6.05078 3.86401L5.5791 3.15601C5.49379 3.02821 5.34995 2.95196 5.19629 2.9519H2.91797Z" fill="currentColor"/>
<path opacity="0.2" d="M13.6602 7.75525C13.9618 7.7556 14.1815 8.04179 14.1045 8.33337L13.0508 12.3031C12.9304 12.7567 12.5191 13.0725 12.0498 13.0726H2.91701C2.23744 13.0725 1.7417 12.4287 1.91603 11.7719L2.77834 8.52478C2.89898 8.07146 3.31018 7.75532 3.77931 7.75525H13.6602ZM5.1963 2.95154C5.34985 2.95159 5.49377 3.02803 5.57912 3.15564L6.0508 3.86365C6.39205 4.37553 6.96685 4.68385 7.58205 4.68396H12.1699C12.7416 4.68396 13.2049 5.14754 13.2051 5.71912V6.37439H3.77931C3.02267 6.37444 2.33067 6.72671 1.88283 7.29333V3.98669C1.88299 3.4152 2.34649 2.95168 2.91798 2.95154H5.1963Z" fill="currentColor"/>
</svg>
)

View File

@@ -14,6 +14,10 @@ export { Menu } from './Menu.tsx'
export type { MenuItem } from './Menu.tsx'
export { ConnectionBanner } from './ConnectionBanner.tsx'
export { FishLogo } from './FishLogo.tsx'
export { BrandWordmark } from './BrandWordmark.tsx'
export { Tooltip } from './Tooltip.tsx'
export type { TooltipSide } from './Tooltip.tsx'
export { JsonBlock } from './markdown/JsonBlock.tsx'
export { MarkdownText } from './markdown/MarkdownText.tsx'
export { MessageText } from './markdown/MessageText.tsx'
export * from './icons/index.tsx'

View File

@@ -0,0 +1,123 @@
.markdown {
display: flex;
min-width: 0;
flex-direction: column;
gap: 12px;
overflow-wrap: anywhere;
font: var(--dsw-font-markdown-base);
}
.markdown :where(h1, h2, h3, h4, h5, h6, p, ul, ol, blockquote, pre, hr) {
margin: 0;
}
.markdown h1 {
font: var(--dsw-font-markdown-h1);
}
.markdown h2 {
font: var(--dsw-font-markdown-h2);
}
.markdown h3 {
font: var(--dsw-font-markdown-h3);
}
.markdown :where(h4, h5, h6) {
font: var(--dsw-font-markdown-h4);
}
.markdown :where(strong, th) {
font-weight: var(--dsw-font-markdown-base-strong-font-weight);
}
.markdown :where(ul, ol) {
padding-inline-start: 24px;
}
.markdown li + li {
margin-block-start: 4px;
}
.markdown li > :where(ul, ol) {
margin-block-start: 4px;
}
.markdown blockquote {
padding-inline-start: 12px;
border-inline-start: 3px solid var(--dsw-alias-markdown-citation);
color: var(--dsw-alias-label-secondary);
}
.markdown a {
color: var(--dsw-alias-state-business-primary);
text-decoration: underline;
text-underline-offset: 2px;
}
.markdown :not(pre) > code {
padding: 2px 4px;
border-radius: 4px;
background: var(--dsw-alias-markdown-inline-code);
font: var(--dsw-font-markdown-code);
}
.markdown pre {
max-width: 100%;
overflow-x: auto;
overscroll-behavior-x: contain;
padding: 12px 16px;
border-radius: 8px;
background: var(--dsw-alias-markdown-code-block);
font: var(--dsw-font-markdown-code-block);
}
.markdown pre code {
padding: 0;
background: transparent;
font: inherit;
overflow-wrap: normal;
word-break: normal;
white-space: pre;
}
.markdown hr {
width: 100%;
border: 0;
border-block-start: 1px solid var(--dsw-alias-markdown-citation);
}
.markdown input[type='checkbox'] {
margin: 0 8px 0 0;
accent-color: var(--dsw-alias-state-business-primary);
}
.tableScroll {
max-width: 100%;
overflow-x: auto;
overscroll-behavior-x: contain;
}
.tableScroll table {
width: max-content;
min-width: 100%;
border-collapse: collapse;
font: var(--dsw-font-markdown-table);
}
.tableScroll :where(th, td) {
padding: 6px 12px;
border: 1px solid var(--dsw-alias-markdown-citation);
text-align: start;
white-space: nowrap;
}
.tableScroll th {
background: var(--dsw-alias-markdown-code-block-banner);
font: var(--dsw-font-markdown-table-head);
}
.imageAlt {
color: var(--dsw-alias-label-tertiary);
font-style: italic;
}

View File

@@ -0,0 +1,64 @@
import ReactMarkdown from 'react-markdown'
import type { Components, UrlTransform } from 'react-markdown'
import remarkGfm from 'remark-gfm'
import css from './MarkdownText.module.css'
const remarkPlugins = [remarkGfm]
function sanitizeUrl(url: string): string {
try {
switch (new URL(url).protocol) {
case 'http:':
case 'https:':
case 'mailto:':
return url
default:
return ''
}
} catch {
return ''
}
}
const safeUrl: UrlTransform = url => sanitizeUrl(url)
const components: Components = {
a: ({ href = '', children }) => {
const safeHref = sanitizeUrl(href)
if (safeHref === '') return <>{children}</>
const external = ['http:', 'https:'].includes(new URL(safeHref).protocol)
return (
<a
href={safeHref}
{...(external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
>
{children}
</a>
)
},
img: ({ alt = '' }) => <span className={css.imageAlt}>{alt}</span>,
table: ({ children }) => (
<div className={css.tableScroll}>
<table>{children}</table>
</div>
),
}
/**
* Render untrusted assistant-authored Markdown as semantic React elements.
* @param props - Markdown source text preserved by the session projection.
* @returns A GFM document with raw HTML, relative links, unsafe protocols, and remote images disabled.
*/
export function MarkdownText({ text }: { text: string }) {
return (
<div className={css.markdown}>
<ReactMarkdown
remarkPlugins={remarkPlugins}
components={components}
urlTransform={safeUrl}
>
{text}
</ReactMarkdown>
</div>
)
}

View File

@@ -1,4 +1,4 @@
// MessageText: the single text-block rendering point (Markdown support later = swap this component's internals, zero card-structure changes).
// MessageText is the literal-text primitive for user and steering content; assistant output uses MarkdownText.
import css from './MessageText.module.css'

View File

@@ -14,8 +14,8 @@ const icons = Object.fromEntries(
const iconNames = Object.keys(icons)
describe('ic_ds_ icon set', () => {
it('exports the full P-I set (43 deepsuite + 6 figma extracts)', () => {
expect(iconNames.length).toBe(49)
it('exports the full P-I set (43 deepsuite + 7 figma extracts)', () => {
expect(iconNames.length).toBe(50)
})
it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', name => {

View File

@@ -1,14 +1,93 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
import { JsonBlock, MarkdownText, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
afterEach(cleanup)
describe('MessageText', () => {
it('renders the text verbatim', () => {
const { container } = render(<MessageText text={'line1\nline2'} />)
expect(container.textContent).toBe('line1\nline2')
const { container } = render(<MessageText text={'# line1\n`line2`'} />)
expect(container.textContent).toBe('# line1\n`line2`')
expect(container.querySelector('h1')).toBeNull()
})
})
describe('MarkdownText', () => {
it('renders CommonMark and GFM elements as semantic DOM', () => {
const markdown = [
'# Heading',
'',
'Paragraph with **strong**, *emphasis*, ~~deleted~~, `inline`, and [safe](https://example.com). ',
'Hard break.',
'',
'> Quote',
'',
'- parent',
' - child',
'',
'1. first',
'2. second',
'',
'- [x] done',
'- [ ] pending',
'',
'| Name | Value |',
'| --- | --- |',
'| alpha | beta |',
'',
'---',
'',
'```ts',
'const answer = 42',
'```',
'',
'<https://deepseek.com>',
].join('\n')
const { container } = render(<MarkdownText text={markdown} />)
expect(screen.getByRole('heading', { level: 1, name: 'Heading' })).toBeTruthy()
expect(container.querySelector('strong')?.textContent).toBe('strong')
expect(container.querySelector('em')?.textContent).toBe('emphasis')
expect(container.querySelector('del')?.textContent).toBe('deleted')
expect(container.querySelector('blockquote')?.textContent?.trim()).toBe('Quote')
expect(container.querySelectorAll('ul')).toHaveLength(3)
expect(container.querySelector('ol')).not.toBeNull()
expect(container.querySelectorAll('input[type="checkbox"]')).toHaveLength(2)
expect(container.querySelector('table')?.textContent).toContain('alphabeta')
expect(container.querySelector('hr')).not.toBeNull()
expect(container.querySelector('pre code')?.textContent).toContain('const answer = 42')
expect(container.querySelector('br')).not.toBeNull()
expect(screen.getByRole('link', { name: 'safe' }).getAttribute('target')).toBe('_blank')
expect(screen.getByRole('link', { name: 'https://deepseek.com' })).toBeTruthy()
})
it('neutralizes raw HTML, unsafe or relative links, and remote images', () => {
const markdown = [
'<script>globalThis.compromised = true</script>',
'<img src="x" onerror="globalThis.compromised = true">',
'[script](javascript:alert(1)) [relative](/settings)',
'[mail](mailto:dev@example.com) [web](http://example.com) [upper](HTTPS://example.com)',
'![remote diagram](https://example.com/private.png)',
].join('\n\n')
const { container } = render(<MarkdownText text={markdown} />)
expect(container.querySelector('script')).toBeNull()
expect(container.querySelector('img')).toBeNull()
const neutralized = [...container.querySelectorAll('p')]
.find(paragraph => paragraph.textContent === 'script relative')
expect(neutralized?.querySelector('a')).toBeNull()
expect(screen.getByRole('link', { name: 'mail' }).getAttribute('target')).toBeNull()
expect(screen.getByRole('link', { name: 'web' }).getAttribute('rel')).toBe('noopener noreferrer')
expect(screen.getByRole('link', { name: 'upper' }).getAttribute('target')).toBe('_blank')
expect(screen.getByText('remote diagram')).toBeTruthy()
})
it('keeps incomplete streaming Markdown renderable', () => {
const { container } = render(<MarkdownText text={'## Streaming\n\n- first\n- **unfinished'} />)
expect(screen.getByRole('heading', { level: 2, name: 'Streaming' })).toBeTruthy()
expect(container.querySelectorAll('li')).toHaveLength(2)
expect(screen.getByText('**unfinished')).toBeTruthy()
})
})

View File

@@ -0,0 +1,123 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
afterEach(cleanup)
describe('Tooltip', () => {
it('shows the bubble to the right on hover and hides it on leave', () => {
render(
<Tooltip label="Open sidebar">
<button type="button">anchor</button>
</Tooltip>,
)
const anchor = screen.getByText('anchor')
fireEvent.mouseEnter(anchor)
const bubble = screen.getByRole('tooltip')
expect(bubble.textContent).toBe('Open sidebar')
expect(bubble.getAttribute('data-side')).toBe('right')
// jsdom rects are all-zero: right placement lands at the +10 gutter.
expect(bubble.style.left).toBe('10px')
expect(bubble.style.top).toBe('0px')
fireEvent.mouseLeave(anchor)
expect(screen.queryByRole('tooltip')).toBeNull()
})
it('supports bottom placement and the focus/blur channel', () => {
render(
<Tooltip label="Below" side="bottom">
<button type="button">anchor</button>
</Tooltip>,
)
const anchor = screen.getByText('anchor')
fireEvent.focus(anchor)
const bubble = screen.getByRole('tooltip')
expect(bubble.getAttribute('data-side')).toBe('bottom')
expect(bubble.style.left).toBe('0px')
expect(bubble.style.top).toBe('8px')
fireEvent.blur(anchor)
expect(screen.queryByRole('tooltip')).toBeNull()
})
it('chains the anchor\'s own handlers ahead of the tooltip\'s', () => {
const onMouseEnter = vi.fn()
const onMouseLeave = vi.fn()
const onFocus = vi.fn()
const onBlur = vi.fn()
render(
<Tooltip label="Chained">
<button type="button" onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} onFocus={onFocus} onBlur={onBlur}>anchor</button>
</Tooltip>,
)
const anchor = screen.getByText('anchor')
fireEvent.mouseEnter(anchor)
fireEvent.mouseLeave(anchor)
fireEvent.focus(anchor)
fireEvent.blur(anchor)
expect(onMouseEnter).toHaveBeenCalledOnce()
expect(onMouseLeave).toHaveBeenCalledOnce()
expect(onFocus).toHaveBeenCalledOnce()
expect(onBlur).toHaveBeenCalledOnce()
})
it('suppresses the bubble while disabled without remounting the anchor', () => {
const { rerender } = render(
<Tooltip label="Rail" disabled>
<button type="button">anchor</button>
</Tooltip>,
)
const anchor = screen.getByText('anchor')
fireEvent.mouseEnter(anchor)
expect(screen.queryByRole('tooltip')).toBeNull()
rerender(
<Tooltip label="Rail">
<button type="button">anchor</button>
</Tooltip>,
)
// Same DOM node: toggling disabled never remounted the anchor.
expect(screen.getByText('anchor')).toBe(anchor)
fireEvent.mouseEnter(anchor)
expect(screen.getByRole('tooltip')).toBeTruthy()
})
it('keeps the bubble while either hover or focus is still active', () => {
render(
<Tooltip label="Sticky">
<button type="button">anchor</button>
</Tooltip>,
)
const anchor = screen.getByText('anchor')
// Focused AND hovered: leaving with the mouse must not drop the bubble.
fireEvent.focus(anchor)
fireEvent.mouseEnter(anchor)
fireEvent.mouseLeave(anchor)
expect(screen.getByRole('tooltip')).toBeTruthy()
fireEvent.blur(anchor)
expect(screen.queryByRole('tooltip')).toBeNull()
// Symmetric: blurring while still hovered keeps it, mouseleave ends it.
fireEvent.mouseEnter(anchor)
fireEvent.focus(anchor)
fireEvent.blur(anchor)
expect(screen.getByRole('tooltip')).toBeTruthy()
fireEvent.mouseLeave(anchor)
expect(screen.queryByRole('tooltip')).toBeNull()
})
it('drops an already-visible bubble when disabled flips mid-hover', () => {
const { rerender } = render(
<Tooltip label="Rail">
<button type="button">anchor</button>
</Tooltip>,
)
fireEvent.mouseEnter(screen.getByText('anchor'))
expect(screen.getByRole('tooltip')).toBeTruthy()
// e.g. clicking a rail control expands the sidebar: no mouseleave fires.
rerender(
<Tooltip label="Rail" disabled>
<button type="button">anchor</button>
</Tooltip>,
)
expect(screen.queryByRole('tooltip')).toBeNull()
})
})

View File

@@ -0,0 +1,20 @@
# @deepseek-ai/dsh-client-ui-question
Web `ask_user_question` feature plugin. Its host half mounts `dsh-tool-ask-user` only when the Web feature is selected; its browser half registers the `question` entry in the conversation-owned `conversation.composer` keyed slot.
The component renders one question at a time with progress navigation, single- and multi-select choices, recommendation badges derived from label suffixes, and custom answers. Single-select choices advance immediately, and Enter submits once every question is answered or skipped; Enter during IME composition confirms the input candidate without advancing. It submits one structured answer batch for the whole request: “Skip this question” retains other drafts and emits the existing blank `{ selected: [] }` shape for that item, while close rejects the whole wait as `ASK_CANCELLED`.
Selection state is local to a component keyed by the request rpcId. A replay with the same id preserves a still-mounted draft, while `question/resolved` from the host removes the composer. The host remains authoritative: successful HTTP delivery does not remove pending state locally.
## Model Experience
Indirectly, through `dsh-tool-ask-user`; that package owns the model-visible tool schema and structured result.
#### KV Cache effect
No direct invalidation; `dsh-tool-ask-user` owns the model-visible tool call and result.
## Known Limitations and Deferred Work
- **Unsubmitted drafts are not durable** — reconnect resync or a full page reload restores the host-owned pending request with the same rpcId, but a composer unmount resets local option and custom-text drafts.
- **One request owns the composer at a time** — later pending requests remain in the session snapshot and become visible after the earlier request resolves.

View File

@@ -0,0 +1,67 @@
{
"name": "@deepseek-ai/dsh-client-ui-question",
"description": "Web ask_user_question feature: host tool mount plus composer-takeover question UI",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-ui-conversation"
],
"platform": "web"
},
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
},
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-tool-ask-user": "workspace:^",
"clsx": "^2.0.0",
"react": "^18.2.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^",
"@types/react": "~18.3.1",
"cordis": "^4.0.0-rc.7"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
]
}

View File

@@ -0,0 +1,348 @@
.frame {
display: flex;
justify-content: center;
padding: 6px 24px 10px;
}
.card {
display: flex;
flex-direction: column;
width: 100%;
max-width: 720px;
/* Composer seat sits in a fixed-height conversation column (overflow
hidden): cap the card against the viewport and scroll the option list
so header and footer actions stay reachable on long batches. */
max-height: min(60vh, 520px);
padding: 14px 16px 12px;
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
border-radius: 18px;
background: var(--dsw-specific-input-major);
box-shadow: var(--dsw-shadow-lv1-blur);
color: var(--dsw-alias-label-primary);
}
.card,
.card * {
box-sizing: border-box;
}
.header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
flex-shrink: 0;
margin-bottom: 8px;
}
.headingBlock {
min-width: 0;
padding: 1px 2px;
}
.eyebrow {
margin-bottom: 2px;
color: var(--dsw-alias-label-tertiary);
font-size: 11px;
line-height: 16px;
}
.title {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 6px;
margin: 0;
font-size: 16px;
line-height: 22px;
font-weight: 600;
}
.multiSelectHint {
color: var(--dsw-alias-label-tertiary);
font-size: 14px;
line-height: 20px;
font-weight: 400;
white-space: nowrap;
}
.detail {
margin: 2px 0 0;
color: var(--dsw-alias-label-tertiary);
font-size: 13px;
line-height: 20px;
font-weight: 400;
}
.headerActions,
.footerActions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.progress {
padding: 0 6px;
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
line-height: 24px;
white-space: nowrap;
}
.iconButton {
display: grid;
place-items: center;
width: 24px;
height: 24px;
padding: 0;
border: none;
border-radius: 999px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
.iconButton:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
.iconButton:disabled {
color: var(--dsw-alias-label-dimmed);
cursor: default;
}
.options {
display: flex;
flex-direction: column;
gap: 4px;
/* The scrollable region of the capped card (ChatView list pattern). */
min-height: 0;
overflow-y: auto;
}
.option {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
min-height: 42px;
padding: 5px 8px;
border: 1px solid transparent;
border-radius: 12px;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
transition: background-color 120ms ease, border-color 120ms ease;
}
.option:hover:not(:disabled),
.optionSelected {
background: var(--dsw-alias-interactive-bg-hover);
}
.optionSelected {
border-color: var(--dsw-alias-border-l2);
}
.option:disabled,
.customTrigger:disabled {
cursor: default;
}
.number {
display: grid;
place-items: center;
flex: 0 0 28px;
width: 28px;
height: 28px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 999px;
background: var(--dsw-alias-bg-module-platform);
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
line-height: 18px;
}
.optionCopy {
min-width: 0;
flex: 1;
}
.optionLine {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 2px 6px;
}
.optionLabel {
font-size: 14px;
line-height: 20px;
font-weight: 600;
}
.badge {
padding: 0 6px;
border-radius: 999px;
background: var(--dsw-alias-bg-module-platform);
color: var(--dsw-alias-label-secondary);
font-size: 11px;
line-height: 18px;
}
.description {
color: var(--dsw-alias-label-tertiary);
font-size: 13px;
line-height: 20px;
font-weight: 400;
}
.choiceIcon {
display: grid;
place-items: center;
width: 20px;
color: var(--dsw-alias-label-tertiary);
}
.custom {
border: 1px solid transparent;
border-radius: 12px;
}
.customOpen {
border-color: var(--dsw-alias-border-l2);
background: var(--dsw-alias-bg-module-platform);
}
.customOptionless {
border: none;
background: transparent;
}
.customTrigger {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
min-height: 42px;
padding: 5px 8px;
border: none;
background: transparent;
color: var(--dsw-alias-label-tertiary);
font-size: 14px;
line-height: 20px;
text-align: left;
cursor: pointer;
}
.customTrigger:hover:not(:disabled) {
color: var(--dsw-alias-label-primary);
}
.customInput {
display: block;
width: calc(100% - 20px);
min-height: 54px;
max-height: 140px;
margin: 0 10px 10px;
padding: 7px 10px;
resize: vertical;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 10px;
outline: none;
background: var(--dsw-specific-input-major);
color: var(--dsw-alias-label-primary);
caret-color: var(--dsw-alias-state-business-primary);
font: inherit;
font-size: 13px;
line-height: 20px;
}
.customInput:focus {
border-color: var(--dsw-alias-state-business-primary);
}
.customInput::placeholder {
color: var(--dsw-alias-label-caption);
}
.customOptionless .customInput {
width: 100%;
min-height: 58px;
margin: 0;
background: var(--dsw-alias-bg-module-platform);
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-shrink: 0;
margin-top: 8px;
padding: 0 2px;
}
.feedback {
min-height: 16px;
color: var(--dsw-alias-state-error-primary);
font-size: 11px;
line-height: 16px;
}
@media (max-width: 720px) {
.frame {
padding: 6px 10px 10px;
}
.card {
padding: 12px 10px 10px;
border-radius: 16px;
}
.header {
display: block;
}
.headerActions {
justify-content: flex-end;
margin-top: 8px;
}
.headingBlock {
padding: 0 2px;
}
.title {
font-size: 15px;
line-height: 21px;
}
.option,
.customTrigger {
align-items: flex-start;
gap: 8px;
padding: 6px;
}
.choiceIcon {
margin-top: 3px;
}
.footer {
align-items: flex-end;
}
.footerActions {
flex-shrink: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.option {
transition: none;
}
}

View File

@@ -0,0 +1,297 @@
import { useMemo, useState, type KeyboardEvent } from 'react'
import clsx from 'clsx'
import {
Button, IconCheckOutline16, IconChevronLeftOutline14, IconChevronRightOutline14,
IconCloseOutline16, IconEditOutline16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { PendingQuestion, type QuestionAnswer, type QuestionComposerProps } from './contract/slots.ts'
import css from './QuestionComposer.module.css'
interface DraftAnswer {
selected: string[]
custom: string
customOpen: boolean
skipped: boolean
}
/**
* Split the conventional recommendation suffix without changing the answer value.
* @param label - Original option label returned if selected.
* @returns Display label plus recommendation state.
*/
export function parseRecommendedLabel(label: string): { label: string; recommended: boolean } {
const suffix = /\s*(?:\((?:recommended|推荐)\)|(?:recommended|推荐))\s*$/i
return suffix.test(label)
? { label: label.replace(suffix, ''), recommended: true }
: { label, recommended: false }
}
/**
* Remove a conventional multi-select suffix so the hint can be styled separately.
* @param title - Question title supplied by the interaction request.
* @returns Question title without a trailing multi-select marker.
*/
export function parseQuestionTitle(title: string): string {
return title.replace(/\s*[(]可多选[)]\s*$/, '')
}
/** Return whether a textarea key event belongs to an active IME composition. */
function isComposing(event: KeyboardEvent<HTMLTextAreaElement>): boolean {
return event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229
}
/**
* Composer takeover boundary; the carrier key keys local drafts, so a
* same-request replay (same key, new carrier object) preserves them.
* @param props - the selector-matched pending question carrier plus the framework standard kit.
* @returns The question flow for this request.
*/
export function QuestionComposer(props: QuestionComposerProps) {
// Domain-face mint rides the carrier's stable identity (never minted in a
// select/render dispatch — per-dispatch minting would churn memo identity).
const question = useMemo(() => new PendingQuestion(props.matched), [props.matched])
return <QuestionFlow key={question.key} pending={question} />
}
function QuestionFlow({ pending }: { pending: PendingQuestion }) {
const questions = pending.questions
const [index, setIndex] = useState(0)
const [drafts, setDrafts] = useState<DraftAnswer[]>(() => questions.map(question => ({
selected: [], custom: '', customOpen: (question.options?.length ?? 0) === 0, skipped: false,
})))
const [busy, setBusy] = useState<'answer' | 'cancel' | null>(null)
const [error, setError] = useState<string | null>(null)
const question = questions[index]!
const draft = drafts[index]!
const hasOptions = (question.options?.length ?? 0) > 0
const cancelFlow = (): void => {
setBusy('cancel')
setError(null)
void pending.cancel().catch((cause: unknown) => {
setBusy(null)
setError(cause instanceof Error ? cause.message : String(cause))
})
}
const updateDraft = (update: (current: DraftAnswer) => DraftAnswer): void => {
setDrafts(current => current.map((item, itemIndex) => itemIndex === index ? update(item) : item))
setError(null)
}
const choose = (label: string): void => {
updateDraft((current) => {
const selected = question.multiSelect === true
? current.selected.includes(label)
? current.selected.filter(item => item !== label)
: [...current.selected, label]
: [label]
return { selected, custom: '', customOpen: false, skipped: false }
})
if (question.multiSelect !== true && index < questions.length - 1) {
setIndex(current => current + 1)
}
}
const openCustom = (): void => {
updateDraft(current => ({ ...current, selected: [], customOpen: true, skipped: false }))
}
const answered = (item: DraftAnswer): boolean =>
item.selected.length > 0 || item.custom.trim() !== ''
const completed = (item: DraftAnswer): boolean => answered(item) || item.skipped
const submitDrafts = (values: DraftAnswer[]): void => {
const missing = values.findIndex(item => !completed(item))
if (missing >= 0) {
setIndex(missing)
setError('请先完成这道问题。')
return
}
const answer: QuestionAnswer = {
answers: questions.map((item, itemIndex) => {
const value = values[itemIndex] as DraftAnswer
if (value.skipped) return { id: item.id, selected: [] }
const custom = value.custom.trim()
return {
id: item.id,
selected: custom === '' ? value.selected : [],
...(custom === '' ? {} : { custom }),
}
}),
}
setBusy('answer')
setError(null)
void pending.answer(answer).catch((cause: unknown) => {
setBusy(null)
setError(cause instanceof Error ? cause.message : String(cause))
})
}
const continueFlow = (): void => {
if (!answered(draft)) {
setError('请选择一个选项或填写自定义答案。')
return
}
if (index < questions.length - 1) {
setIndex(current => current + 1)
setError(null)
return
}
submitDrafts(drafts)
}
const skipQuestion = (): void => {
const nextDrafts = drafts.map((item, itemIndex) => itemIndex === index
? {
selected: [], custom: '',
customOpen: (question.options?.length ?? 0) === 0,
skipped: true,
}
: item)
setDrafts(nextDrafts)
setError(null)
if (index < questions.length - 1) {
setIndex(current => current + 1)
return
}
submitDrafts(nextDrafts)
}
return (
<div className={css.frame} data-question-key={pending.key}>
<section className={css.card} aria-labelledby={`question-${pending.key}-${String(index)}`}>
<header className={css.header}>
<div className={css.headingBlock}>
{question.header !== undefined && <div className={css.eyebrow}>{question.header}</div>}
<h2 className={css.title} id={`question-${pending.key}-${String(index)}`}>
<span>{question.multiSelect === true
? parseQuestionTitle(question.question)
: question.question}</span>
{question.multiSelect === true && <span className={css.multiSelectHint}></span>}
</h2>
{question.detail !== undefined && <p className={css.detail}>{question.detail}</p>}
</div>
<div className={css.headerActions}>
<span className={css.progress}>{index + 1} / {questions.length}</span>
<button
type="button" className={css.iconButton} aria-label="上一题"
disabled={index === 0 || busy !== null}
onClick={() => { setIndex(index - 1); setError(null) }}
>
<IconChevronLeftOutline14 />
</button>
<button
type="button" className={css.iconButton} aria-label="下一题"
disabled={index === questions.length - 1 || busy !== null}
onClick={() => { setIndex(index + 1); setError(null) }}
>
<IconChevronRightOutline14 />
</button>
<button
type="button" className={css.iconButton} aria-label="放弃整组问题"
title="放弃整组问题"
disabled={busy !== null} onClick={cancelFlow}
>
<IconCloseOutline16 />
</button>
</div>
</header>
<div className={css.options} role={question.multiSelect === true ? 'group' : 'radiogroup'}>
{(question.options ?? []).map((option, optionIndex) => {
const selected = draft.selected.includes(option.label)
const display = parseRecommendedLabel(option.label)
return (
<button
type="button" key={`${option.label}-${String(optionIndex)}`}
className={clsx(css.option, selected && css.optionSelected)}
role={question.multiSelect === true ? 'checkbox' : 'radio'}
aria-checked={selected}
aria-label={display.label}
disabled={busy !== null}
onClick={() => { choose(option.label) }}
onKeyDown={(event) => {
if (event.key !== 'Enter' || !drafts.every(completed)) return
event.preventDefault()
submitDrafts(drafts)
}}
>
<span className={css.number}>{optionIndex + 1}</span>
<span className={css.optionCopy}>
<span className={css.optionLine}>
<span className={css.optionLabel}>{display.label}</span>
{display.recommended && <span className={css.badge}></span>}
{option.description !== undefined && (
<span className={css.description}>{option.description}</span>
)}
</span>
</span>
<span className={css.choiceIcon}>
{selected ? <IconCheckOutline16 /> : <IconChevronRightOutline14 />}
</span>
</button>
)
})}
<div className={clsx(
css.custom,
draft.customOpen && css.customOpen,
!hasOptions && css.customOptionless,
)}>
{hasOptions && (
<button
type="button" className={css.customTrigger}
disabled={busy !== null} onClick={openCustom}
aria-expanded={draft.customOpen}
>
<span className={css.number}><IconEditOutline16 /></span>
<span></span>
</button>
)}
{draft.customOpen && (
<textarea
autoFocus
className={css.customInput}
value={draft.custom}
disabled={busy !== null}
rows={2}
placeholder="输入你的答案"
onChange={(event) => {
const value = event.target.value
updateDraft(current => ({
...current, selected: [], custom: value, customOpen: true, skipped: false,
}))
}}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey && !isComposing(event)) {
event.preventDefault()
continueFlow()
}
}}
/>
)}
</div>
</div>
<footer className={css.footer}>
<div className={css.feedback} role="status">{error}</div>
<div className={css.footerActions}>
<Button variant="ghost" size="sm" disabled={busy !== null} onClick={skipQuestion}>
</Button>
<Button
variant="primary" size="sm"
disabled={busy !== null || !answered(draft)} onClick={continueFlow}
>
{busy === 'answer'
? '正在提交…'
: index === questions.length - 1 ? '提交' : '下一题'}
</Button>
</div>
</footer>
</section>
</div>
)
}

View File

@@ -0,0 +1,77 @@
/**
* Question-composer slot contract: the registrant-side props composition for
* the conversation-owned `conversation.composer` slot, plus the question
* domain face over the runtime's carrier object. The carrier (PendingWait)
* owns envelope transport only; the question protocol — answer value shape,
* cancelled error encoding, receipt checks — lives HERE, with the package
* that consumes it.
*/
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
// Also pulls ui-conversation's SlotMap merge (the 'conversation.composer'
// entry) into every program that sees this contract, so PropsRuntime resolves.
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
import type { QuestionResponsePayload } from '@deepseek-ai/dsh-client-connection/client'
/** The pending question carrier the owner dispatches into the composer slot. */
export type QuestionWait = PendingWait<'question'>
/** One structured answer batch covering every question of the request. */
export type QuestionAnswer = QuestionResponsePayload['answer']
/**
* Question domain face over the carrier: render identity and questions
* transparently forwarded; answer/cancel own the wire encoding (the ok value
* shape and the cancelled error) and turn a rejected carrier receipt into a
* thrown error. Components mint one per carrier via useMemo (never inside a
* select — a per-dispatch mint would churn identity and break memoization).
*/
export class PendingQuestion {
/**
* @param wait - the runtime carrier for one pending question request.
*/
constructor(private readonly wait: QuestionWait) {}
/** Opaque render identity (React key / draft remount axis), forwarded from the carrier. */
get key(): string {
return this.wait.key
}
/** The request's question list, forwarded from the carrier payload. */
get questions(): QuestionWait['payload']['questions'] {
return this.wait.payload.questions
}
/**
* Deliver the whole answer batch; a rejected carrier receipt throws.
* @param answer - complete structured answer batch.
*/
async answer(answer: QuestionAnswer): Promise<void> {
const receipt = await this.wait.respond({
ok: true, value: { sessionId: this.wait.sessionId, answer },
})
if (!receipt.accepted) {
throw new Error(`question response rejected: ${receipt.reason}`)
}
}
/** Reject the whole wait (the host resolves the tool call as cancelled); a rejected receipt throws. */
async cancel(): Promise<void> {
const receipt = await this.wait.respond({
ok: false,
error: { code: 'cancelled', message: 'the user closed this question request', details: {} },
})
if (!receipt.accepted) {
throw new Error(`question cancellation rejected: ${receipt.reason}`)
}
}
}
/**
* Full component props: the framework runtime share (chain currency +
* session/global standard kit) plus the chain `matched` share — the entry's
* selector result, already narrowed to the question carrier. No injected
* share: the carrier plus the domain face above carry the whole behavior
* surface.
*/
export type QuestionComposerProps = PropsRuntime<'conversation.composer'> & { matched: QuestionWait }

View File

@@ -0,0 +1,41 @@
/**
* Web question plugin, browser half: QuestionComposer registered as a
* selector-routed entry of the conversation-declared composer chain. Pure
* consumer — the selector narrows the owner's currency to the question
* carrier (matched prop), and the whole behavior surface rides the carrier
* (domain encoding in contract/slots.ts PendingQuestion); no inject face, no
* service dependency beyond slots. Export discipline: packages/client/AGENTS.md.
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { QuestionWait } from './contract/slots.ts'
import { QuestionComposer } from './QuestionComposer.tsx'
export { PendingQuestion } from './contract/slots.ts'
export type { QuestionAnswer, QuestionComposerProps, QuestionWait } from './contract/slots.ts'
/**
* Required services (cordis fiber inject). 'conversation' is an ordering
* edge, not a call dependency: the 'conversation.composer' chain slot is
* declared by ui-conversation's apply, and register() into an undeclared
* slot throws — service waiting orders this apply after the declaring one.
*/
export const inject = ['slots', 'conversation']
/** Chain routing: claim the composer while a question wait is pending (pure — owner props only). */
function selectQuestion({ interactions }: ComposerChainProps): QuestionWait | null {
return interactions.find((i): i is QuestionWait => i.kind === 'question') ?? null
}
/**
* Client plugin body: register the question composer into the composer chain.
* Zero business face — data and verbs both live on the matched carrier.
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
const slots = ctx.slots
ctx.effect(
() => slots.register({ name: 'conversation.composer', select: selectQuestion }, QuestionComposer),
'ui-question: composer chain registration',
)
}

View File

@@ -0,0 +1,4 @@
declare module '*.module.css' {
const classes: Readonly<Record<string, string>>
export default classes
}

View File

@@ -0,0 +1,17 @@
/**
* Web question plugin, node half: enabling this UI feature also exposes the
* model-facing ask_user_question tool on the host composition.
*/
import type { Context } from 'cordis'
import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user'
/** Host services required by the model-facing tool. */
export const inject = ['tools', 'userInteraction']
/**
* Mount ask_user_question for hosts that selected the Web question plugin.
* @param ctx - Host plugin context carrying tools and userInteraction.
*/
export function apply(ctx: Context): void {
toolAskUser.apply(ctx)
}

View File

@@ -0,0 +1,31 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-question`.
* @module @deepseek-ai/dsh-client-ui-question/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-question'
/** Cordis companion plugin name. */
export const name = 'client-ui-question-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: tool and slot registrations are effects
* owned and observed by their respective registries; the host pending table is
* exercised through the public wire protocol.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns The installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,70 @@
/**
* apply wiring on a real cordis Context + SlotsService: QuestionComposer
* registered as the `question` entry of the conversation-declared composer
* slot with ZERO business face (data and verbs ride the dispatched carrier),
* load-order fail-loud, and fiber-teardown unregistration. Component and
* domain-face behavior is covered props-direct in question-composer.spec.tsx;
* no renderer machinery here.
*/
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { QuestionComposer } from '../src/client/QuestionComposer.tsx'
import { apply, inject } from '../src/client/index.ts'
async function bench() {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
const slots = ctx.get('slots') as SlotsService
// Stand-in for ui-conversation's conversation entry: the composer slot only
// exists while a live entry declares it in children (declaration account:
// design §2.2).
slots.register(
{ name: 'root', children: { 'conversation.composer': { kind: 'chain', scope: 'session' } } } as never,
() => null,
)
// 'conversation' inject is an ordering edge (the declaring plugin provides
// it after declaring the chain); the bench declares the chain itself.
ctx.provide('conversation', {})
return { ctx, slots }
}
describe('apply', () => {
it('declares the services it binds', () => {
expect(inject).toEqual(['slots', 'conversation'])
})
it('fails loud when no live entry has declared the composer slot', async () => {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
// Satisfy the ordering inject without declaring the chain: apply must
// then hit the undeclared-slot throw, not sit waiting on the service.
ctx.provide('conversation', {})
await expect(ctx.plugin({ inject: [...inject], apply }))
.rejects.toThrow(/slot "conversation.composer" is not declared/)
})
it('registers the question entry: routing selector, no inject face', async () => {
const { ctx, slots } = await bench()
await ctx.plugin({ inject: [...inject], apply }).await()
const entry = slots.entries('conversation.composer')[0]!
expect(entry.component).toBe(QuestionComposer)
// The whole behavior surface rides the matched carrier: no business face.
expect(entry.inject).toBeUndefined()
// The selector narrows the chain currency: question wait in → that wait; none → null.
const select = entry.select as (owner: { interactions: readonly { kind: string }[] }) => unknown
const question = { kind: 'question' }
expect(select({ interactions: [{ kind: 'approval' }, question] })).toBe(question)
expect(select({ interactions: [{ kind: 'approval' }] })).toBeNull()
expect(select({ interactions: [] })).toBeNull()
})
it('teardown unregisters the slot entry', async () => {
const { ctx, slots } = await bench()
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
expect(slots.entries('conversation.composer')).toHaveLength(1)
await fiber.dispose()
expect(slots.entries('conversation.composer')).toHaveLength(0)
})
})

View File

@@ -0,0 +1,28 @@
import { Context } from 'cordis'
import { afterEach, describe, expect, it } from 'vitest'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import { apply, inject } from '../src/index.ts'
let ctx: Context | undefined
afterEach(async () => {
await ctx?.fiber.dispose()
ctx = undefined
})
describe('ui-question node plugin', () => {
it('exposes ask_user_question only for the selected Web feature lifecycle', async () => {
ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(UserInteractionService)
const feature = ctx.plugin({ inject: [...inject], apply })
await feature.await()
expect(ctx.tools.get('ask_user_question')).toBeDefined()
await feature.dispose()
expect(ctx.tools.get('ask_user_question')).toBeUndefined()
})
})

View File

@@ -0,0 +1,265 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
import type { RpcReceipt } from '@deepseek-ai/dsh-client-connection/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { PendingQuestion } from '../src/client/contract/slots.ts'
import {
QuestionComposer, parseQuestionTitle, parseRecommendedLabel,
} from '../src/client/QuestionComposer.tsx'
afterEach(cleanup)
const SID = 's1' as SessionId
/** Framework standard-kit stubs: the composer consumes none of them, the
* composed props type mandates their delivery (framework hooks are plain
* stubs per the client testing discipline). */
const kit = {
sessionId: SID,
useSession: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<ConversationSnapshot>,
useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<SessionListState>,
}
const QUESTIONS = [
{
id: 'profile', header: '偏好', question: '选择候选人类型',
detail: '按当前空缺岗位的优先级选择。',
options: [
{ label: '工程落地型 (Recommended)', description: '优先工程交付。' },
{ label: '研究潜力型', description: '优先研究能力。' },
],
},
{
id: 'detail', question: '补充你的要求',
},
{
id: 'signals', question: '选择重要信号(可多选)', multiSelect: true,
options: [{ label: '系统设计' }, { label: '代码质量' }, { label: '产品判断' }],
},
]
/** Carrier fixture: a real PendingWait over a scripted respond carrier. */
function wait(rpcId = 'question-1', respond = vi.fn(() => Promise.resolve<RpcReceipt>({ accepted: true }))) {
const carrier = new PendingWait(
'question', RpcId(rpcId), SID, { questions: QUESTIONS } as PendingWait<'question'>['payload'], respond)
return { carrier, respond }
}
/** The client-response envelope respond must have received for an answer batch. */
function answeredEnvelope(rpcId: string, answers: object[]) {
return {
type: 'client-response', rpcId: RpcId(rpcId),
result: { ok: true, value: { sessionId: SID, answer: { answers } } },
}
}
describe('QuestionComposer', () => {
it('collects single, custom, and multi-select answers before one batch submit', () => {
const { carrier, respond } = wait()
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
expect(screen.getByText('1 / 3')).toBeTruthy()
expect(screen.getByText('推荐')).toBeTruthy()
expect(screen.getByText('工程落地型')).toBeTruthy()
expect(screen.getByText('按当前空缺岗位的优先级选择。')).toBeTruthy()
fireEvent.keyDown(screen.getByRole('radio', { name: /工程落地型/ }), { key: 'Enter' })
expect(respond).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('radio', { name: /工程落地型/ }))
expect(screen.getByText('2 / 3')).toBeTruthy()
// detail is per-question: the second question carries none.
expect(screen.queryByText('按当前空缺岗位的优先级选择。')).toBeNull()
expect(screen.queryByRole('button', { name: '填写答案' })).toBeNull()
const custom = screen.getByPlaceholderText('输入你的答案')
fireEvent.change(custom, { target: { value: '要能独立排查线上问题' } })
fireEvent.keyDown(custom, { key: 'Enter' })
expect(screen.getByText('3 / 3')).toBeTruthy()
expect(screen.getByText('选择重要信号')).toBeTruthy()
expect(screen.getByText('可多选')).toBeTruthy()
expect(screen.queryByText('(可多选)')).toBeNull()
fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
fireEvent.click(screen.getByRole('checkbox', { name: '代码质量' }))
fireEvent.keyDown(screen.getByRole('checkbox', { name: '代码质量' }), { key: 'Enter' })
// The domain face encoded the whole batch into one carrier envelope.
expect(respond).toHaveBeenCalledWith(answeredEnvelope('question-1', [
{ id: 'profile', selected: ['工程落地型 (Recommended)'] },
{ id: 'detail', selected: [], custom: '要能独立排查线上问题' },
{ id: 'signals', selected: ['系统设计', '代码质量'] },
]))
expect((screen.getByRole('button', { name: '正在提交…' }) as HTMLButtonElement).disabled).toBe(true)
})
it('skips individual questions without discarding earlier answers', () => {
const { carrier, respond } = wait()
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
expect((screen.getByText('下一题').closest('button') as HTMLButtonElement).disabled).toBe(true)
fireEvent.click(screen.getByRole('radio', { name: '研究潜力型' }))
expect(screen.getByText('2 / 3')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: '跳过本题' }))
expect(screen.getByText('3 / 3')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: '跳过本题' }))
expect(respond).toHaveBeenCalledWith(answeredEnvelope('question-1', [
{ id: 'profile', selected: ['研究潜力型'] },
{ id: 'detail', selected: [] },
{ id: 'signals', selected: [] },
]))
})
it('keeps IME Enter inside the custom input until composition finishes', () => {
const { carrier, respond } = wait()
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
fireEvent.click(screen.getByRole('radio', { name: '研究潜力型' }))
const custom = screen.getByPlaceholderText('输入你的答案')
fireEvent.change(custom, { target: { value: '中文输入' } })
fireEvent.keyDown(custom, { key: 'Enter', isComposing: true })
expect(screen.getByText('2 / 3')).toBeTruthy()
expect(respond).not.toHaveBeenCalled()
fireEvent.keyDown(custom, { key: 'Enter', keyCode: 229 })
expect(screen.getByText('2 / 3')).toBeTruthy()
expect(respond).not.toHaveBeenCalled()
fireEvent.keyDown(custom, { key: 'Enter' })
expect(screen.getByText('3 / 3')).toBeTruthy()
})
it('opens custom input, reports missing skipped answers, and supports header navigation', () => {
const { carrier, respond } = wait()
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
fireEvent.click(screen.getByRole('button', { name: '其他,请填写自定义答案' }))
expect(screen.getByPlaceholderText('输入你的答案')).toBeTruthy()
fireEvent.click(screen.getByRole('radio', { name: '工程落地型' }))
const emptyCustom = screen.getByPlaceholderText('输入你的答案')
fireEvent.keyDown(emptyCustom, { key: 'Enter', shiftKey: true })
expect(screen.getByText('2 / 3')).toBeTruthy()
fireEvent.keyDown(emptyCustom, { key: 'Enter' })
expect(screen.getByText('请选择一个选项或填写自定义答案。')).toBeTruthy()
fireEvent.click(screen.getByLabelText('下一题'))
fireEvent.click(screen.getByRole('checkbox', { name: '产品判断' }))
fireEvent.click(screen.getByRole('button', { name: '提交' }))
expect(screen.getByText('请先完成这道问题。')).toBeTruthy()
expect(screen.getByText('2 / 3')).toBeTruthy()
fireEvent.click(screen.getByLabelText('上一题'))
expect(screen.getByText('1 / 3')).toBeTruthy()
expect(respond).not.toHaveBeenCalled()
})
it('surfaces cancellation failures: rejected receipt text and raw transport reasons', async () => {
const respond = vi.fn()
.mockResolvedValueOnce({ accepted: false, reason: 'bad-response' })
.mockRejectedValueOnce(new Error('第二次取消失败'))
const { carrier } = wait('question-1', respond)
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
// Receipt rejection surfaces through the domain face's thrown message.
fireEvent.click(screen.getByRole('button', { name: '放弃整组问题' }))
expect(await screen.findByText('question cancellation rejected: bad-response')).toBeTruthy()
expect((screen.getByRole('button', { name: '跳过本题' }) as HTMLButtonElement).disabled).toBe(false)
fireEvent.click(screen.getByRole('button', { name: '放弃整组问题' }))
expect(await screen.findByText('第二次取消失败')).toBeTruthy()
})
it('surfaces transport rejection and resets local drafts for a different request', async () => {
const respond = vi.fn()
.mockRejectedValueOnce(new Error('网络中断'))
.mockRejectedValueOnce('字符串错误')
const first = wait('first', respond)
const view = render(<QuestionComposer matched={first.carrier} interactions={[first.carrier]} {...kit} />)
fireEvent.click(screen.getByRole('radio', { name: /研究潜力型/ }))
expect(screen.getByText('2 / 3')).toBeTruthy()
const second = wait('second', respond)
view.rerender(<QuestionComposer matched={second.carrier} interactions={[second.carrier]} {...kit} />)
expect(screen.getByRole('radio', { name: /研究潜力型/ }).getAttribute('aria-checked')).toBe('false')
fireEvent.click(screen.getByRole('radio', { name: /工程落地型/ }))
const custom = screen.getByPlaceholderText('输入你的答案')
fireEvent.change(custom, { target: { value: 'x' } })
fireEvent.keyDown(custom, { key: 'Enter' })
fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
fireEvent.click(screen.getByRole('button', { name: '提交' }))
expect(await screen.findByText('网络中断')).toBeTruthy()
expect((screen.getByRole('button', { name: '提交' }) as HTMLButtonElement).disabled).toBe(false)
fireEvent.click(screen.getByRole('button', { name: '提交' }))
expect(await screen.findByText('字符串错误')).toBeTruthy()
})
it('same-key carrier replacement (baseline replay) keeps drafts', () => {
const first = wait('same-id')
const view = render(<QuestionComposer matched={first.carrier} interactions={[first.carrier]} {...kit} />)
fireEvent.click(screen.getByRole('radio', { name: /研究潜力型/ }))
expect(screen.getByText('2 / 3')).toBeTruthy()
// Replay mints a NEW carrier for the same request; same key = no remount.
const replayed = wait('same-id')
view.rerender(<QuestionComposer matched={replayed.carrier} interactions={[replayed.carrier]} {...kit} />)
expect(screen.getByText('2 / 3')).toBeTruthy()
})
})
describe('PendingQuestion domain face', () => {
it('encodes the answer batch into the ok envelope and throws on a rejected receipt', async () => {
const respond = vi.fn()
.mockResolvedValueOnce({ accepted: true })
.mockResolvedValueOnce({ accepted: false, reason: 'not-pending' })
const question = new PendingQuestion(wait('rq', respond).carrier)
const batch = { answers: [{ id: 'mode', selected: ['Fast'] }] }
await expect(question.answer(batch)).resolves.toBeUndefined()
expect(respond).toHaveBeenCalledWith(answeredEnvelope('rq', batch.answers))
await expect(question.answer(batch)).rejects.toThrow(/question response rejected: not-pending/)
})
it('encodes cancellation as the cancelled error envelope and throws on a rejected receipt', async () => {
const respond = vi.fn()
.mockResolvedValueOnce({ accepted: true })
.mockResolvedValueOnce({ accepted: false, reason: 'bad-response' })
const question = new PendingQuestion(wait('rc', respond).carrier)
await expect(question.cancel()).resolves.toBeUndefined()
expect(respond).toHaveBeenCalledWith({
type: 'client-response', rpcId: RpcId('rc'),
result: {
ok: false,
error: { code: 'cancelled', message: 'the user closed this question request', details: {} },
},
})
await expect(question.cancel()).rejects.toThrow(/question cancellation rejected: bad-response/)
})
it('forwards key and questions from the carrier', () => {
const question = new PendingQuestion(wait('rk').carrier)
expect(question.key).toBe('q:rk')
expect(question.questions).toBe(wait('rk').carrier.payload.questions)
})
})
describe('parseRecommendedLabel', () => {
it('recognizes English and Chinese suffixes without changing ordinary labels', () => {
expect(parseRecommendedLabel('Fast (Recommended)')).toEqual({ label: 'Fast', recommended: true })
expect(parseRecommendedLabel('稳妥(推荐)')).toEqual({ label: '稳妥', recommended: true })
expect(parseRecommendedLabel('稳妥 (推荐)')).toEqual({ label: '稳妥', recommended: true })
expect(parseRecommendedLabel('Plain')).toEqual({ label: 'Plain', recommended: false })
})
})
describe('parseQuestionTitle', () => {
it('removes Chinese and ASCII multi-select suffixes', () => {
expect(parseQuestionTitle('选择信号(可多选)')).toBe('选择信号')
expect(parseQuestionTitle('选择信号 (可多选)')).toBe('选择信号')
expect(parseQuestionTitle('选择信号')).toBe('选择信号')
})
})

View File

@@ -0,0 +1,36 @@
{
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../connection"
},
{
"path": "../runtime"
},
{
"path": "../ui-conversation"
},
{
"path": "../ui-primitives"
},
{
"path": "../ui-slots"
},
{
"path": "../../ui/tool-ask-user"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -0,0 +1,3 @@
import { clientBundle } from '../tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-client-ui-question', ['lib/types/index.js', 'lib/types/invariant.js'])

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-client-ui-sidebar
Sidebar plugin: session multi-level tree (cwd grouping + parentId nesting), search, by-workspace grouping, state dots, three creation entries. Collapse morphs the four control rows into the layout-owned 56px rail (expand / new session / new workspace / search — search expands and focuses the search box) plus the settings foot: geometry animates on the deepsuite curve while wide-only content cross-fades and unmounts at settle. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md).
Sidebar plugin: session multi-level tree (cwd grouping + parentId nesting), search, by-workspace grouping, state dots, three creation entries. Collapse is a slide + crossfade into the layout-owned 56px rail (open / new session / new workspace / search — search expands and focuses the search box plus the settings foot): the expanded content freezes at its width and fades in place while the column slides over it, then the rail — whale mark resting, panel icon on hover, tooltips on every control — crossfades in at settle as the wide content unmounts. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md).
`src/client/contract/slots.ts` is the single-domain contract file: `SidebarRootInjected` (the registrant's own injected share — plain service callbacks: onOpen/onCreate/onToggleSidebar) and `SidebarRootComponentProps = PropsRuntime<'sidebar'> & SidebarRootInjected` (owner `{collapsed,width}` plus the standard `useSessions` hook, resolved off ui-layout's SlotMap declaration, never re-stated). `apply` registers SidebarRoot cast-free against that composition; the inject factory closes over the plugin's own ctx.

View File

@@ -24,6 +24,7 @@
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-layout"
],
"platform": "web"
@@ -34,21 +35,25 @@
},
"license": "BSD-3-Clause",
"dependencies": {
"clsx": "^2.0.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"clsx": "^2.0.0",
"react": "^18.2.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"cordis": "^4.0.0-rc.7"
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"
},
"files": [
"lib/index.js",

View File

@@ -24,12 +24,38 @@
background: var(--dsw-alias-interactive-bg-active);
}
/* Two-line row: the leading slot (folder/chevron), title, and trailing
actions all top-align on the 20px first text line (figma cell) — content
is 42px (20 + 2 + 20), so 6px vertical padding centers the block. */
.projectRow {
height: 54px;
align-items: flex-start;
padding-top: 6px;
padding-bottom: 6px;
box-sizing: border-box;
}
.projectRow .rowActions {
height: 20px;
}
/* Session cell (figma): pad 8, adjacent 16px twist + status slots, then a 4px
gap to the title — the slots butt together, so the row gap is zeroed and
the title carries its own margins. */
.sessionRow {
height: 34px;
gap: 0;
/* Mount fade: session rows appear by unfolding a group (or the tree
mounting). Stable row keys keep already-visible rows from replaying it. */
animation: row-in 150ms var(--ds-ease-in-out);
}
.sessionRow .title {
margin: 0 6px 0 4px;
}
@keyframes row-in {
from { opacity: 0; }
}
.slot {
@@ -47,11 +73,20 @@
color: var(--dsw-alias-state-business-primary);
}
/* Project leading slot: folder by default, chevron on row hover. */
/* Project leading slot: folder by default, expand arrow on row hover. */
.projectRow .chevron { display: none; }
.projectRow:hover .chevron { display: inline-flex; }
.projectRow:hover .folder { display: none; }
/* Expand arrow (filled triangle): points right closed, rotates to point down open. */
.arrow {
transition: transform 150ms var(--ds-ease-in-out);
}
.arrowOpen {
transform: rotate(90deg);
}
.projectText {
flex: 1;
min-width: 0;
@@ -131,22 +166,25 @@
}
/* Session expand twist occupies the leading 16px slot; keep a spacer when absent
so titles align across sibling rows. */
so titles align across sibling rows. Duplicates the .iconButton reset instead
of `composes:` — the tsdown CSS-modules pipeline drops composes mappings, which
left the raw UA button box showing. */
.twist {
composes: iconButton;
width: 16px;
height: 20px;
}
/* "L" connector slot (figma arrow 14:3071): 16x16, glyph right-aligned. */
.cornerSlot {
flex: none;
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: flex-end;
color: var(--dsw-alias-label-caption);
justify-content: center;
width: 16px;
height: 20px;
border: none;
border-radius: 4px;
padding: 0;
background: transparent;
cursor: pointer;
}
.twist:hover {
color: var(--dsw-alias-label-primary);
}
/* Chevrons and tree twists ride the caption grey (#ADB2B8); the folder glyph
@@ -156,3 +194,11 @@
.twist {
color: var(--dsw-alias-label-caption);
}
@media (prefers-reduced-motion: reduce) {
.sessionRow,
.arrow {
animation: none;
transition: none;
}
}

View File

@@ -5,16 +5,15 @@
*/
import clsx from 'clsx'
import {
IconChevronDownOutline14, IconChevronRightOutline14,
IconEllipsisOutline16, IconFolderClose16, IconFolderOpen16, IconPlusOutline16,
IconTreeCorner8x10, StateDot,
IconTriangleRightFill14, StateDot,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { ProjectRow, SessionRow } from './tree.ts'
import { formatRelativeTime } from './tree.ts'
import css from './Rows.module.css'
/** Indent step per tree level: 16px slot + 6px gap (figma). */
const INDENT_STEP = 22
/** Indent step per tree level: one 16px slot (figma session cell). */
const INDENT_STEP = 16
/**
* Project (workspace) row: 54px, folder + title + session count; hover
@@ -38,7 +37,7 @@ export function ProjectRowItem({ row, active, onToggle, onCreate }: {
{row.expanded ? <IconFolderOpen16 /> : <IconFolderClose16 />}
</span>
<span className={clsx(css.slot, css.chevron)}>
{row.expanded ? <IconChevronDownOutline14 /> : <IconChevronRightOutline14 />}
<IconTriangleRightFill14 className={clsx(css.arrow, row.expanded && css.arrowOpen)} />
</span>
<span className={css.projectText}>
<span className={css.title}>{row.label}</span>
@@ -79,17 +78,16 @@ export function SessionRowItem({ row, selected, now, onOpen, onToggle }: {
onOpen: () => void
onToggle: () => void
}) {
// Rail (figma sub-cell slot sequence): twist slot, always-reserved state
// slot (opacity-0 slots keep their 22px in figma, so titles align whether
// or not the dot is lit), then the L connector on child rows. Extra depth
// rides the left padding: indent spacers = depth - 1.
// Rail (figma session cell: pad 8, twist slot 16, status slot 16, gap 4 to
// the title): both slots are always reserved so titles align whether or not
// the twist/dot is lit. Extra depth rides the left padding.
return (
<div
className={clsx(css.sessionRow, selected && css.selected)}
role="treeitem"
aria-selected={selected}
{...(row.hasChildren ? { 'aria-expanded': row.expanded } : {})}
style={{ paddingLeft: 8 + Math.max(0, row.depth - 1) * INDENT_STEP }}
style={{ paddingLeft: 8 + row.depth * INDENT_STEP }}
onClick={onOpen}
>
{row.hasChildren
@@ -100,16 +98,11 @@ export function SessionRowItem({ row, selected, now, onOpen, onToggle }: {
aria-label={row.expanded ? 'Collapse' : 'Expand'}
onClick={(e) => { e.stopPropagation(); onToggle() }}
>
{row.expanded ? <IconChevronDownOutline14 /> : <IconChevronRightOutline14 />}
<IconTriangleRightFill14 className={clsx(css.arrow, row.expanded && css.arrowOpen)} />
</button>
)
: <span className={css.slot} />}
<span className={css.slot}>{row.running && <StateDot state="ongoing" />}</span>
{row.depth > 0 && (
<span className={css.cornerSlot} data-tree-corner="">
<IconTreeCorner8x10 />
</span>
)}
<span className={css.title}>{row.title}</span>
<span className={css.time}>{formatRelativeTime(row.updatedAt, now)}</span>
<span className={css.rowActions}>

View File

@@ -1,41 +1,62 @@
/* Sidebar column (figma 133:7629): vertical stack, padding 16/6, sidebar
fill + 1px right border painted by the layout column. Collapse morphs in
place: the four control rows persist into the 56px rail (one icon each,
x-converged by the shrinking column), geometry rides the deepsuite curve
while wide-only content cross-fades 200ms; explicit margins own the
vertical rhythm in both states so every gap can transition. */
/* Sidebar column (figma 133:7629): vertical stack, padding 12/6, sidebar
fill + 1px right border painted by the layout column. Collapse is a
slide + crossfade, not a morph: the content holds its frozen expanded
layout (inline width set by the component) and fades in place (.fading)
while the sliding column (AppFrame grid tracks) clips it; the rail layout
(.collapsed) only applies after the fade settles, so nothing reflows
mid-slide. */
.root {
display: flex;
flex-direction: column;
height: 100%;
padding: 6px 16px;
padding: 6px 12px;
box-sizing: border-box;
background: var(--dsw-specific-sidebar-fill);
color: var(--dsw-alias-label-primary);
font-size: 14px;
transition: padding var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
/* Rail geometry (figma rail spec): 36x36 control boxes centered in the 56px
rail (10px side padding), 12px vertical rhythm, 18px from the rail top to
the whale's box (24px to the 24-wide whale glyph itself). */
.root.collapsed {
padding-top: 14px;
padding: 18px 10px 6px;
}
/* Wide-only content: fades ahead of the geometry (200ms vs 300ms) and
unmounts once the collapse settles; remounts fade back in. */
/* Collapse phase 1: the whole frozen-width content fades out in place over
150ms; at settle the children unmount/snap to the rail layout. */
.fading > * {
opacity: 0;
transition: opacity 150ms var(--ds-ease-in-out);
}
/* Wide-only content fades back in on expand remount. */
.wide {
animation: wide-in 200ms var(--ds-ease-in-out);
transition: opacity 200ms var(--ds-ease-in-out);
}
.collapsed .wide {
opacity: 0;
}
@keyframes wide-in {
from { opacity: 0; }
}
/* Rail controls hold hidden while the column slides shut, then fade in over
the slide's tail: .railIn applies at settle (150ms into the 0.3s AppFrame
track transition), so a 100ms delay + 150ms fade starts just before the
slide ends (250ms) and finishes at 400ms; `backwards` keeps them at
opacity 0 through the delay. Only a live collapse gets .railIn — a
refresh straight into the collapsed state renders statically. */
.railIn .iconButton,
.railIn .newSession,
.railIn .searchButton,
.railIn .foot {
animation: rail-in 150ms var(--ds-ease-in-out) 100ms backwards;
}
@keyframes rail-in {
from { opacity: 0; }
}
/* Logo row (figma pad (4,8,4,8)): brand left, panel toggle right-anchored —
the toggle is the rail's expand control and slides in with the right edge. */
.logoRow {
@@ -45,23 +66,19 @@
justify-content: flex-end;
gap: 8px;
height: 60px;
padding: 8px 4px;
padding: 8px 0 8px 4px;
margin-bottom: 16px;
box-sizing: border-box;
overflow: hidden;
transition:
height var(--ds-transition-duration-slow) var(--ds-ease-in-out),
padding var(--ds-transition-duration-slow) var(--ds-ease-in-out),
margin var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
.collapsed .logoRow {
height: 24px;
height: 36px;
padding: 0;
margin-bottom: 8px;
margin-bottom: 12px;
}
/* Brand group (figma I133:7632): fish + wordmark ride the text ink
/* Brand group (figma I133:7632): the full wordmark rides the text ink
(figma-flows ruling: main-screen instance is black; blue is brand
emphasis only). */
.brand {
@@ -69,28 +86,9 @@
min-width: 0;
display: inline-flex;
align-items: center;
gap: 7px;
overflow: hidden;
}
.wordmark {
font-weight: 600;
white-space: nowrap;
}
/* HARNESS badge (figma 34:10358): 14px tall, mono 11/500 on primary fill. */
.badge {
flex: none;
padding: 0 3px;
border-radius: 2px;
background: var(--dsw-alias-label-primary);
color: var(--dsw-alias-label-primary-inverted);
font-family: var(--ds-font-family-code);
font-size: 11px;
font-weight: 500;
line-height: 14px;
}
.iconButton {
flex: none;
display: inline-flex;
@@ -104,9 +102,6 @@
background: transparent;
cursor: pointer;
color: var(--dsw-alias-label-secondary);
transition:
width var(--ds-transition-duration-slow) var(--ds-ease-in-out),
height var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
.iconButton:hover {
@@ -114,12 +109,33 @@
}
.collapsed .iconButton {
width: 24px;
height: 24px;
width: 36px;
height: 36px;
}
/* New Session: 38px capsule (figma 133:7634) morphing into the rail's plain
icon control — border and fill fade with the label. */
/* Rail logo swap: collapsed, the toggle rests as the whale mark (brand ink,
no hover circle) and hovering reveals the panel icon — the expand
affordance (figma sidebar-hover flow). Expanded it is a plain panel icon. */
.collapsed .toggle .panelIcon {
display: none;
}
.collapsed .toggle:hover .panelIcon {
display: inline;
}
.collapsed .toggle:hover .railFish {
display: none;
}
/* Rail icons ride the primary ink (figma rail spec); expanded keeps the
secondary icon-button ink. */
.collapsed .iconButton {
color: var(--dsw-alias-label-primary);
}
/* New Session: 38px capsule (figma 133:7634); collapsed it renders as the
rail's plain icon control. */
.newSession {
flex: none;
display: flex;
@@ -128,24 +144,17 @@
gap: 6px;
height: 38px;
padding: 8px 16px;
margin-bottom: 20px; /* former headerBlock padBottom 12 + root gap 8 */
margin: 0 2px 20px; /* bottom: former headerBlock padBottom 12 + root gap 8 */
box-sizing: border-box;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 24px;
background: var(--dsw-alias-button-elevated-fill);
color: var(--dsw-alias-label-primary);
font-size: 14px;
font-weight: 510;
font-weight: 500;
line-height: 22px;
cursor: pointer;
overflow: hidden;
transition:
height var(--ds-transition-duration-slow) var(--ds-ease-in-out),
padding var(--ds-transition-duration-slow) var(--ds-ease-in-out),
margin var(--ds-transition-duration-slow) var(--ds-ease-in-out),
gap var(--ds-transition-duration-slow) var(--ds-ease-in-out),
border-color var(--ds-transition-duration-slow) var(--ds-ease-in-out),
background-color 200ms var(--ds-ease-in-out);
}
.newSession:hover {
@@ -153,9 +162,9 @@
}
.collapsed .newSession {
height: 24px;
height: 36px;
padding: 0;
margin-bottom: 8px;
margin: 0 0 12px;
gap: 0;
border-color: transparent;
background: transparent;
@@ -169,7 +178,6 @@
max-width: 200px;
overflow: hidden;
white-space: nowrap;
transition: max-width var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
.collapsed .newSessionLabel {
@@ -191,16 +199,12 @@
border-radius: 12px;
overflow: hidden;
color: var(--dsw-alias-label-tertiary);
transition:
height var(--ds-transition-duration-slow) var(--ds-ease-in-out),
padding var(--ds-transition-duration-slow) var(--ds-ease-in-out),
margin var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
.collapsed .sectionHeader {
height: 24px;
height: 36px;
padding-left: 0;
margin-bottom: 8px;
margin-bottom: 12px;
}
.sectionLabel {
@@ -211,8 +215,8 @@
line-height: 20px;
}
/* Search input: 38px capsule (figma 133:7649) morphing into the rail's
search control. Upstream binds a dedicated design-system variable (light
/* Search input: 38px capsule (figma 133:7649); collapsed it renders as the
rail's search control. Upstream binds a dedicated design-system variable (light
#F1F3F5 / dark #1B1B1C) matching no shipped alias — a component token
pinned to the static scale mirrors it (ruled compliant: indirect via
custom property, upstream-variable equivalent). */
@@ -223,7 +227,7 @@
align-items: center;
gap: 8px;
height: 38px;
margin-bottom: 12px; /* former listArea gap 4 + own 8 (spec padB12 to the first cell) */
margin: 0 2px 12px; /* bottom: former listArea gap 4 + own 8 (spec padB12 to the first cell) */
padding: 0 14px;
box-sizing: border-box;
border: 1px solid var(--dsw-alias-border-l2);
@@ -231,13 +235,6 @@
background: var(--dsh-search-input-fill);
color: var(--dsw-alias-label-caption);
overflow: hidden;
transition:
height var(--ds-transition-duration-slow) var(--ds-ease-in-out),
padding var(--ds-transition-duration-slow) var(--ds-ease-in-out),
margin var(--ds-transition-duration-slow) var(--ds-ease-in-out),
gap var(--ds-transition-duration-slow) var(--ds-ease-in-out),
border-color var(--ds-transition-duration-slow) var(--ds-ease-in-out),
background-color 200ms var(--ds-ease-in-out);
}
:global(body[data-ds-dark-theme]) .search {
@@ -245,9 +242,9 @@
}
.collapsed .search {
height: 24px;
height: 36px;
padding: 0;
margin-bottom: 8px;
margin: 0 0 12px;
gap: 0;
border-color: transparent;
background: transparent;
@@ -261,8 +258,6 @@
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
border-radius: 50%;
padding: 0;
@@ -272,9 +267,11 @@
}
.collapsed .searchButton {
width: 36px;
height: 36px;
pointer-events: auto;
cursor: pointer;
color: var(--dsw-alias-label-secondary);
color: var(--dsw-alias-label-primary);
}
.collapsed .searchButton:hover {
@@ -366,39 +363,41 @@
font-size: 13px;
}
/* Foot: settings entry (figma 133:7668). Left padding lands the 14px glyph
on the rail's icon axis when collapsed. */
/* Foot: settings entry (figma 133:7668, 49 hug): the former 18/10 vertical
margins fold into the row so the hover pill spans the full 49px. */
.foot {
flex: none;
display: flex;
align-items: center;
gap: 8px;
height: 29px;
margin: 18px 0 10px; /* former root gap 8 + own 10 above; root padBottom 6 below */
height: 49px;
margin: 8px 0 0; /* + 49px row + root padBottom 6 keeps the old 57px band */
padding: 0 2px 0 6px;
border-radius: 12px;
cursor: pointer;
overflow: hidden;
color: var(--dsw-alias-label-primary);
transition:
padding var(--ds-transition-duration-slow) var(--ds-ease-in-out),
gap var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
.foot:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
/* Rail settings: the same 36x36 circle box as the other rail controls. */
.collapsed .foot {
width: 36px;
height: 36px;
margin: 18px 0 10px;
justify-content: center;
gap: 0;
padding: 0 0 0 5px;
padding: 0;
border-radius: 50%;
}
.footLabel {
max-width: 120px;
overflow: hidden;
white-space: nowrap;
transition: max-width var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
.collapsed .footLabel {
@@ -406,16 +405,12 @@
}
@media (prefers-reduced-motion: reduce) {
.root,
.wide,
.logoRow,
.iconButton,
.newSession,
.newSessionLabel,
.sectionHeader,
.search,
.foot,
.footLabel {
.fading > *,
.railIn .iconButton,
.railIn .newSession,
.railIn .searchButton,
.railIn .foot {
transition: none;
animation: none;
}

View File

@@ -6,28 +6,32 @@
* state, and rows are derived in render via useMemo (slot design section 6:
* derived data is a pure function, no materializing store).
*
* Collapse is a morph, not a swap: the four control rows persist into the
* 56px rail (collapse/new session/new workspace/search, one icon each, same
* top-down order as their expanded rows) and animate their geometry on the
* deepsuite curve, while wide-only content (brand, labels, input, tree)
* cross-fades out and unmounts once the collapse settles — dropping the
* sessions subscription. Rail search expands and focuses the search box.
* Collapse is a slide + crossfade: the content freezes at its expanded
* width (inline style) and fades out in place while the sliding column
* (AppFrame grid tracks) clips it — nothing reflows mid-slide. At settle
* the wide-only content (brand, labels, input, tree) unmounts, dropping
* the sessions subscription, and the control rows snap to the 56px rail
* (one icon each, same top-down order) fading in as the slide ends. Rail
* search expands and focuses the search box.
*/
import { Fragment, useEffect, useMemo, useRef, useState } from 'react'
import clsx from 'clsx'
import {
FishLogo,
BrandWordmark, FishLogo,
IconCloseFill14, IconNewChatOutline16, IconPanelLeftOutline16, IconPersonalizationOutline16,
IconProjectAddOutline16, IconSearchOutline16, IconSettingsOutline14,
Menu,
Menu, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { SidebarRootComponentProps } from './contract/slots.ts'
import { deriveRows } from './tree.ts'
import { ProjectRowItem, SessionRowItem } from './Rows.tsx'
import css from './SidebarRoot.module.css'
/** Wide-content unmount delay; matches --ds-transition-duration-slow (0.3s). */
const COLLAPSE_SETTLE_MS = 300
/** Wide-content unmount delay; matches the 150ms wide-content fade-out. */
const COLLAPSE_SETTLE_MS = 150
/** Column slide length (--ds-transition-duration-slow): rail-search focus waits it out — focus() forces a synchronous layout and would jank the slide. */
const EXPAND_SLIDE_MS = 300
const GROUP_BY_ITEMS = [
{ id: 'workspace', label: 'WorkSpace' },
@@ -134,7 +138,7 @@ function SessionTree({ useSessions, onOpen, onCreate, query }: SessionTreeProps)
* @param props - composed slot props (runtime share + injected callbacks, contract/slots.ts).
* @returns the sidebar element tree.
*/
export function SidebarRoot({ collapsed, useSessions, onOpen, onCreate, onToggleSidebar }: SidebarRootComponentProps) {
export function SidebarRoot({ collapsed, width, useSessions, onOpen, onCreate, onToggleSidebar }: SidebarRootComponentProps) {
// The query outlives the tree and the input (both wide-only) so collapsing
// does not silently drop an in-progress filter.
const [query, setQuery] = useState('')
@@ -150,72 +154,98 @@ export function SidebarRoot({ collapsed, useSessions, onOpen, onCreate, onToggle
}, [collapsed])
const wide = !collapsed || !settled
// Freeze the content at its expanded width while it fades out (collapsed
// && wide): the sliding column then clips it instead of reflowing it. The
// rail layout (.collapsed styles) only applies once the fade settles.
const lastWideWidth = useRef(width)
if (!collapsed) lastWideWidth.current = width
// Rail-in only crossfades a live collapse: a refresh straight into the
// collapsed state renders the rail statically (no delay-hidden icons).
const everWide = useRef(!collapsed)
if (!collapsed) everWide.current = true
// Rail search = expand + land in the search box: the flag arms before the
// expand toggle; once expanded the input is mounted and takes focus.
const [searchOnExpand, setSearchOnExpand] = useState(false)
useEffect(() => {
if (!collapsed && searchOnExpand) {
searchInput.current?.focus()
setSearchOnExpand(false)
const timer = window.setTimeout(() => {
searchInput.current?.focus({ preventScroll: true })
setSearchOnExpand(false)
}, EXPAND_SLIDE_MS)
return () => { window.clearTimeout(timer) }
}
}, [collapsed, searchOnExpand])
return (
<div className={clsx(css.root, collapsed && css.collapsed)}>
<div
className={clsx(css.root, !wide && css.collapsed, !wide && everWide.current && css.railIn, collapsed && wide && css.fading)}
style={wide ? { width: collapsed ? lastWideWidth.current : width } : undefined}
>
<div className={css.logoRow}>
{wide && (
<span className={clsx(css.brand, css.wide)}>
{/* Wordmark svg not extracted yet (figma 88:8932) — text stands in at the same ink. */}
<FishLogo size={23} />
<span className={css.wordmark}>deepseek</span>
<span className={css.badge}>HARNESS</span>
<BrandWordmark />
</span>
)}
<button
type="button"
className={css.iconButton}
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
onClick={() => { onToggleSidebar() }}
>
<IconPanelLeftOutline16 />
</button>
{/* Rail resting state is the whale mark; hovering swaps in the panel
icon (the expand affordance, figma sidebar-hover flow). */}
<Tooltip label="Open sidebar" disabled={wide}>
<button
type="button"
className={clsx(css.iconButton, css.toggle)}
aria-label={collapsed ? 'Open sidebar' : 'Collapse sidebar'}
onClick={() => { onToggleSidebar() }}
>
{!wide && <FishLogo className={css.railFish} size={24} />}
{/* Rail icons render at 18 (figma rail spec); expanded keeps the glyph-native sizes. */}
<IconPanelLeftOutline16 className={css.panelIcon} size={wide ? 16 : 18} />
</button>
</Tooltip>
</div>
<button
type="button"
className={css.newSession}
aria-label="New session"
onClick={() => { onCreate() }}
>
<IconNewChatOutline16 size={14} />
{wide && <span className={clsx(css.newSessionLabel, css.wide)}>New Session</span>}
</button>
<Tooltip label="New session" disabled={wide}>
<button
type="button"
className={css.newSession}
aria-label="New session"
onClick={() => { onCreate() }}
>
<IconNewChatOutline16 size={wide ? 14 : 18} />
{wide && <span className={clsx(css.newSessionLabel, css.wide)}>New Session</span>}
</button>
</Tooltip>
<div className={css.sectionHeader}>
{wide && <span className={clsx(css.sectionLabel, css.wide)}>WorkSpace</span>}
{wide && <GroupByMenu />}
<button
type="button"
className={css.iconButton}
aria-label="New workspace"
onClick={() => { onCreate() }}
>
<IconProjectAddOutline16 />
</button>
<Tooltip label="New Workspace" disabled={wide}>
<button
type="button"
className={css.iconButton}
aria-label="New workspace"
onClick={() => { onCreate() }}
>
<IconProjectAddOutline16 size={wide ? 16 : 18} />
</button>
</Tooltip>
</div>
{/* Expanded: the row is a click-to-focus field (the leading icon is
decorative). Collapsed: the icon is the rail's search control. */}
<div className={css.search} onClick={() => { if (!collapsed) searchInput.current?.focus() }}>
<button
type="button"
className={css.searchButton}
aria-label="Search sessions"
tabIndex={collapsed ? 0 : -1}
onClick={() => { if (collapsed) { setSearchOnExpand(true); onToggleSidebar() } }}
>
<IconSearchOutline16 size={14} />
</button>
<Tooltip label="Search" disabled={wide}>
<button
type="button"
className={css.searchButton}
aria-label="Search sessions"
tabIndex={collapsed ? 0 : -1}
onClick={() => { if (collapsed) { setSearchOnExpand(true); onToggleSidebar() } }}
>
<IconSearchOutline16 size={wide ? 14 : 18} />
</button>
</Tooltip>
{wide && (
<input
ref={searchInput}
@@ -245,7 +275,7 @@ export function SidebarRoot({ collapsed, useSessions, onOpen, onCreate, onToggle
</div>
<div className={css.foot} role="button" tabIndex={0} aria-label="Settings">
<IconSettingsOutline14 />
<IconSettingsOutline14 size={wide ? 14 : 18} />
{wide && <span className={clsx(css.footLabel, css.wide)}>Settings</span>}
</div>
</div>

View File

@@ -154,7 +154,7 @@ function sessionRow(g: Group, s: SessionSummary, depth: number, hasChildren: boo
type: 'session',
id: s.id,
groupKey: g.key,
title: s.title,
title: s.displayTitle,
depth,
hasChildren,
expanded,
@@ -183,7 +183,7 @@ function flattenVisible(g: Group, expandedSessions: ReadonlySet<string>, rows: S
function searchVisible(g: Group, q: string): Set<SessionId> {
const visible = new Set<SessionId>()
for (const m of g.summaries.values()) {
if (!m.title.toLowerCase().includes(q)) continue
if (!m.displayTitle.toLowerCase().includes(q)) continue
let cur: SessionSummary | undefined = m
while (cur !== undefined && !visible.has(cur.id)) {
visible.add(cur.id)
@@ -213,9 +213,9 @@ function flattenSearch(g: Group, visible: ReadonlySet<SessionId>, rows: SidebarR
*
* Normal mode: every project row shows; sessions show under expanded
* projects, descending only into expanded sessions. Search mode (non-blank
* query, case-insensitive title substring): expansion state is ignored —
* query, case-insensitive display-title substring): expansion state is ignored —
* matched sessions and their ancestor chains are forced visible, groups
* without a title or label hit are dropped, and a label-only hit keeps the
* without a display-title or label hit are dropped, and a label-only hit keeps the
* bare project row.
* @param list - sessions list snapshot.
* @param view - local expansion arrays and search query.

View File

@@ -23,7 +23,7 @@ async function bench() {
await ctx.plugin(SlotsService).await()
const list = createSnapshotStore<SessionListState>({
ids: [sid('a')],
byId: { [sid('a')]: { id: sid('a'), title: 'alpha', cwd: '/proj', running: false, updatedAt: 1 } },
byId: { [sid('a')]: { id: sid('a'), title: 'alpha', displayTitle: 'alpha', cwd: '/proj', running: false, updatedAt: 1 } },
current: undefined,
})
const sessions = { list, create: vi.fn(async () => sid('minted')), open: vi.fn() }

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