Merge remote-tracking branch 'origin/master' into feature/gui-first-run-welcome

# Conflicts:
#	packages/client/ui-settings-general/src/client/index.ts
#	packages/client/ui-settings-general/src/client/locales.ts
#	packages/client/ui-settings-general/tests/apply.spec.ts
#	packages/client/ui-settings/README.i18n.yaml
#	packages/client/ui-settings/README.md
#	packages/client/ui-settings/README.zh.md
#	packages/client/ui-settings/src/client/index.ts
#	packages/host/apiproxy/README.i18n.yaml
#	packages/host/apiproxy/src/api-proxy.ts
This commit is contained in:
NI0317
2026-07-31 12:48:39 +08:00
462 changed files with 12006 additions and 2484 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/connection/README.md
README.md: d2fda9f15125915594259e01e5b153609ceb21bb
README.zh.md: 669ae760693b4d98ee873ee5fe323554f58e7ca5
README.md: c8b7c4787cbcbf6a202fb944459a589fcadd7c8d
README.zh.md: 693420183ffa4fb20e1fecbff523a12261a45d45

View File

@@ -10,7 +10,7 @@ The node half guards every request under `/api` before bridging (`src/api-reques
## Keyless fixture
Any `fixture` query parameter selects the in-memory carrier. `fixture=empty` starts with no Workspace or Session; `fixturePrompt=reject` rejects prompts before acceptance; `fixtureAttach=fail` publishes a Session but rejects its Workspace attachment; `fixtureSessionCreate=drop-response` publishes and frames a Session before dropping the create response; and `fixtureFrames=workspace-first` reverses the default session-first create-frame order. Workspace creation by name/path and caller-preallocated SessionIds remain deterministic enough for assembled Web tests to reconcile list and frame arrival.
Any `fixture` query parameter selects the in-memory carrier. `fixture=empty` starts with no Workspace or Session; `fixturePrompt=reject` rejects prompts before acceptance; `fixtureAttach=fail` publishes a Session but rejects its Workspace attachment; `fixtureSessionCreate=drop-response` publishes and frames a Session before dropping the create response; and `fixtureFrames=workspace-first` reverses the default session-first create-frame order. Workspace creation by name/path and caller-preallocated SessionIds remain deterministic enough for assembled Web tests to reconcile list and frame arrival. Fixture content search preserves the production-facing `unicode61`-style case, diacritic, and token-phrase behavior and returns a match-centered snippet of at most 120 Unicode code points.
## Model Experience

View File

@@ -10,7 +10,7 @@ node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust
## 无密钥 fixture
任何 `fixture` 查询参数都会选择内存载体。`fixture=empty` 启动时不含 Workspace 或 Session`fixturePrompt=reject` 在接受前拒绝提示词;`fixtureAttach=fail` 发布 Session 但拒绝将其附加到 Workspace`fixtureSessionCreate=drop-response` 在丢弃创建响应前发布 Session 并为其发出帧;`fixtureFrames=workspace-first` 则反转默认的 Session 优先创建帧顺序。按名称/路径创建 Workspace 以及由调用方预先分配 SessionId均具有足够的确定性组装后的 Web 测试可以据此协调列表与帧的到达。
任何 `fixture` 查询参数都会选择内存载体。`fixture=empty` 启动时不含 Workspace 或 Session`fixturePrompt=reject` 在接受前拒绝提示词;`fixtureAttach=fail` 发布 Session 但拒绝将其附加到 Workspace`fixtureSessionCreate=drop-response` 在丢弃创建响应前发布 Session 并为其发出帧;`fixtureFrames=workspace-first` 则反转默认的 Session 优先创建帧顺序。按名称/路径创建 Workspace 以及由调用方预先分配 SessionId均具有足够的确定性组装后的 Web 测试可以据此协调列表与帧的到达。fixture 内容搜索会保留面向生产环境的 `unicode61` 式大小写、变音符号和 token短语行为并返回以匹配位置为中心、最多包含 120 个 Unicode 码点的 snippet。
## 模型体验

View File

@@ -1,12 +1,12 @@
// Central contract re-export point: every contract import inside
// web-runtime goes through this single file.
// Types are type-only imports from the apiproxy api/ layer (zero Node deps, browser-safe);
// the only runtime values are the RpcId constructor and the AbstractApiClient seam.
// Types and runtime protocol helpers/bounds come from the apiproxy api/ layer
// (zero Node deps, browser-safe); AbstractApiClient is the client seam.
// NEVER import the package root: it drags bootHost/cordis into the browser bundle.
// The ./api and ./client subpath exports are the browser-safe channels added for this.
export type {
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
DirectoryEntry, DirectoryListing,
WorkspaceApi, WorkspaceId, WorkspaceView,
@@ -25,7 +25,11 @@ export type {
// 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 {
RpcId,
SESSION_SEARCH_RESULT_LIMIT,
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'

View File

@@ -26,13 +26,14 @@ import type {
// Type-only: the brand constructor is host-side; the fixture casts at its
// wire-fabrication boundary (the schema layer's one-cast-point posture).
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import { foldSurface } from '@deepseek-ai/dsh-session/surface'
import type {
ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt,
ModelProviderGroup, ModelTarget, RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary,
ToolCallView, ToolEventView, ToolResultView, WorkspaceId, WorkspaceView,
} from './api.ts'
import type { RequestPayload, ResponseValue, RpcMethodMap } from '@deepseek-ai/dsh-host-apiproxy/api'
import { AbstractApiClient, RpcId } from './api.ts'
import { AbstractApiClient, RpcId, SESSION_SEARCH_RESULT_LIMIT } from './api.ts'
/** The fake carrier mints like a real one (business code never mints). */
function rpcRequest<P>(payload: P): RpcRequest<P> {
@@ -136,6 +137,44 @@ const TERMINAL_EXIT_STATUS: Record<string, { exitCode: number } | { signal: stri
[TERMINAL_OUTPUT_FIXTURE]: { exitCode: 1 },
}
/**
* The structured `web_search` result view for fixture turn 66, authored inline
* because this client-side fixture cannot import the web tool that projects it.
* The sources exercise the citation list's features: a titled source with a
* snippet and a date, a source with no title (its hostname labels the link) and
* a snippet but no date, and a source with a title and a date but no snippet.
* `truncated` marks the capped indicator. The shape is the contract's own
* search view minus its wire discriminants.
*/
const WEB_SEARCH_RESULT: Omit<Extract<ToolResultView, { card: 'web'; kind: 'search' }>, 'card' | 'kind'> = {
answer: 'DeepSeek Harness is a plugin-based agent harness on vendored Cordis where **every capability is a plugin**.',
sources: [
{
url: 'https://github.com/deepseek-ai/deepseek-harness',
title: 'DeepSeek Harness — plugin-based agent harness',
snippet: 'Everything is a plugin: session, tools, agent-loop, and LLM adapters all mount on the same Cordis context.',
publishedAt: '2026-07-01',
},
{
url: 'https://www.deepseek.com/blog/harness-architecture',
snippet: 'The capability-seam pattern splits each capability into interface, implementation, and consumer packages.',
},
{
url: 'https://docs.deepseek.com/harness/plugins',
title: 'Writing a harness plugin',
publishedAt: '2026-06-15',
},
],
truncated: true,
}
/** The `web_fetch` result view for fixture turn 67, authored inline for the same reason. */
const WEB_FETCH_RESULT: Omit<Extract<ToolResultView, { card: 'web'; kind: 'fetch' }>, 'card' | 'kind'> = {
url: 'https://www.deepseek.com/blog/harness-architecture',
statusCode: 200,
truncated: false,
}
const DEEPSEEK_REASONING = {
efforts: [
{ id: 'off', name: 'Off' },
@@ -325,8 +364,20 @@ function buildAlphaLog(): SessionEvent[] {
// strip empty and take the todo surfaces' own coverage with it.
toolTurn(65, 'bash', '{"command":"pnpm run check","cwd":"/tmp/fixture/deep/nested"}', TERMINAL_OUTPUT_FIXTURE)
// Turns 66-67: the web render intent — a web_search whose result view carries
// structured sources plus an answer (the citation list, one source lacking a
// title so its hostname labels the link, the capped indicator on), and a
// web_fetch whose result view carries the fetched URL and its HTTP status.
// Both keep a generic pending call view and add the `web` card only at
// result time, which is the contract's result-only web shape. Named after
// the real tools so they hit the keyed WebRow registration. Ordered BEFORE
// the todo turn for the same reason turn 65 is: the standing plan retires at
// the next turn/start, so a turn after it would empty the dock's plan strip.
toolTurn(66, 'web_search', '{"query":"deepseek harness architecture"}', 'Search results for deepseek harness architecture.')
toolTurn(67, 'web_fetch', '{"url":"https://www.deepseek.com/blog/harness-architecture"}', '# Harness architecture\n\nEverything is a plugin.')
const todoArgs = JSON.stringify({ todos: fixtureTodos })
toolTurn(66, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.')
toolTurn(68, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.')
// The real tool appends the snapshot mid-execution — between tool/call and
// tool/result — so the fixture reproduces that exact ordering (the last
// toolTurn events run ... tool/call, tool/result, step/end, turn/end).
@@ -365,6 +416,13 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined {
return { card: 'generic', title: `Edit ${str(args.file_path)}`, kind: 'edit', rawInput: args }
case 'write':
return { card: 'generic', title: `Write ${str(args.file_path)}`, kind: 'edit', rawInput: args }
// The web tools keep a GENERIC pending card and add the `web` result card
// only at result time (the contract's result-only web shape); their pending
// kind matches the result kind so a call and its result read as one category.
case 'web_search':
return { card: 'generic', title: `Search ${str(args.query)}`, kind: 'search', rawInput: args }
case 'web_fetch':
return { card: 'generic', title: `Fetch ${str(args.url)}`, kind: 'fetch', rawInput: args }
default:
return undefined // echo et al: the documented no-view fallback path
}
@@ -373,6 +431,17 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined {
function presentResult(name: string, argsRaw: string, resultText: string): ToolResultView | undefined {
const call = presentCall(name, argsRaw)
if (call === undefined) return undefined
// The web tools keep a generic pending card, so their result card is chosen
// by tool name rather than by the pending card tag: the structured `web` card
// the frontend consumes. The view carries no `content` copy (per the contract
// and the web-result-card note); a capability-less UI falls back to the raw
// `tool/result` content, which this fixture emits from `resultText`.
if (name === 'web_search') {
return { card: 'web', kind: 'search', ...WEB_SEARCH_RESULT }
}
if (name === 'web_fetch') {
return { card: 'web', kind: 'fetch', ...WEB_FETCH_RESULT }
}
switch (call.card) {
case 'terminal':
// The sample's own exit status, authored beside it: re-parsing the
@@ -575,6 +644,144 @@ function pageOf(
return { events, hasMore: start > 0 }
}
/** Fixture mirror of first-party message extraction used by session-query. */
function searchBlockText(block: ContentBlock): string[] {
switch (block.type) {
case 'text':
return [block.text]
case 'reasoning':
return []
case 'tool-call':
return [block.name, block.arguments]
case 'tool-result':
return block.content.flatMap(searchBlockText)
default:
return []
}
}
/** One current-surface user/assistant/steering document, if searchable. */
function searchEventText(event: SessionEvent): string {
const content = event.type === 'user/message'
? event.data.content
: event.type === 'assistant/message' || event.type === 'steering/message'
? event.data.message.content
: undefined
if (content === undefined) return ''
return content.flatMap(searchBlockText).map(part => part.trim()).filter(Boolean).join('\n')
}
interface FixtureSearchToken {
value: string
/** Inclusive code-point offset in the whitespace-normalized display text. */
start: number
/** Exclusive code-point offset in the whitespace-normalized display text. */
end: number
}
/**
* Browser-safe approximation of SQLite FTS5 unicode61 token boundaries.
* Keeping phrase matching token-based prevents the development fixture from
* promising arbitrary within-token substring behavior that production lacks.
*/
function searchTokenSpans(value: string): { text: string; tokens: FixtureSearchToken[] } {
const text = value.replace(/\s+/gu, ' ').trim()
const characters = Array.from(text)
const tokens: FixtureSearchToken[] = []
let start: number | undefined
let raw = ''
const flush = (end: number): void => {
if (start !== undefined) {
const folded = raw.normalize('NFD').replace(/\p{M}+/gu, '').toLowerCase()
if (folded !== '') tokens.push({ value: folded, start, end })
}
start = undefined
raw = ''
}
for (let index = 0; index < characters.length; index++) {
const character = characters[index] as string
const tokenBase = character.normalize('NFD').replace(/\p{M}+/gu, '')
if (tokenBase === '') {
if (start !== undefined) raw += character
continue
}
if (/^[\p{L}\p{N}\p{Co}]+$/u.test(tokenBase)) {
start ??= index
raw += character
} else {
flush(index)
}
}
flush(characters.length)
return { text, tokens }
}
interface FixturePhraseMatch {
count: number
start: number
end: number
}
/** Count exact contiguous token-phrase occurrences and retain the first display span. */
function phraseMatch(document: readonly FixtureSearchToken[], phrase: readonly string[]): FixturePhraseMatch {
if (phrase.length === 0 || phrase.length > document.length) return { count: 0, start: 0, end: 0 }
let count = 0
let firstStart = 0
let firstEnd = 0
for (let start = 0; start <= document.length - phrase.length; start++) {
if (!phrase.every((token, offset) => document[start + offset]?.value === token)) continue
count++
if (count === 1) {
firstStart = document[start]?.start ?? 0
firstEnd = document[start + phrase.length - 1]?.end ?? firstStart
}
}
return { count, start: firstStart, end: firstEnd }
}
/** Match-centered fixture excerpt, bounded by Unicode code points for the sidebar. */
function searchSnippet(value: string, matchStart: number, matchEnd: number): string {
const characters = Array.from(value)
if (characters.length <= 120) return value
const boundedStart = Math.min(Math.max(0, matchStart), characters.length - 1)
const boundedEnd = Math.min(
characters.length,
Math.max(boundedStart + 1, matchEnd),
)
const center = Math.floor((boundedStart + boundedEnd) / 2)
let start = Math.min(
characters.length - 118,
Math.max(0, center - Math.floor(118 / 2)),
)
let end = start + 118
if (start === 0) {
end = 119
} else if (end === characters.length) {
start = characters.length - 119
}
return `${start > 0 ? '…' : ''}${characters.slice(start, end).join('')}${end < characters.length ? '…' : ''}`
}
interface FixtureSearchCandidate {
sessionId: SessionId
seq: number
time: number
text: string
matchCount: number
matchStart: number
matchEnd: number
documentLength: number
}
/** Mirrors `packages/session-query/session-query-sqlite/src/index.ts`; update both together. */
function compareSearchCandidates(a: FixtureSearchCandidate, b: FixtureSearchCandidate): number {
if (a.matchCount !== b.matchCount) return b.matchCount - a.matchCount
if (a.documentLength !== b.documentLength) return a.documentLength - b.documentLength
if (a.time !== b.time) return b.time - a.time
if (a.sessionId !== b.sessionId) return a.sessionId < b.sessionId ? -1 : 1
return b.seq - a.seq
}
/**
* Current plan projection over the full log (host parallel: latest todo/write
* with no later turn/start; a new turn retires the previous plan).
@@ -919,6 +1126,8 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
let failNextHistory = false
/** Force-enders for currently open stream generators (timing hook: simulated connection loss). */
const streamBreakers = new Set<() => void>()
/** Retry scenarios opened by timing hooks and completed in a later browser assertion phase. */
const retryScenarios = new Map<SessionId, { turn: number; stepStarted: boolean }>()
// Timing-acceptance hooks (browser test backdoor): the in-memory fixture is ideally timed, which
// is exactly what masked the open-window and reconnect-gap bugs (audit S1/S3). These let
@@ -942,6 +1151,89 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
const messageSeqs = log.filter(event => event.type === 'user/message').map(event => event.seq)
append(sid(id), { type: 'session/title', data: { title, messageSeqs, source: { kind: 'provider', provider: 'fixture' } } })
},
/** Open one failed model step whose partial remains visible until llm/retry arrives. */
beginModelRetry(id: string): void {
const sessionId = sid(id)
const turn = nextTurn.get(sessionId) ?? 0
nextTurn.set(sessionId, turn + 1)
retryScenarios.set(sessionId, { turn, stepStarted: true })
setRunning(sessionId, true)
append(sessionId, { type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
append(sessionId, { type: 'user/message', surfaceOp: 'append', data: { content: text('请重试这个请求'), source: { kind: 'user' } } })
append(sessionId, { type: 'step/start', data: { turn, step: 1 } })
append(sessionId, { type: 'assistant/chunk', data: { turn, step: 1, chunk: { type: 'block-start', index: 0, blockType: 'text' } } })
append(sessionId, { type: 'assistant/chunk', data: { turn, step: 1, chunk: { type: 'text-delta', index: 0, text: '应撤回的半截回复' } } })
append(sessionId, { type: 'step/end', data: { turn, step: 1 } })
},
/** Record one retry decision, then open the next retry turn. */
scheduleModelRetry(id: string, retry = 1, delayMs = 450): void {
const sessionId = sid(id)
const scenario = retryScenarios.get(sessionId)
if (scenario === undefined) throw new Error(`fixture: no model retry scenario for ${id}`)
if (!scenario.stepStarted) {
append(sessionId, { type: 'step/start', data: { turn: scenario.turn, step: 1 } })
append(sessionId, { type: 'assistant/chunk', data: { turn: scenario.turn, step: 1, chunk: { type: 'block-start', index: 0, blockType: 'text' } } })
append(sessionId, { type: 'assistant/chunk', data: { turn: scenario.turn, step: 1, chunk: { type: 'text-delta', index: 0, text: `${String(retry)} 次应撤回的回复` } } })
append(sessionId, { type: 'step/end', data: { turn: scenario.turn, step: 1 } })
scenario.stepStarted = true
}
const failure = { code: 'TRANSPORT', message: '连接被重置' }
append(sessionId, {
type: 'llm/retry',
data: {
turn: scenario.turn, step: 1,
provider: 'fixture', mode: 'normal', policyKey: 'fixture-normal',
retry, maxRetries: 2, delayMs, failure,
},
})
append(sessionId, {
type: 'turn/end',
data: { turn: scenario.turn, reason: { kind: 'error', step: 1, failure } },
})
const next = nextTurn.get(sessionId) ?? scenario.turn + 1
nextTurn.set(sessionId, next + 1)
append(sessionId, { type: 'turn/start', data: { turn: next, trigger: { kind: 'retry' } } })
scenario.turn = next
scenario.stepStarted = false
},
/** Record one retry decision, then cancel its source turn before the retry starts. */
cancelModelRetryDuringBackoff(id: string, delayMs = 450): void {
const sessionId = sid(id)
const scenario = retryScenarios.get(sessionId)
if (scenario === undefined) throw new Error(`fixture: no model retry scenario for ${id}`)
const failure = { code: 'TRANSPORT', message: '连接被重置' }
append(sessionId, {
type: 'llm/retry',
data: {
turn: scenario.turn, step: 1,
provider: 'fixture', mode: 'normal', policyKey: 'fixture-normal',
retry: 1, maxRetries: 2, delayMs, failure,
},
})
append(sessionId, { type: 'turn/end', data: { turn: scenario.turn, reason: { kind: 'aborted' } } })
retryScenarios.delete(sessionId)
setRunning(sessionId, false)
},
/** Finish the timing-hook retry with a finalized response in the open retry turn. */
completeModelRetry(id: string): void {
const sessionId = sid(id)
const scenario = retryScenarios.get(sessionId)
if (scenario === undefined) throw new Error(`fixture: no model retry scenario for ${id}`)
retryScenarios.delete(sessionId)
append(sessionId, { type: 'step/start', data: { turn: scenario.turn, step: 1 } })
append(sessionId, {
type: 'assistant/message',
surfaceOp: 'append',
data: {
turn: scenario.turn,
step: 1,
message: assistantMessage(text('重试后的完整回复')),
},
})
append(sessionId, { type: 'step/end', data: { turn: scenario.turn, step: 1 } })
append(sessionId, { type: 'turn/end', data: { turn: scenario.turn, reason: { kind: 'completed' } } })
setRunning(sessionId, false)
},
/** 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))
@@ -987,6 +1279,45 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
return {
sessions: {
list: request => ok(request, { items: [...sessions].sort((a, b) => b.updatedAt - a.updatedAt) }),
search: (request, signal) => {
if (signal.aborted) {
return err(request, {
code: 'cancelled',
message: 'fixture session search was aborted',
details: {},
})
}
const query = searchTokenSpans(request.payload.query).tokens.map(token => token.value)
const matches = sessions.flatMap((summary) => {
const log = logs.get(summary.sessionId) ?? []
const current = new Set(foldSurface(log).nodes)
const best = log.flatMap((event): FixtureSearchCandidate[] => {
if (!current.has(event.seq)) return []
const eventText = searchEventText(event)
const document = searchTokenSpans(eventText)
const match = phraseMatch(document.tokens, query)
if (match.count === 0) return []
return [{
sessionId: summary.sessionId,
seq: event.seq,
time: event.time,
text: document.text,
matchCount: match.count,
matchStart: match.start,
matchEnd: match.end,
documentLength: Array.from(eventText).length,
}]
}).sort(compareSearchCandidates)[0]
return best === undefined ? [] : [best]
}).sort(compareSearchCandidates)
return ok(request, {
items: matches.slice(0, SESSION_SEARCH_RESULT_LIMIT).map(match => ({
sessionId: match.sessionId,
snippet: searchSnippet(match.text, match.matchStart, match.matchEnd),
})),
hasMore: matches.length > SESSION_SEARCH_RESULT_LIMIT,
})
},
create: async (request) => {
const workspace = request.payload.workspaceId === undefined
? undefined
@@ -1691,20 +2022,30 @@ export class FixtureApiClient extends AbstractApiClient {
protected override async callUnary<K extends keyof RpcMethodMap>(
method: K,
payload: RequestPayload<K>,
signal?: AbortSignal,
): Promise<RpcResponse<ResponseValue<K>>> {
const request = rpcRequest(payload)
const full: ClientRequest = { type: 'client-request', rpcId: request.rpcId, method, payload }
this.onEnvelope(full)
const response = await this.dispatch(method, request as RpcRequest<never>) as RpcResponse<ResponseValue<K>>
const response = await this.dispatch(
method,
request as RpcRequest<never>,
signal ?? new AbortController().signal,
) as RpcResponse<ResponseValue<K>>
const fullResponse: ServerResponse = { type: 'server-response', rpcId: response.rpcId, result: response.result }
this.onEnvelope(fullResponse)
return response
}
/** Method-key dispatch into the in-memory contract impl (a real carrier routes by URL path instead). */
private dispatch(method: keyof RpcMethodMap, request: RpcRequest<never>): Promise<RpcResponse<unknown>> {
private dispatch(
method: keyof RpcMethodMap,
request: RpcRequest<never>,
signal: AbortSignal,
): Promise<RpcResponse<unknown>> {
switch (method) {
case 'session.list': return this.api.sessions.list(request)
case 'session.search': return this.api.sessions.search(request, signal)
case 'session.create': return this.api.sessions.create(request)
case 'session.history': return this.api.sessions.history(request)
case 'session.models': return this.api.sessions.models(request)
@@ -1725,8 +2066,7 @@ export class FixtureApiClient extends AbstractApiClient {
case 'workspace.delete': return this.api.workspace.delete(request)
case 'workspace.insertSessionBefore': return this.api.workspace.insertSessionBefore(request)
case 'command.list': return this.api.commands.list(request)
// The in-memory execute never blocks, so a never-aborting signal is faithful here.
case 'command.execute': return this.api.commands.execute(request, new AbortController().signal)
case 'command.execute': return this.api.commands.execute(request, signal)
case 'skill.list': return this.api.skills.list(request)
case 'goal.create': return this.api.goals.create(request)
case 'goal.edit': return this.api.goals.edit(request)

View File

@@ -11,7 +11,7 @@ import { WebApiClient } from './web-api-client.ts'
// ---- Contract re-exports (browser-safe apiproxy channels + core types) ----
export type {
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
DirectoryEntry, DirectoryListing,
ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
@@ -25,7 +25,11 @@ export type {
SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView,
CredentialsApi, CredentialView, ConfigurableProviderView, LlmApi,
} from './api.ts'
export { RpcId, AbstractApiClient, transportError } from './api.ts'
export {
RpcId,
AbstractApiClient,
transportError,
} from './api.ts'
// Connection loop types are public through ConnectionHandle.start; the
// controller remains package-internal.

View File

@@ -4,7 +4,7 @@
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type {
CommandDescriptor, HostFrame, IApiClient, ModelTarget, MuxFrame,
RpcRequest, RpcResponse, SessionId, SessionModels, SkillEntry,
RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry,
} from '../src/client/api.ts'
import { RpcId } from '../src/client/api.ts'
@@ -44,6 +44,8 @@ export class FakeApiClient implements IApiClient {
// Programmable slots (defaults answer OK-empty); reassign per case.
onList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
onSearch: (payload: unknown) => Promise<RpcResponse<{ items: SessionSearchItem[]; hasMore: boolean }>> =
() => Promise.resolve(ok({ items: [], hasMore: false }))
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
onRename: (payload: unknown) => Promise<RpcResponse<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
onFork: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId }))
@@ -87,12 +89,17 @@ export class FakeApiClient implements IApiClient {
private readonly muxConns: StreamConn<MuxFrame>[] = []
private readonly hostConns: StreamConn<HostFrame>[] = []
lastSearchSignal: AbortSignal | undefined
// Parameter annotations below are local structural types on purpose: the CI
// lint lane runs without built artifacts, where IApiClient's wire types
// (apiproxy subpath) resolve to any and inferred params trip no-unsafe-argument.
readonly sessions: IApiClient['sessions'] = {
list: (payload: unknown) => this.record('session.list', payload, this.onList(payload)),
search: (payload: unknown, signal?: AbortSignal) => {
this.lastSearchSignal = signal
return this.record('session.search', payload, this.onSearch(payload))
},
create: (payload: unknown) => this.record('session.create', payload, this.onCreate(payload)),
history: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) =>
this.record('session.history', payload, this.onHistory(payload)),

View File

@@ -19,6 +19,10 @@ interface TimingHooks {
failNextHistory(): void
appendUser(id: string, msg: string): void
appendTitle(id: string, title: string): void
beginModelRetry(id: string): void
scheduleModelRetry(id: string, retry?: number, delayMs?: number): void
cancelModelRetryDuringBackoff(id: string, delayMs?: number): void
completeModelRetry(id: string): void
appendSilent(id: string, msg: string): void
breakStreams(): void
}
@@ -48,6 +52,59 @@ describe('createFixtureApi', () => {
expect(response.result.value.items[1]?.parentSessionId).toBe('fx-alpha') // lineage material
})
it('searches current message text with literal unicode61-style token phrases', async () => {
const api = createFixtureApi()
const signal = new AbortController().signal
const phrase = await api.sessions.search(req({ query: 'FIXTURE 历史消息' }), signal)
expect(phrase.result).toMatchObject({
ok: true,
value: {
items: [{ sessionId: 'fx-alpha' }],
hasMore: false,
},
})
if (!phrase.result.ok) throw new Error('search failed')
expect(phrase.result.value.items[0]?.snippet).toContain('fixture 历史消息')
timing().appendUser(
'fx-alpha',
`${'leading context '.repeat(20)}late café token${' trailing context'.repeat(20)}`,
)
const late = await api.sessions.search(req({ query: 'LATE CAFE TOKEN' }), signal)
if (!late.result.ok) throw new Error('late search failed')
const lateSnippet = late.result.value.items[0]?.snippet ?? ''
expect(lateSnippet).toContain('late café token')
expect(lateSnippet.startsWith('…')).toBe(true)
expect(lateSnippet.endsWith('…')).toBe(true)
expect(Array.from(lateSnippet).length).toBeLessThanOrEqual(120)
timing().appendUser('fx-alpha', 'Greek final sigma: ος')
const finalSigma = await api.sessions.search(req({ query: 'ΟΣ' }), signal)
if (!finalSigma.result.ok) throw new Error('final sigma search failed')
expect(finalSigma.result.value.items[0]?.snippet).toContain('ος')
const substring = await api.sessions.search(req({ query: 'ixtur' }), signal)
expect(substring.result).toEqual({
ok: true,
value: { items: [], hasMore: false },
})
const punctuationOnly = await api.sessions.search(req({ query: '*' }), signal)
expect(punctuationOnly.result).toEqual({
ok: true,
value: { items: [], hasMore: false },
})
const reasoningOnly = await api.sessions.search(req({ query: '思考过程' }), signal)
expect(reasoningOnly.result).toEqual({
ok: true,
value: { items: [], hasMore: false },
})
const aborted = new AbortController()
aborted.abort()
await expect(api.sessions.search(req({ query: 'fixture' }), aborted.signal))
.resolves.toMatchObject({ result: { ok: false, error: { code: 'cancelled' } } })
})
it('pages history backwards on message-boundary cuts with seq-contiguous stitching', async () => {
const api = createFixtureApi()
const tail = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 10 }))
@@ -761,8 +818,18 @@ describe('createFixtureApi', () => {
hooks.appendSilent('fx-alpha', '静默丢帧')
hooks.appendUser('fx-alpha', '正常直播')
hooks.appendTitle('fx-alpha', 'Fixture 修订标题')
hooks.beginModelRetry('fx-alpha')
hooks.scheduleModelRetry('fx-alpha')
hooks.completeModelRetry('fx-alpha')
hooks.beginModelRetry('fx-alpha')
hooks.cancelModelRetryDuringBackoff('fx-alpha')
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/event' && (f.event as { type: string }).type === 'llm/retry')).toBe(true)
expect(seen.some(f => f.type === 'session/event' && JSON.stringify(f.event.data).includes('重试后的完整回复'))).toBe(true)
expect(seen.some(f => f.type === 'session/event'
&& f.event.type === 'turn/end'
&& f.event.data.reason.kind === 'aborted')).toBe(true)
expect(seen.some(f => f.type === 'session/projection' && f.key === 'title' && f.value === 'Fixture 修订标题')).toBe(true)
})
expect(seen.some(f => f.type === 'session/event' && JSON.stringify(f.event.data).includes('静默丢帧'))).toBe(false)
@@ -819,6 +886,10 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => {
it('covers the whole unary dispatch table', async () => {
const client = new FixtureApiClient()
expect((await client.sessions.search(
{ query: 'fixture' },
new AbortController().signal,
)).result.ok).toBe(true)
const created = await client.sessions.create({})
if (!created.result.ok) throw new Error('create failed')
const id = created.result.value.sessionId

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/modules/README.md
README.md: efba9e2eb0b148677fc7ac18bfad6333fb6f80da
README.zh.md: b057bfdd8c0a269252496d0c6a0fc4184932fd72
README.md: 99565b349d782c58752ac3e73ce7c0be527f78a8
README.zh.md: a8ed0a4949ccefce53933b4f2fb8f51f5291684f

View File

@@ -8,6 +8,8 @@ Lazy CJS model (web2): executing a plugin bundle only REGISTERS its factory (`wi
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).
The Node half scans enabled Loader entries for web `dshClient` packages, resolves each `exports["./client"]`, hashes the built bundle into the boot graph, and serves it under `/plugins`. Source launch maps host imports to TypeScript source but still consumes this built client export; missing files share one build instruction followed by a package/path list, while unrelated filesystem errors remain separate failures.
## Model Experience
None, as the module loader is browser-side kernel machinery; nothing here reaches a model request.

View File

@@ -8,6 +8,8 @@
解析分支顺序(`import(specifier)`):平台种子词 → 外壳实例;记忆化记录 → 表层;外壳自身的静态注册表(`registerStatic`app-shell→ 模块;已注册 factory → 物化;模块图记录(`window.__DSH_BOOT__`)→ 抓取 + 执行 + 物化;其他情况一律抛出异常。这是构建时组合包纯度门禁的运行时镜像。交给 factory 的同步 `require` 采用相同顺序,但不含抓取分支,并把观察到的边记录到模块记录中。`prefetch` 是第一阶段加载钩子(抓取 + 执行,只注册;并发调用共享一个进行中的任务);`invalidate` 会丢弃 factory 与物化记录,使下一次 prefetch/import 重新抓取;它是 HMR热模块替换钩子。
Node 侧会扫描已启用的 Loader 配置项以发现 web `dshClient` 包,解析每个 `exports["./client"]`,把构建后的组合包哈希写入启动图,并通过 `/plugins` 提供该文件。源码启动会把宿主侧导入映射到 TypeScript 源码,但仍消费客户端导出的构建产物;缺失文件共享一条构建要求,随后以 package/path list 列出各项,而无关的文件系统错误仍是独立故障。
## 模型体验
无。模块 loader 属于浏览器侧内核机制;这里没有任何内容进入模型请求。

View File

@@ -58,6 +58,47 @@ interface PkgMeta {
immediately: boolean
}
/** Recovery instruction shared by grouped startup and steady-state bundle diagnostics. */
const CLIENT_BUNDLE_BUILD_INSTRUCTION = 'run `pnpm run build` before launch'
/** Missing built client export, retained as structured data for activation-error grouping. */
class MissingClientBundleError extends Error {
constructor(
readonly packageName: string,
readonly clientPath: string,
cause: unknown,
) {
super(
[
`client-modules: client bundle not found; ${CLIENT_BUNDLE_BUILD_INSTRUCTION}:`,
` package: ${packageName}`,
` path: ${clientPath}`,
].join('\n'),
{ cause },
)
}
}
/** Activation failures grouped by actionable package-build errors and unrelated failures. */
class ClientPackageCompositionError extends AggregateError {
constructor(failures: Error[]) {
const missingBundles = failures.filter((error): error is MissingClientBundleError => error instanceof MissingClientBundleError)
const otherFailures = failures.filter(error => !(error instanceof MissingClientBundleError))
const packageNoun = failures.length === 1 ? 'package' : 'packages'
const lines = [`client-modules: ${String(failures.length)} client ${packageNoun} failed to compose:`]
if (missingBundles.length > 0) {
lines.push(` client bundles not found; ${CLIENT_BUNDLE_BUILD_INSTRUCTION}:`)
for (const error of missingBundles) {
lines.push(` - package: ${error.packageName}`, ` path: ${error.clientPath}`)
}
}
if (otherFailures.length > 0) {
lines.push(' other failures:', ...otherFailures.map(error => ` - ${error.message}`))
}
super(failures, lines.join('\n'))
}
}
/** One composed table row: the wire entry plus its bundle path. */
interface WebPluginRecord {
entry: WebBootEntry
@@ -138,7 +179,7 @@ export function injectBootManifest(html: string, graph: WebBootGraph): string {
* + bundle route + index tap. Construction runs the activation scan
* synchronously — a malformed declaration or missing bundle among the
* already-loaded entries aggregates into one loud throw (FAILED fiber; the
* boot sweep reports it).
* boot activation audit reports it).
*/
export class ClientModuleHostService extends Service {
static inject = ['httpServer', 'loader']
@@ -194,10 +235,7 @@ export class ClientModuleHostService extends Service {
const failures: Error[] = []
this.flush(err => failures.push(err))
if (failures.length > 0) {
throw new AggregateError(
failures,
`client-modules: ${String(failures.length)} client package(s) failed to compose:\n${failures.map(e => ` - ${e.message}`).join('\n')}`,
)
throw new ClientPackageCompositionError(failures)
}
ctx.effect(
@@ -322,6 +360,22 @@ export class ClientModuleHostService extends Service {
return meta
}
/**
* Read the activation-time bundle revision.
* @param pkgName - package that declares the client bundle.
* @param clientPath - absolute path of the built client artifact.
* @returns the bundle content's short hash for use as its revision.
* @throws {MissingClientBundleError} when the read fails with `ENOENT`; other filesystem errors are rethrown unchanged.
*/
private initialBundleRevision(pkgName: string, clientPath: string): string {
try {
return shortHash(readFileSync(clientPath))
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
throw new MissingClientBundleError(pkgName, clientPath, error)
}
}
/** Reconcile one entry name against the live loader entries. @returns whether the table changed. */
private processOne(entryName: string): boolean {
let qualifies = false
@@ -337,7 +391,7 @@ export class ClientModuleHostService extends Service {
if (meta === null) return false
// The rev rides the row from here on: a fiber restart reuses the row (and
// its rev) untouched; only rebuilt() re-reads the bundle.
const rev = shortHash(readFileSync(meta.clientPath))
const rev = this.initialBundleRevision(entryName, meta.clientPath)
this.table.set(entryName, { entry: graphRow(entryName, rev, meta.inject, meta.immediately), clientPath: meta.clientPath })
return true
}

View File

@@ -0,0 +1,87 @@
/** Node-half composition diagnostics for package metadata and built client bundles. */
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { Context } from 'cordis'
import { afterEach, describe, expect, it } from 'vitest'
import type { HttpServerService } from '@deepseek-ai/dsh-host-webserver'
import { ClientModuleHostService } from '../src/index.ts'
let root: string | undefined
afterEach(() => {
if (root !== undefined) rmSync(root, { recursive: true, force: true })
root = undefined
})
/** Create a resolvable dshClient package whose client export points at the returned path. */
function writePackage(packageName: string): string {
root ??= realpathSync(mkdtempSync(join(tmpdir(), 'dsh-client-modules-')))
const pkgRoot = join(root, 'node_modules', ...packageName.split('/'))
const clientPath = join(pkgRoot, 'lib', 'client.js')
mkdirSync(pkgRoot, { recursive: true })
writeFileSync(join(pkgRoot, 'package.json'), JSON.stringify({
name: packageName,
exports: {
'./client': './lib/client.js',
'./package.json': './package.json',
},
dshClient: { platform: 'web' },
}))
return clientPath
}
/** Construct the node-half service over the enabled fixture entries. */
function construct(packageNames: string[]): ClientModuleHostService {
const ctx = new Context()
ctx.baseUrl = pathToFileURL(root!).href + '/'
ctx.provide('loader', {
*entries() {
for (const packageName of packageNames) {
yield { options: { name: packageName }, fiber: {}, disabled: false }
}
},
})
const httpServer: Pick<HttpServerService, 'port' | 'register' | 'tapIndex'> = {
port: 0,
register: () => () => {},
tapIndex: () => () => {},
}
ctx.provide('httpServer', httpServer as HttpServerService)
return new ClientModuleHostService(ctx)
}
describe('client bundle activation', () => {
it('groups missing bundles under one source-build instruction with a package/path list', () => {
const firstName = '@fixture/missing-first'
const secondName = '@fixture/missing-second'
const firstPath = writePackage(firstName)
const secondPath = writePackage(secondName)
expect(() => construct([firstName, secondName])).toThrow([
'client-modules: 2 client packages failed to compose:',
' client bundles not found; run `pnpm run build` before launch:',
` - package: ${firstName}`,
` path: ${firstPath}`,
` - package: ${secondName}`,
` path: ${secondPath}`,
].join('\n'))
})
it('does not report other bundle read failures as missing builds', () => {
const packageName = '@fixture/unreadable-client'
const clientPath = writePackage(packageName)
mkdirSync(clientPath, { recursive: true })
let thrown: unknown
try {
construct([packageName])
} catch (error) {
thrown = error
}
expect(String(thrown)).toContain('client-modules: 1 client package failed to compose:')
expect(String(thrown)).toContain(' other failures:')
expect(String(thrown)).toContain('EISDIR')
expect(String(thrown)).not.toContain('pnpm run build')
})
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
README.md: a116a5e4ad3070f20e6d90490f2507c1e2369c37
README.zh.md: f375811e6f1480d6636fe4eb77746b76d6414b1e
README.md: 9f2b165f1a98dcecfa3ab82386da9b094cfd2f54
README.zh.md: 3ed047e65d3bddc14c3b6b84f327bbeebf805d4b

View File

@@ -12,6 +12,8 @@ Workspace and Session lists have independent monotone `pending` → `ready` base
SlotsService gives the renderer separate bare observables for `useSessions` and `useWorkspaces`; web-react creates the hooks. Workspace business state does not enter `SessionListState` or an entry store.
`SessionsService.search(query, signal)` is a stateless one-shot action over the `session.search` RPC. It returns ranked session/snippet pairs without putting query, loading, or error state into the shared Session list, so each UI owner controls debounce, cancellation, stale-response suppression, and fallback presentation. `searchResultLimit` re-exposes `SESSION_SEARCH_RESULT_LIMIT` — the bound the response schema itself enforces — as injected presentation data, so client plugins do not duplicate it. It is a protocol constant rather than per-connection state, so the connection handle does not carry it.
## New Session and the blank mirror
`WorkspacesService.connectWorkspace(workspaceId)` resolves the session a New Session flow lands in: it reuses the workspace's existing blank session from the list mirror (`blank && cwd == workspace.path`) or calls `session.create({workspaceId})`, returning the session id for the caller to open. `SessionSummary.blank` mirrors the host's derived empty-log bit and only ever lowers on the client: seeded by `session.list` / the `host/session-added` frame, flipped false by the first ACCEPTED local `prompt()` (on the RPC success response — acceptance proves the user message is in the host log; a rejected first prompt keeps the session blank and reusable) and by any `running: true` status frame, re-aligned by every list re-pull. List surfaces hide blank rows; the store carries every row. `SessionsService.create` accepts an optional caller-preallocated SessionId and throws `SessionCreateError` (carrying `requestedSessionId`) on failure.
@@ -28,6 +30,10 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
`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. `ISession.rename` settles the `title` projection cell directly from the unary response's `{title, seq}` under the same higher-seq-wins rule — the list row and every `useProjection('title')` reader update ahead of the push frame, whose later replay of the same seq is a no-op.
## Model retry projection
The Session object validates plugin-owned, provider-routed `llm/retry` payloads at the event wire boundary against the producer's complete field contract, including timer, integer, status, provider-delay, and non-empty diagnostic bounds. A valid event removes the matching failed step's streaming partial and inserts a durable retry notice at the event's sequence position. The notice is `scheduled` until a following retry turn starts; an aborted or disposed source turn marks it `cancelled`, while the retry turn marks it `started`. Normal-mode notices carry their finite maximum; always-mode notices remain explicitly unbounded. Window rebuild and history replay apply the same projection, so logged chunks from the discarded attempt never reappear as an interrupted reply after refresh. A terminal turn without `llm/retry` retains the existing behavior: visible unfinalized output is frozen as an interrupted assistant node.
## Session forking
`ISessions.fork({sessionId, atSeq?, increaseTitle?})` resolves only after the child summary is locally addressable, carrying source lineage and cwd with `blank: false`; callers choose whether to open it. With `increaseTitle: true`, the client renames the child from the source session's persisted title: a trailing `(N)` or `N` is incremented without changing bracket style, while any other title gets ` (1)` appended; the rename is skipped when the source has no persisted title, and a rename failure rejects the promise but leaves the created child in place. This option is not sent in the Host fork request. A `workspace-attach-failed` response still identifies a child already published by the Host, so `SessionManager` reconciles that partial success before `SessionForkError` reaches the caller instead of making a retry create a duplicate child.

View File

@@ -12,6 +12,8 @@ Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线
SlotsService 分别为 renderer 提供 `useSessions``useWorkspaces` 的裸 observableweb-react 创建钩子。Workspace 业务状态不会进入 `SessionListState` 或配置项 store。
`SessionsService.search(query, signal)` 是基于 `session.search` RPC 的无状态单次操作。它返回经过排序的会话snippet 对,但不会将查询条件、加载状态或错误状态写入共享 Session 列表,因此每个 UI 所有者都自行负责防抖、取消、抑制陈旧响应和回退呈现。`searchResultLimit``SESSION_SEARCH_RESULT_LIMIT`——即响应 schema 自身强制执行的上限——作为注入的呈现数据重新公开,使客户端插件无需复制该值。它是协议常量而非逐连接状态,因此连接 handle 不携带它。
## New Session 与 blank 镜像
`WorkspacesService.connectWorkspace(workspaceId)` 解析 New Session 流程最终落入的会话:先在列表镜像中复用该 workspace 的既有空会话(`blank && cwd == workspace.path`),未命中则调用 `session.create({workspaceId})`,返回会话 id 由调用方 open。`SessionSummary.blank` 镜像主机派生的空日志位,在客户端只降不升:由 `session.list``host/session-added` 帧播种,本地首次获 Host 接受的 `prompt()`RPC 成功响应时——受理即证明用户消息已入主机日志;首讯被拒则会话保持 blank、保持可复用与任何 `running: true` 状态帧翻为 false每次列表重拉重新对齐。列表界面隐藏 blank 行store 保留全部行。`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId失败时抛出 `SessionCreateError`(携带 `requestedSessionId`)。
@@ -28,6 +30,10 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
`SessionManager` 独立于列表和 Session 实例到达情况,保留最近一次通过验证的 `session/title` 控制快照。seq 更高的事件会替换旧快照,标题时间戳计入列表新近程度;订阅基线会先丢弃 seq 超过其 `lastSeq` 的任何已保留标题,再接收可选的折叠标题。显式移除 Session 也会清除已保留标题。因此,面向客户端的 `SessionSummary.title` 只包含实际的持久化标题;`displayTitle` 始终存在,并依次回退到 cwd basename 和 Session id。冷态持久化会话会保持该回退值直到打开或恢复会话促使主机折叠并投影由日志支撑的标题。`ISession.rename` 用 unary 响应中的 `{title, seq}` 直接结算 `title` 投影格,遵循同一 seq 高者胜规则——列表行和所有 `useProjection('title')` 读者在推送帧到达前即更新;推送帧随后重放同一 seq 时为无操作。
## 模型重试投影
Session 对象会在事件 wire 边界依据生产方的完整字段契约,验证由插件负责、按提供方路由的 `llm/retry` 载荷,包括计时器、整数、状态、提供方延迟和非空诊断字段的边界。有效事件会移除对应失败步骤的流式输出片段,并在该事件的序列位置插入一条持久的重试提示。该提示在后续重试轮次开始前为 `scheduled`;源轮次中止或释放会将其标记为 `cancelled`,重试轮次则会将其标记为 `started`。normal mode 提示携带其有限上限always mode 提示则保持显式无界。窗口重建与历史回放应用相同的投影,因此刷新后,来自已丢弃尝试的日志分片绝不会重新显示为中断回复。没有 `llm/retry` 的终止轮次保留现有行为:可见但尚未定稿的输出会冻结为中断的 assistant 节点。
## 会话 fork
`ISessions.fork({sessionId, atSeq?, increaseTitle?})` 只在子会话摘要已能在本地寻址后才完成;该摘要携带源会话的谱系和 cwd`blank: false`,由调用方决定是否打开。`increaseTitle: true` 会在 client 端把源会话的持久化标题改名到子会话:尾部 `(N)``N` 递增并保留括号样式,其余标题追加 ` (1)`;源会话没有持久化标题时跳过改名,改名失败时拒绝 promise 但保留已创建的子会话。该选项不会进入 Host fork 请求。即使响应为 `workspace-attach-failed`,其中仍会标识 Host 已发布的子会话,因此 `SessionManager` 会先将这一部分成功对账,再让 `SessionForkError` 到达调用方,避免重试创建重复的子会话。

View File

@@ -36,6 +36,7 @@
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
@@ -49,6 +50,7 @@
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"@types/react": "~18.3.1",
"cordis": "^4.0.0-rc.7"
},

View File

@@ -8,8 +8,9 @@
* explicit act of widening what features may do to the sessions domain.
*/
import type { Context } from 'cordis'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { RpcResult, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { HostObservable, SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionSearchResultItem } from '../sessions/manager.ts'
import type {
SessionBinding, SessionListState, SessionProvideDescriptor,
} from '../sessions/service.ts'
@@ -22,6 +23,12 @@ export interface ISessions {
readonly list: ObservableSnapshot<SessionListState>
/** Atomic current-session provide projection (the renderer host's `sessions.provideInfo` feed). */
readonly currentProvideInfo: HostObservable<SessionMaybeProvideInfo>
/**
* The `session.search` result bound the wire schema fixes, exposed to
* presentation as injected data. Not per-connection state: every transport
* (fixture included) reports the same number.
*/
readonly searchResultLimit: number
/**
* Select a session as current.
* @param id - session id (must exist in the list; unknown ids fail loud).
@@ -29,6 +36,17 @@ export interface ISessions {
open(id: SessionId): void
/** Clear the current selection into the no-session view state. */
clear(): void
/**
* Search the Host's visible message-content index. Results stay
* request-local; the list snapshot remains the metadata authority.
* @param query - non-blank literal phrase.
* @param signal - cancellation for a superseded search.
* @returns bounded results, or a business/transport error.
*/
search(
query: string,
signal: AbortSignal,
): Promise<RpcResult<{ items: SessionSearchResultItem[]; hasMore: boolean }>>
/**
* Fork a session from a completed-turn prefix of the source; on resolution
* the child is in the list store and `open()` can target it.

View File

@@ -31,7 +31,7 @@ export type { IWorkspaces } from './contract/workspaces.ts'
export type {
SessionBinding, SessionListState, SessionProvideContribution, SessionProvideDescriptor, SessionSummary,
} from './sessions/service.ts'
export type { SessionListPhase } from './sessions/manager.ts'
export type { SessionListPhase, SessionSearchResultItem } from './sessions/manager.ts'
export type { WorkspaceListPhase } from './workspaces/manager.ts'
export type { WorkspaceListState } from './workspaces/service.ts'
export type {
@@ -45,7 +45,7 @@ export type {
export type {
AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig,
AssistantTiming, CodeSubCall, CommandNode, ComposerPhase, ContextMessageNode, ConversationNode,
ConversationSnapshot, QueuedMessage, RunningToolCall,
ConversationSnapshot, ModelRetryNode, QueuedMessage, RunningToolCall,
SteeringMessageNode, TodoItem, ToolResultNode, UnknownSurfaceNode, UserMessageNode,
} from './sessions/conversation.ts'
export type {

View File

@@ -5,6 +5,7 @@
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types'
import type { TodoItem } from '@deepseek-ai/dsh-session/types'
import type {
InboxItemId, RpcError, SessionId, ToolCallView, ToolResultView,
@@ -121,6 +122,19 @@ export interface ContextMessageNode {
source: unknown
}
/** Durable notice that a closed failed step is waiting for a model-request retry. */
export type ModelRetryNode = LlmRetryEventData & {
kind: 'model-retry'
seq: number
/** Unix epoch ms from the llm/retry session event. */
time: number
/**
* Client-derived lifecycle: scheduled until a retry turn starts, started
* once it does, or cancelled when the failed turn aborts first.
*/
retryState: 'scheduled' | 'started' | 'cancelled'
}
/** A tool result paired (when in-window) with its call head. */
export interface ToolResultNode {
kind: 'tool-result'
@@ -183,6 +197,7 @@ export type ConversationNode =
| AssistantMessageNode
| SteeringMessageNode
| ContextMessageNode
| ModelRetryNode
| ToolResultNode
| CommandNode
| UnknownSurfaceNode
@@ -265,7 +280,7 @@ export interface PromptError {
/** The immutable snapshot contract Session hands to uSES (see the web client architecture RFC). */
export interface ConversationSnapshot {
sessionId: SessionId
/** Surface fold product (finalized conversation nodes in surface order). */
/** Finalized surface events and durable operational notices in event order. */
nodes: readonly ConversationNode[]
/** Fold degradation flag (cross-window replace defense): when true, nodes come from the lenient linear scan. */
foldDegraded: boolean

View File

@@ -2,7 +2,10 @@
// dispatch entry + list state, constructed and held by SessionsService (one per client runtime).
// List data never enters zustand; React connects via subscribe/getListSnapshot.
import type { IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId, SessionSummary, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client'
import type {
IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId,
SessionSummary, WorkspaceId,
} 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'
@@ -27,6 +30,12 @@ import { Session } from './session.ts'
*/
export type SessionListPhase = 'pending' | 'ready'
/** Request-local content hit returned to sidebar search consumers. */
export interface SessionSearchResultItem {
sessionId: SessionId
snippet: string
}
/** Immutable session-list snapshot for useSessionList. */
export interface SessionListSnapshot {
items: readonly SessionListEntry[]
@@ -248,6 +257,24 @@ export class SessionManager {
return this.listInflight
}
/**
* Search visible session message content without adding transient query
* state to the list snapshot.
* @param query - non-blank literal phrase.
* @param signal - cancellation for superseded UI queries.
* @returns the Host result or a folded transport error.
*/
async search(
query: string,
signal: AbortSignal,
): Promise<RpcResult<{ items: SessionSearchResultItem[]; hasMore: boolean }>> {
try {
return (await this.api.sessions.search({ query }, signal)).result
} catch (error: unknown) {
return transportError(error)
}
}
/**
* Contract session.create; on success merge into summaries immediately (no
* wait for the next refresh). A created session is blank by definition

View File

@@ -16,7 +16,12 @@
* survives frozen (read-only view) until the stage moves on.
*/
import type { Context, Fiber } from 'cordis'
import type { IApiClient, RpcError, SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client'
import type {
IApiClient, RpcError, RpcResult, SessionId, WorkspaceId,
} 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 { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api'
import type {
HostObservable, SessionMaybeProvideInfo, SessionProvideInfo,
} from '@deepseek-ai/dsh-client-ui-slots'
@@ -26,7 +31,7 @@ import type { SessionFace } from '../contract/session.ts'
import type { ISessions } from '../contract/sessions.ts'
import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts'
import { SessionManager } from './manager.ts'
import type { SessionListPhase } from './manager.ts'
import type { SessionListPhase, SessionSearchResultItem } from './manager.ts'
import { SessionProvideChannel } from './provide.ts'
import type { Session } from './session.ts'
@@ -189,6 +194,13 @@ export interface SessionProvideDescriptor {
/** Root sessions service: list store, current selection, object-layer manager, scope tree, bindings, ancestry. */
export class SessionsService implements ISessions {
/**
* The wire schema's own result bound, re-exposed for presentation plugins as
* injected data. Not per-connection state: the `session.search` response
* schema caps `items` at this constant, so every transport (fixture included)
* reports the same number.
*/
readonly searchResultLimit = SESSION_SEARCH_RESULT_LIMIT
/** List snapshot store (list RPC + host stream increments; re-pulled on reconnect) — the useSessions standard feed, current included. */
readonly list: SnapshotStore<SessionListState>
/** The object-layer instance cluster and frame dispatch entry. */
@@ -228,7 +240,10 @@ export class SessionsService implements ISessions {
* @param ctx - client root context (scope fibers mount under it).
* @param api - wire client shared with every Session.
*/
constructor(private readonly rootCtx: Context, api: IApiClient) {
constructor(
private readonly rootCtx: Context,
api: IApiClient,
) {
this.selection = createSnapshotStore<{ sessionId?: SessionId }>(
{},
{ persist: { name: 'dsh.sessions.current' } })
@@ -307,6 +322,20 @@ export class SessionsService implements ISessions {
return this.manager.refreshList()
}
/**
* Search the Host's visible message-content index. Results stay
* request-local; the list snapshot remains the metadata authority.
* @param query - non-blank literal phrase.
* @param signal - cancellation for a superseded search.
* @returns bounded results or a business/transport error.
*/
search(
query: string,
signal: AbortSignal,
): Promise<RpcResult<{ items: SessionSearchResultItem[]; hasMore: boolean }>> {
return this.manager.search(query, signal)
}
/**
* Route a mux stream envelope into the Session object layer.
* @param envelope - validated mux stream envelope.

View File

@@ -2,6 +2,7 @@
import type { Context } from 'cordis'
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {
HistoryEntry, IApiClient, InboxItemId, MuxFrame, QueueAction, RpcError,
@@ -12,8 +13,8 @@ import type {
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { SessionFace } from '../contract/session.ts'
import type {
CodeSubCall, ComposerPhase, ConversationNode, ConversationSnapshot, OpenState,
PromptError, QueuedMessage, RunningToolCall,
CodeSubCall, ComposerPhase, ConversationNode, ConversationSnapshot, ModelRetryNode,
OpenState, PromptError, QueuedMessage, RunningToolCall,
} from './conversation.ts'
import type { PendingInteraction } from './pending.ts'
import { PendingWait } from './pending.ts'
@@ -26,6 +27,10 @@ import type { ProjectionsBaseline } from './projection-store.ts'
/** Messages requested per history page. */
export const PAGE_MESSAGES = 50
// Browser bundles cannot value-import the host timeout library. This protocol
// bound is pinned to @deepseek-ai/dsh-timeout's MAX_TIMER_DELAY_MS in tests.
const MAX_RETRY_DELAY_MS = 2_147_483_647
/** Manager-owned observers of a Session object's local state edges. */
export interface SessionOptions {
/**
@@ -88,9 +93,9 @@ export class Session implements SessionFace {
private readonly foldAdapter = new FoldAdapter()
private partial: PartialAccumulator | null = null
private openCalls = new Map<string, RunningToolCall>()
/** Interrupted-turn terminal nodes (frozen partial text / aborted tool cards), merged into the flow by seq.
* Derived from window events (turn/end sweep) — rebuilt by rebuildDerivedFromWindow like partial/openCalls. */
private frozenNodes: ConversationNode[] = []
/** Operational notices and interrupted-turn terminal nodes merged into the flow by seq.
* Derived from window events — rebuilt by rebuildDerivedFromWindow like partial/openCalls. */
private derivedNodes: ConversationNode[] = []
private pending = new Map<string, PendingInteraction>()
// Revision counters preserve array identity when derived content is unchanged, so
// React.memo children survive unrelated snapshot swaps (chunk storms must not re-render every
@@ -100,12 +105,12 @@ export class Session implements SessionFace {
private callsCache: { rev: number; value: RunningToolCall[] } | null = null
private pendingRev = 0
private pendingCache: { rev: number; value: PendingInteraction[] } | null = null
private derivedRev = 0
private nodesCache: { folded: readonly ConversationNode[]; derivedRev: number; value: readonly ConversationNode[] } | null = null
/** Authoritative stream-only inbox snapshot; pending work never hits history. */
private queued: QueuedMessage[] = []
private queueRev = 0
private queueCache: { rev: number; value: QueuedMessage[] } | null = null
private frozenRev = 0
private nodesCache: { folded: readonly ConversationNode[]; frozenRev: number; value: readonly ConversationNode[] } | null = null
/** `run_code` sub-dispatches by parent callId (window-derived, like openCalls). Appends
* copy-on-write the per-parent array so published snapshot references never mutate. */
private codeDispatches = new Map<string, readonly CodeSubCall[]>()
@@ -625,8 +630,28 @@ export class Session implements SessionFace {
}
/** Per-event side effects (right column of the §A.9 dispatch table):
* chunk accumulation / partial clear on finalize / openCalls add-remove. */
* chunk/retry projection and openCalls add-remove. */
private applyEventSideEffects(event: SessionEvent, view?: ToolEventView): void {
const eventType = event.type as string
if (eventType === 'llm/retry') {
const data = parseRetryEventData(event.data)
if (data === null) {
console.error(`[web-runtime] ignored malformed llm/retry event at seq ${event.seq}`)
return
}
if (this.partial !== null && this.partial.turn === data.turn && this.partial.step === data.step) {
this.partial = null
}
this.derivedNodes.push({
kind: 'model-retry',
seq: event.seq,
time: event.time,
retryState: 'scheduled',
...data,
})
this.derivedRev++
return
}
// The `tool/code-dispatch-start`/`tool/code-dispatch` pair is declared by
// the host-side dsh-tools plugin whose types cannot enter the client
// program (its host Context merges collide with the client's), so this
@@ -687,6 +712,10 @@ export class Session implements SessionFace {
return
}
switch (event.type) {
case 'turn/start': {
if (event.data.trigger.kind === 'retry') this.settleScheduledRetry('started')
return
}
case 'assistant/chunk': {
const { turn, step, chunk } = event.data
if (this.partial === null || this.partial.turn !== turn || this.partial.step !== step) {
@@ -715,6 +744,9 @@ export class Session implements SessionFace {
return
}
case 'turn/end': {
if (event.data.reason.kind === 'aborted' || event.data.reason.kind === 'disposed') {
this.settleScheduledRetry('cancelled', event.data.turn)
}
// Aborted turns never finalize. The accumulated partial is VALUE, not residue: freeze it
// into an interrupted terminal node (pulse stops, text survives) instead of deleting it.
// Shared by live and window-replay paths, so a refresh reconstructs the same frozen node
@@ -724,12 +756,12 @@ export class Session implements SessionFace {
const visible = blocks.some(b => (b.kind === 'text' || b.kind === 'reasoning' ? b.text !== '' : true))
if (visible) {
// Fractional seq: strictly after every event of this turn (all < turn/end seq), before the next turn.
this.frozenNodes.push({
this.derivedNodes.push({
kind: 'assistant', seq: event.seq - 0.9, time: event.time,
turn: this.partial.turn, step: this.partial.step,
blocks, interrupted: true,
})
this.frozenRev++
this.derivedRev++
}
this.partial = null
}
@@ -739,7 +771,7 @@ export class Session implements SessionFace {
this.openCalls.delete(callId)
this.callsRev++
// The spinner card becomes an interrupted terminal card (never vanishes mid-flow).
this.frozenNodes.push({
this.derivedNodes.push({
kind: 'tool-result', seq: event.seq - 0.8 + callOffset++ * 0.01, time: event.time,
callId,
call: { name: call.name, argsRaw: call.argsRaw },
@@ -747,7 +779,7 @@ export class Session implements SessionFace {
content: [], isError: true, error: { name: 'Interrupted', code: 'interrupted' },
callView: call.callView, resultView: null,
})
this.frozenRev++
this.derivedRev++
}
return
}
@@ -756,15 +788,36 @@ export class Session implements SessionFace {
}
}
/** Re-derive state (partial/openCalls/frozenNodes) from raw window events after a rebuild — keeps
* paging/stitching consistent, and makes the live freeze and the history replay converge on the
* same interrupted nodes (chunks are logged, so the replayed sweep re-freezes identical text). */
/**
* Settle the newest scheduled retry, optionally restricted to its failed turn.
* @param retryState - next client projection state to publish.
* @param turn - failed turn required for cancellation; omitted for the next retry turn start.
*/
private settleScheduledRetry(
retryState: Exclude<ModelRetryNode['retryState'], 'scheduled'>,
turn?: number,
): void {
const index = this.derivedNodes.findLastIndex(node =>
node.kind === 'model-retry'
&& node.retryState === 'scheduled'
&& (turn === undefined || node.turn === turn))
if (index < 0) return
const node = this.derivedNodes[index]
/* v8 ignore next -- findLastIndex's predicate narrows the indexed node only at runtime. */
if (node?.kind !== 'model-retry') return
this.derivedNodes[index] = { ...node, retryState }
this.derivedRev++
}
/** Re-derive state (partial/openCalls/derivedNodes) from raw window events after a rebuild — keeps
* paging/stitching consistent, and makes live handling and history replay converge on the same
* retry notices and interrupted nodes. */
private rebuildDerivedFromWindow(): void {
this.partial = null
this.openCalls.clear()
this.callsRev++
this.frozenNodes = []
this.frozenRev++
this.derivedNodes = []
this.derivedRev++
this.codeDispatches = new Map()
this.dispatchesRev++
for (let i = 0; i < this.events.length; i++) {
@@ -781,17 +834,17 @@ export class Session implements SessionFace {
private buildSnapshot(): ConversationSnapshot {
const { nodes: folded, degraded } = this.foldAdapter.nodes()
// Frozen interrupted nodes ride fractional seqs: a stable merge keeps them in flow order.
// The merged array is cached on (folded reference, frozenRev) so an unchanged flow keeps its
// Derived nodes use their event seq or a nearby fractional seq: a stable merge keeps flow order.
// The merged array is cached on (folded reference, derivedRev) so an unchanged flow keeps its
// reference across snapshot swaps (§A.9.4).
let nodes: readonly ConversationNode[]
if (this.nodesCache !== null && this.nodesCache.folded === folded && this.nodesCache.frozenRev === this.frozenRev) {
if (this.nodesCache !== null && this.nodesCache.folded === folded && this.nodesCache.derivedRev === this.derivedRev) {
nodes = this.nodesCache.value
} else {
nodes = this.frozenNodes.length === 0
nodes = this.derivedNodes.length === 0
? folded
: [...folded, ...this.frozenNodes].sort((a, b) => a.seq - b.seq)
this.nodesCache = { folded, frozenRev: this.frozenRev, value: nodes }
: [...folded, ...this.derivedNodes].sort((a, b) => a.seq - b.seq)
this.nodesCache = { folded, derivedRev: this.derivedRev, value: nodes }
}
if (this.callsCache === null || this.callsCache.rev !== this.callsRev) {
this.callsCache = { rev: this.callsRev, value: [...this.openCalls.values()] }
@@ -835,6 +888,58 @@ export class Session implements SessionFace {
}
}
/** Validate the plugin-owned payload at the session-event wire boundary. */
function parseRetryEventData(value: unknown): LlmRetryEventData | null {
if (value === null || typeof value !== 'object') return null
const data = value as Record<string, unknown>
const failure = data.failure
if (failure === null || typeof failure !== 'object') return null
const failureData = failure as Record<string, unknown>
if (!nonNegativeSafeInteger(data.turn)
|| !nonNegativeSafeInteger(data.step)
|| typeof data.provider !== 'string'
|| data.provider.length === 0
|| typeof data.policyKey !== 'string'
|| data.policyKey.length === 0
|| !positiveSafeInteger(data.retry)
|| typeof data.delayMs !== 'number'
|| !Number.isFinite(data.delayMs)
|| data.delayMs < 0
|| data.delayMs > MAX_RETRY_DELAY_MS
|| typeof failureData.message !== 'string'
|| failureData.message.length === 0
|| typeof failureData.code !== 'string'
|| failureData.code.length === 0) return null
if (data.mode === 'normal') {
if (!positiveSafeInteger(data.maxRetries) || data.retry > data.maxRetries) return null
} else if (data.mode === 'always') {
if ('maxRetries' in data) return null
} else {
return null
}
if (failureData.status !== undefined
&& (typeof failureData.status !== 'number'
|| !Number.isInteger(failureData.status)
|| failureData.status < 100
|| failureData.status > 599)) return null
if (failureData.providerRetryAfterMs !== undefined
&& (typeof failureData.providerRetryAfterMs !== 'number'
|| !Number.isFinite(failureData.providerRetryAfterMs)
|| failureData.providerRetryAfterMs <= 0)) return null
if (failureData.requestId !== undefined
&& (typeof failureData.requestId !== 'string'
|| failureData.requestId.length === 0)) return null
return data as unknown as LlmRetryEventData
}
function nonNegativeSafeInteger(value: unknown): value is number {
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
}
function positiveSafeInteger(value: unknown): value is number {
return nonNegativeSafeInteger(value) && value > 0
}
/**
* The composerPhase judgment — the single site that knows the predicate
* (consumers switch on the result, never re-derive). Monotone per session

View File

@@ -7,6 +7,7 @@ import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import type { ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client'
import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api'
import * as RuntimeClient from '../src/client/index.ts'
import type { SessionsService } from '../src/client/sessions/service.ts'
import type { WorkspacesService } from '../src/client/workspaces/service.ts'
@@ -50,6 +51,8 @@ describe('runtime client apply', () => {
const workspaces = bench.ctx.get('workspaces')
expect(sessions !== undefined).toBe(true)
expect(workspaces !== undefined).toBe(true)
// The bound the wire schema enforces, not a per-connection negotiation.
expect((sessions as SessionsService).searchResultLimit).toBe(SESSION_SEARCH_RESULT_LIMIT)
if (workspaces === undefined) throw new Error('WorkspacesService missing after runtime apply')
expect(bench.sinks).toBeDefined()

View File

@@ -63,7 +63,25 @@ export const ev = {
}),
stepEnd: (seq: number, turn: number, step = 0): SessionEvent =>
at(seq, { type: 'step/end', data: { turn, step } }),
turnEnd: (seq: number, turn: number, reason: 'completed' | 'cancelled' = 'completed'): SessionEvent =>
retry: (
seq: number,
turn: number,
step = 0,
retry = 1,
maxRetries = 2,
delayMs = 500,
message = 'temporary transport failure',
): SessionEvent =>
at(seq, {
type: 'llm/retry',
data: {
turn, step,
provider: 'fake', mode: 'normal', policyKey: 'fake-normal',
retry, maxRetries, delayMs,
failure: { code: 'TRANSPORT', message },
},
}),
turnEnd: (seq: number, turn: number, reason: 'completed' | 'aborted' | 'disposed' = 'completed'): SessionEvent =>
at(seq, { type: 'turn/end', data: { turn, reason: { kind: reason } } }),
commandRun: (seq: number, commandId: string, name: string, args = ''): SessionEvent =>
at(seq, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }),

View File

@@ -4,7 +4,7 @@
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type {
ClientResponse, CommandDescriptor, HostFrame, IApiClient, ModelTarget, MuxFrame,
RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionModels, SkillEntry,
RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry,
WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-connection/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
@@ -61,6 +61,8 @@ export class FakeApiClient implements IApiClient {
// Programmable slots (defaults answer OK-empty); reassign per case.
onList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
onSearch: (payload: unknown) => Promise<RpcResponse<{ items: SessionSearchItem[]; hasMore: boolean }>> =
() => Promise.resolve(ok({ items: [], hasMore: false }))
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
readonly defaultModel: ModelTarget = { provider: 'deepseek-official', model: 'deepseek-v4-flash' }
onRename: (payload: unknown) => Promise<RpcResponse<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
@@ -106,12 +108,17 @@ export class FakeApiClient implements IApiClient {
private readonly muxConns: StreamConn<MuxFrame>[] = []
private readonly hostConns: StreamConn<HostFrame>[] = []
lastSearchSignal: AbortSignal | undefined
// Parameters carry local structural annotations: the CI lint lane runs
// without built lib/, so IApiClient's indexed-access types collapse to any
// and inferred parameters would trip no-unsafe-argument.
readonly sessions: IApiClient['sessions'] = {
list: (payload: unknown) => this.record('session.list', payload, this.onList(payload)),
search: (payload: unknown, signal?: AbortSignal) => {
this.lastSearchSignal = signal
return this.record('session.search', payload, this.onSearch(payload))
},
create: (payload: unknown) => this.record('session.create', payload, this.onCreate(payload)),
history: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) =>
this.record('session.history', payload, this.onHistory(payload)),

View File

@@ -206,6 +206,49 @@ describe('list lifecycle', () => {
})
})
describe('search', () => {
it('returns bounded Host results and forwards the caller signal', async () => {
const api = new FakeApiClient()
api.onSearch = () => Promise.resolve(ok({
items: [{ sessionId: S1, snippet: 'matching excerpt' }],
hasMore: true,
}))
const manager = new SessionManager(api)
const signal = new AbortController().signal
await expect(manager.search('exact phrase', signal)).resolves.toEqual({
ok: true,
value: {
items: [{ sessionId: S1, snippet: 'matching excerpt' }],
hasMore: true,
},
})
expect(api.callsOf('session.search')).toEqual([{ query: 'exact phrase' }])
expect(api.lastSearchSignal).toBe(signal)
})
it('preserves business errors and folds transport failures', async () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
api.onSearch = () => Promise.resolve(err({
code: 'internal',
message: 'index unavailable',
details: {},
}))
const signal = new AbortController().signal
await expect(manager.search('first', signal)).resolves.toMatchObject({
ok: false,
error: { code: 'internal', message: 'index unavailable' },
})
api.onSearch = () => Promise.reject(new Error('wire down'))
await expect(manager.search('second', signal)).resolves.toMatchObject({
ok: false,
error: { code: 'internal', message: 'wire down' },
})
})
})
describe('host frame routing', () => {
it('adds/removes/flips sessions from host frames and keeps removed instances resident', async () => {
const api = new FakeApiClient()

View File

@@ -8,6 +8,7 @@
import { describe, expect, it, vi } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { Session } from '../src/client/sessions/session.ts'
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
@@ -161,6 +162,214 @@ describe('live event path', () => {
expect((last as { interrupted?: true }).interrupted).toBeUndefined()
})
it('retracts the failed step partial on retry and keeps a replayable notice before the recovered response', async () => {
const { session } = await opened()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
const retryTurn = [
ev.turnStart(6, 1),
ev.user(7, '请重试'),
ev.stepStart(8, 1),
ev.chunkStart(9, 1),
ev.chunkText(10, 1, '不完整回复'),
ev.stepEnd(11, 1),
ev.retry(12, 1, 0, 1, 2, 450, '连接被重置'),
at(13, {
type: 'turn/end',
data: {
turn: 1,
reason: {
kind: 'error', step: 0,
failure: { code: 'TRANSPORT', message: '连接被重置' },
},
},
}),
at(14, { type: 'turn/start', data: { turn: 2, trigger: { kind: 'retry' } } }),
ev.stepStart(15, 2),
ev.assistant(16, 2, '完整回复'),
ev.stepEnd(17, 2),
ev.turnEnd(18, 2),
]
for (const event of retryTurn.slice(0, 7)) feed(event)
let snapshot = session.getSnapshot()
expect(snapshot.partial).toBeNull()
expect(snapshot.nodes.at(-1)).toMatchObject({
kind: 'model-retry',
retryState: 'scheduled',
turn: 1,
step: 0,
provider: 'fake',
mode: 'normal',
policyKey: 'fake-normal',
retry: 1,
maxRetries: 2,
delayMs: 450,
failure: { code: 'TRANSPORT', message: '连接被重置' },
})
expect(JSON.stringify(snapshot.nodes)).not.toContain('不完整回复')
for (const event of retryTurn.slice(7)) feed(event)
snapshot = session.getSnapshot()
expect(snapshot.nodes.slice(-2).map(node => node.kind)).toEqual(['model-retry', 'assistant'])
expect(snapshot.nodes.at(-2)).toMatchObject({ kind: 'model-retry', retryState: 'started' })
expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'assistant', blocks: [{ kind: 'text', text: '完整回复' }] })
const replay = makeSession()
replay.api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...retryTurn])
await replay.session.open()
expect(replay.session.getSnapshot().nodes).toEqual(snapshot.nodes)
expect(replay.session.getSnapshot().partial).toBeNull()
})
it('rejects retry payloads outside the producer contract without retracting the current partial', async () => {
const { session } = await opened()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
feed(ev.turnStart(6, 1))
feed(ev.chunkStart(7, 1))
feed(ev.chunkText(8, 1, '仍在生成'))
const valid = {
turn: 1, step: 0,
provider: 'fake', mode: 'normal', policyKey: 'fake-normal',
retry: 1, maxRetries: 2, delayMs: 500,
failure: { code: 'TRANSPORT', message: 'temporary failure' },
}
const invalid = [
{ ...valid, turn: Number.MAX_SAFE_INTEGER + 1 },
{ ...valid, step: Number.MAX_SAFE_INTEGER + 1 },
{ ...valid, provider: '' },
{ ...valid, policyKey: '' },
{ ...valid, retry: Number.MAX_SAFE_INTEGER + 1 },
{ ...valid, maxRetries: Number.MAX_SAFE_INTEGER + 1 },
{ ...valid, delayMs: -1 },
{ ...valid, delayMs: Number.POSITIVE_INFINITY },
{ ...valid, delayMs: MAX_TIMER_DELAY_MS + 1 },
{ ...valid, failure: { ...valid.failure, message: '' } },
{ ...valid, failure: { ...valid.failure, code: '' } },
{ ...valid, failure: { ...valid.failure, status: '429' } },
{ ...valid, failure: { ...valid.failure, status: 99 } },
{ ...valid, failure: { ...valid.failure, status: 429.5 } },
{ ...valid, failure: { ...valid.failure, status: 600 } },
{ ...valid, failure: { ...valid.failure, providerRetryAfterMs: 0 } },
{ ...valid, failure: { ...valid.failure, providerRetryAfterMs: Number.POSITIVE_INFINITY } },
{ ...valid, failure: { ...valid.failure, requestId: 1 } },
{ ...valid, failure: { ...valid.failure, requestId: '' } },
]
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
try {
for (const [index, data] of invalid.entries()) {
feed(at(9 + index, { type: 'llm/retry', data }))
}
expect(session.getSnapshot().partial?.blocks).toEqual([{ kind: 'text', text: '仍在生成' }])
expect(session.getSnapshot().nodes.filter(node => node.kind === 'model-retry')).toEqual([])
expect(errorSpy).toHaveBeenCalledTimes(invalid.length)
expect(errorSpy).toHaveBeenCalledWith('[web-runtime] ignored malformed llm/retry event at seq 9')
} finally {
errorSpy.mockRestore()
}
})
it('accepts complete retry payloads at the producer field boundaries', async () => {
const { session } = await opened()
session.handleMuxEnvelope('r' as never, {
type: 'session/event',
sessionId: SID,
event: at(6, {
type: 'llm/retry',
data: {
turn: Number.MAX_SAFE_INTEGER,
step: Number.MAX_SAFE_INTEGER,
provider: 'fake',
mode: 'normal',
policyKey: 'fake-normal',
retry: Number.MAX_SAFE_INTEGER,
maxRetries: Number.MAX_SAFE_INTEGER,
delayMs: MAX_TIMER_DELAY_MS,
failure: {
code: 'RATE_LIMIT',
message: 'provider busy',
status: 599,
providerRetryAfterMs: Number.MIN_VALUE,
requestId: 'req-1',
},
},
}),
})
expect(session.getSnapshot().nodes.at(-1)).toMatchObject({
kind: 'model-retry',
retryState: 'scheduled',
retry: Number.MAX_SAFE_INTEGER,
delayMs: MAX_TIMER_DELAY_MS,
failure: { status: 599, providerRetryAfterMs: Number.MIN_VALUE, requestId: 'req-1' },
})
})
it('projects always-mode retries and rejects mode-specific maximums or unknown modes', async () => {
const { session } = await opened()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
try {
feed(at(6, {
type: 'llm/retry',
data: {
turn: 1, step: 0,
provider: 'fake', mode: 'always', policyKey: 'fake-always',
retry: 3, delayMs: 500,
failure: { code: 'TRANSPORT', message: 'retry forever' },
},
}))
expect(session.getSnapshot().nodes.at(-1)).toMatchObject({
kind: 'model-retry',
retryState: 'scheduled',
mode: 'always',
retry: 3,
})
feed(at(7, {
type: 'llm/retry',
data: {
turn: 2, step: 0,
provider: 'fake', mode: 'always', policyKey: 'fake-always',
retry: 4, maxRetries: 4, delayMs: 500,
failure: { code: 'TRANSPORT', message: 'unexpected maximum' },
},
}))
feed(at(8, {
type: 'llm/retry',
data: {
turn: 2, step: 0,
provider: 'fake', mode: 'sometimes', policyKey: 'fake-unknown',
retry: 4, delayMs: 500,
failure: { code: 'TRANSPORT', message: 'unknown mode' },
},
}))
expect(session.getSnapshot().nodes.filter(node => node.kind === 'model-retry')).toHaveLength(1)
expect(errorSpy).toHaveBeenCalledTimes(2)
} finally {
errorSpy.mockRestore()
}
})
it.each(['aborted', 'disposed'] as const)(
'marks a scheduled retry as cancelled when its failed turn ends %s',
async (reason) => {
const { session } = await opened()
const feed = (event: SessionEvent) => {
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event })
}
feed(ev.turnStart(6, 1))
feed(ev.retry(7, 1))
expect(session.getSnapshot().nodes.at(-1)).toMatchObject({
kind: 'model-retry',
retryState: 'scheduled',
})
feed(ev.turnEnd(8, 1, reason))
expect(session.getSnapshot().nodes.at(-1)).toMatchObject({
kind: 'model-retry',
retryState: 'cancelled',
})
},
)
it('freezes an unfinalized partial into an interrupted node on turn/end (cancel path)', async () => {
const { session } = await opened()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
@@ -168,7 +377,7 @@ describe('live event path', () => {
feed(ev.user(7, '要被打断的'))
feed(ev.chunkStart(8, 1))
feed(ev.chunkText(9, 1, '说到一半'))
feed(ev.turnEnd(10, 1, 'cancelled')) // no assistant/message ever arrives
feed(ev.turnEnd(10, 1, 'aborted')) // no assistant/message ever arrives
const snapshot = session.getSnapshot()
expect(snapshot.partial).toBeNull()
const frozen = snapshot.nodes.at(-1)
@@ -187,7 +396,7 @@ describe('live event path', () => {
expect(session.getSnapshot().runningCalls).toEqual([])
// Second call never resolves: turn/end freezes it as an error card.
feed(ev.toolCall(9, 1, 'c2', 'slow_tool', '{}'))
feed(ev.turnEnd(10, 1, 'cancelled'))
feed(ev.turnEnd(10, 1, 'aborted'))
const snapshot = session.getSnapshot()
expect(snapshot.runningCalls).toEqual([])
expect(snapshot.nodes.at(-1)).toMatchObject({
@@ -529,7 +738,7 @@ describe('remaining branches', () => {
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
feed(ev.turnStart(6, 1))
feed(ev.chunkStart(7, 1)) // empty text block only, no delta
feed(ev.turnEnd(8, 1, 'cancelled'))
feed(ev.turnEnd(8, 1, 'aborted'))
const snapshot = session.getSnapshot()
expect(snapshot.partial).toBeNull()
expect(snapshot.nodes.filter(n => n.kind === 'assistant' && (n as { interrupted?: true }).interrupted)).toEqual([])
@@ -543,7 +752,7 @@ describe('remaining branches', () => {
feed(ev.turnStart(6, 1))
feed(ev.toolCall(7, 1, 'turn1-call', 'echo', '{}'))
feed(ev.toolCall(8, 2, 'turn2-call', 'echo', '{}')) // stray call attributed to a later turn
feed(ev.turnEnd(9, 1, 'cancelled'))
feed(ev.turnEnd(9, 1, 'aborted'))
const snapshot = session.getSnapshot()
expect(snapshot.runningCalls.map(c => c.callId)).toEqual(['turn2-call'])
expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'tool-result', callId: 'turn1-call', isError: true })
@@ -637,7 +846,7 @@ describe('remaining branches', () => {
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
feed(ev.turnStart(6, 1))
feed(at(7, { type: 'assistant/chunk', data: { turn: 1, step: 0, chunk: { type: 'tool-call-delta', index: 0, id: 'c1', name: 'echo', argumentsDelta: '{' } } }))
feed(ev.turnEnd(8, 1, 'cancelled'))
feed(ev.turnEnd(8, 1, 'aborted'))
const frozen = session.getSnapshot().nodes.at(-1)
expect(frozen).toMatchObject({ kind: 'assistant', interrupted: true, blocks: [{ kind: 'tool-call', callId: 'c1' }] })
})

View File

@@ -69,6 +69,29 @@ describe('list store projection', () => {
})
})
describe('search', () => {
it('delegates transient content search without changing the list snapshot', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
const before = b.svc.list.getSnapshot()
b.api.onSearch = () => Promise.resolve(ok({
items: [{ sessionId: sid('s1'), snippet: 'matching excerpt' }],
hasMore: false,
}))
const signal = new AbortController().signal
await expect(b.svc.search('needle', signal)).resolves.toEqual({
ok: true,
value: {
items: [{ sessionId: 's1', snippet: 'matching excerpt' }],
hasMore: false,
},
})
expect(b.api.lastSearchSignal).toBe(signal)
expect(b.svc.list.getSnapshot()).toBe(before)
})
})
describe('scope tree', () => {
it('mints lazily on first resolution, tags the ctx, and keeps binding identity stable', async () => {
const b = bench()

View File

@@ -35,6 +35,9 @@
{
"path": "../../llm/llm"
},
{
"path": "../../llm/llm-retry"
},
{
"path": "../../support/invariants"
}

View File

@@ -28,6 +28,7 @@
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-client-web-react": "^0.0.1",
"@deepseek-ai/dsh-host-apiproxy": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0",
@@ -37,6 +38,7 @@
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-web-react": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"@types/react-dom": "~18.3.0",

View File

@@ -37,6 +37,7 @@ export { FixtureSession, TestSessions } from './sessions.ts'
export { TestWorkspaces } from './workspaces.ts'
export { conversationSnapshot, workspaceListState } from './fixtures.ts'
export type { SessionBehaviorOverrides, SessionFixture, Stabilizer } from './fixtures.ts'
export { makeTranslate } from './translate.ts'
/** Erased register face for the internal root call (the public declare seam holds the typing). */
type ErasedRegister = (options: object, component: unknown) => () => void

View File

@@ -4,8 +4,11 @@ import { createScope, scopeOf, SessionProvideChannel } from '@deepseek-ai/dsh-cl
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type {
ConversationSnapshot, ISessions, ObservableSnapshot, ProjectionsFace, SessionFace, SessionId,
SessionListState, SessionProvideDescriptor, SessionSummary, SnapshotStore,
SessionListState, SessionProvideDescriptor, SessionSearchResultItem, SessionSummary, SnapshotStore,
} from '@deepseek-ai/dsh-client-runtime/client'
// The double reports the wire schema's own search bound, like the production
// service — a transport-varying limit would be a fiction no client can see.
import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { HostObservable, SessionMaybeProvideInfo, SessionProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
import { conversationSnapshot } from './fixtures.ts'
import type { SessionFixture, Stabilizer } from './fixtures.ts'
@@ -151,8 +154,8 @@ export interface TestSessionBinding {
*
* Implements the same ISessions face features receive as `ctx.sessions`, so
* a production face change breaks this double at compile time; the extra
* members (add/updateSnapshot/setCurrent/remove/behavior/calls and the
* legacy provideInfo/maybeProvideInfo lookups) are bench-only surface.
* members (add/updateSnapshot/setCurrent/remove/behavior/calls/stubSearch and
* the legacy provideInfo/maybeProvideInfo lookups) are bench-only surface.
*/
export class TestSessions implements ISessions {
/** The useSessions standard feed (list rows + current selection). */
@@ -168,8 +171,14 @@ export class TestSessions implements ISessions {
/** The production provide channel (roster, materialization rules, current projection) — no test-side mirror. */
private readonly channel: SessionProvideChannel
/** Calls observed on the service-level face (open/clear), newest last. */
readonly calls: { method: 'open' | 'clear' | 'fork'; args: unknown[] }[] = []
/** Calls observed on the service-level face (open/clear/search/fork), newest last. */
readonly calls: { method: 'open' | 'clear' | 'search' | 'fork'; args: unknown[] }[] = []
/** The wire schema's `session.search` result bound (production parity). */
readonly searchResultLimit = SESSION_SEARCH_RESULT_LIMIT
/** Replaceable search behavior (see {@link TestSessions.stubSearch}). */
private searchStub: ((query: string, signal: AbortSignal) => { items: SessionSearchResultItem[]; hasMore: boolean }) | undefined
/**
* @param stabilize - the owning runtime's act wrapper.
@@ -392,6 +401,27 @@ export class TestSessions implements ISessions {
this.list.update((draft) => { draft.current = undefined })
}
/**
* Replace the sidebar-search result page (the call is still recorded).
* @param impl - hits for a query, as the Host would rank them.
*/
stubSearch(impl: (query: string, signal: AbortSignal) => { items: SessionSearchResultItem[]; hasMore: boolean }): void {
this.searchStub = impl
}
/**
* Content search over the fixture corpus (recorded). The default answers an
* empty page: content ranking is Host behavior, so a scenario that asserts
* hits declares them through {@link TestSessions.stubSearch}.
* @param query - non-blank literal phrase.
* @param signal - cancellation for a superseded search (recorded and forwarded).
* @returns the stubbed or empty result page.
*/
search(query: string, signal: AbortSignal): ReturnType<ISessions['search']> {
this.calls.push({ method: 'search', args: [query, signal] })
return Promise.resolve({ ok: true, value: this.searchStub?.(query, signal) ?? { items: [], hasMore: false } })
}
/**
* Recorded fork stub: no child materializes (benches asserting the full
* fork flow drive the production service; this face only proves the call).

View File

@@ -0,0 +1,32 @@
/**
* Test double of the locale lookup chain: a translate stub over plain
* dictionaries, mirroring LocaleService's resolution order (first dictionary
* that owns the key wins, then the key itself stays visible) and its
* `{name}` template interpolation. Specs stub the framework-injected `t`
* seat with `makeTranslate(zh, commonZh)` instead of re-implementing the
* chain per suite.
*/
/**
* Build a translate stub resolving through `dicts` in order (namespace
* first, then the shared common vocabulary), falling back to the key.
* @param dicts - dictionaries consulted in order.
* @returns the translate function (assignable to any `XxxProps['t']` seat).
*/
export function makeTranslate(
...dicts: readonly Record<string, string>[]
): (key: string, params?: Record<string, unknown>) => string {
return (key, params) => {
let template = key
for (const dict of dicts) {
const hit = dict[key]
if (hit !== undefined) {
template = hit
break
}
}
if (!params) return template
return template.replace(/\{(\w+)\}/g, (match, name: string) =>
name in params ? String(params[name]) : match)
}
}

View File

@@ -221,6 +221,28 @@ describe('sessions', () => {
])
await runtime.dispose()
})
it('answers search with an empty page until a scenario declares hits, recording every call', async () => {
const runtime = await runtimeWithFrame()
await runtime.sessions.add({ id: 's1' })
const signal = new AbortController().signal
expect(runtime.sessions.searchResultLimit).toBeGreaterThan(0)
await expect(runtime.sessions.search('marker', signal))
.resolves.toEqual({ ok: true, value: { items: [], hasMore: false } })
runtime.sessions.stubSearch(query => ({
items: [{ sessionId: 's1' as SessionId, snippet: `hit: ${query}` }],
hasMore: true,
}))
await expect(runtime.sessions.search('marker', signal)).resolves.toEqual({
ok: true,
value: { items: [{ sessionId: 's1', snippet: 'hit: marker' }], hasMore: true },
})
expect(runtime.sessions.calls).toEqual([
{ method: 'search', args: ['marker', signal] },
{ method: 'search', args: ['marker', signal] },
])
await runtime.dispose()
})
})
describe('stores', () => {

View File

@@ -22,6 +22,9 @@
},
{
"path": "../../support/invariants"
},
{
"path": "../../host/apiproxy"
}
]
}

View File

@@ -25,6 +25,7 @@
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-slash",
"@deepseek-ai/dsh-client-ui-conversation"
],
@@ -40,6 +41,7 @@
},
"peerDependencies": {
"@deepseek-ai/dsh-client-connection": "^0.0.1",
"@deepseek-ai/dsh-client-locale": "^0.0.1",
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
@@ -51,7 +53,9 @@
},
"devDependencies": {
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",

View File

@@ -13,6 +13,7 @@ import { useEffect, useRef } from 'react'
import { useSyncExternalStore } from 'react'
import clsx from 'clsx'
import { IconCheckOutline16, useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import { filterOptions } from './popup.ts'
import type { PopupSelectController } from './popup.ts'
import css from './PopupSelectView.module.css'
@@ -26,12 +27,15 @@ export interface PopupSelectInjected {
popup: PopupSelectController
}
/** Full shell props: injected face + the locale seat. */
export type PopupSelectViewProps = PopupSelectInjected & PropsLocale<'command'>
/**
* Render the popupSelect shell overlay entry.
* @param props - injected face: the session's shell controller.
* @param props - injected face: the session's shell controller; `t` rides the standard locale seat.
* @returns the select card while open; null while closed.
*/
export function PopupSelectView({ popup }: PopupSelectInjected) {
export function PopupSelectView({ popup, t }: PopupSelectViewProps) {
const state = useSyncExternalStore(
fn => popup.state.subscribe(fn),
() => popup.state.getSnapshot(),
@@ -103,15 +107,15 @@ export function PopupSelectView({ popup }: PopupSelectInjected) {
ref={cardRef}
className={css.card}
style={{ maxHeight }}
aria-label={`/${String(state.command)} options`}
aria-label={t('overlay.aria', { command: String(state.command) })}
onKeyDown={onKeyDown}
>
<input
ref={searchRef}
className={css.search}
type="text"
placeholder="Search…"
aria-label="Filter options"
placeholder={t('search.placeholder')}
aria-label={t('search.aria')}
value={state.search}
readOnly={state.submitting}
onChange={(ev) => { popup.setSearch(ev.currentTarget.value) }}
@@ -120,15 +124,15 @@ export function PopupSelectView({ popup }: PopupSelectInjected) {
<div className={css.error} role="alert">
<span className={css.errorText}>{state.error}</span>
{state.status === 'failed' && (
<button type="button" className={css.retry} onClick={() => { popup.retry() }}>Retry</button>
<button type="button" className={css.retry} onClick={() => { popup.retry() }}>{t('retry')}</button>
)}
</div>
)}
{state.status === 'pending' && <div className={css.status}>Loading options</div>}
{state.submitting && <div className={css.status}>Applying</div>}
{state.status === 'ready' && rows.length === 0 && <div className={css.status}>No options</div>}
{state.status === 'pending' && <div className={css.status}>{t('status.loading')}</div>}
{state.submitting && <div className={css.status}>{t('status.applying')}</div>}
{state.status === 'ready' && rows.length === 0 && <div className={css.status}>{t('status.empty')}</div>}
{state.status === 'ready' && (
<div role="listbox" aria-label={`/${String(state.command)} matches`} className={css.viewport}>
<div role="listbox" aria-label={t('listbox.aria', { command: String(state.command) })} className={css.viewport}>
{rows.map((option, index) => (
<div
key={option.id}

View File

@@ -10,19 +10,23 @@ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
// key's owner) into this program so the overlay registration below typechecks
// against the real declaration — no runtime edge to ui-conversation.
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
import { CommandService } from './service.ts'
import type { PopupSelectInjected } from './PopupSelectView.tsx'
import { PopupSelectView } from './PopupSelectView.tsx'
import { en, zh, type CommandKey } from './locales.ts'
export { CommandService } from './service.ts'
export { CommandDirectory } from './directory.ts'
export type { CommandDescriptor, DirectoryStatus } from './directory.ts'
export { filterOptions, PopupSelectController } from './popup.ts'
export type { PopupSelectDeps, PopupSpec, PopupState, TokenSegment } from './popup.ts'
export type { PopupSelectInjected } from './PopupSelectView.tsx'
export type { PopupSelectInjected, PopupSelectViewProps } from './PopupSelectView.tsx'
export type {
CommandContribution, CommandDecoration, CommandServiceContract, CommandUiSpec, SelectOption,
} from './contract.ts'
export type { CommandKey } from './locales.ts'
declare module 'cordis' {
interface Context {
@@ -30,8 +34,18 @@ declare module 'cordis' {
}
}
/** Required services: the '/' source registry plus the scope + wire faces the service reads. */
export const inject = ['slash', 'sessions', 'connection']
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
/** The popupSelect shell's copy. */
command: CommandKey
}
}
/** Dictionary namespace owned by this plugin. */
const NS = 'command'
/** Required services: the '/' source registry plus the scope + wire faces the service reads, and the copy's locale registry. */
export const inject = ['slash', 'sessions', 'connection', 'locale']
/**
* Client plugin body: mount the service, then register the popupSelect shell
@@ -39,6 +53,7 @@ export const inject = ['slash', 'sessions', 'connection']
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-command: dictionaries')
ctx.plugin(CommandService)
// Conditional mount, same seam as ui-slash's MenuView registration:
// 'conversation.input.overlay' is declared by the conversation composer
@@ -51,6 +66,7 @@ export function apply(ctx: ClientContext): void {
name: 'conversation.input.overlay',
id: 'command-popup',
order: 1,
locale: NS,
inject: (sessionId): PopupSelectInjected => {
const actx = sessions.scope(sessionId)
if (actx === undefined) throw new Error(`ui-command: session "${String(sessionId)}" resolved no scope`)

View File

@@ -0,0 +1,26 @@
/** `command` namespace dictionaries (the popupSelect shell's copy). */
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'search.placeholder': '搜索…',
'search.aria': '筛选选项',
'status.loading': '正在加载选项…',
'status.applying': '正在应用…',
'status.empty': '无选项',
'overlay.aria': '/{command} 选项',
'listbox.aria': '/{command} 匹配项',
} satisfies Record<string, string>
/** The command namespace key union. */
export type CommandKey = keyof typeof zh
/** English dictionary, checked complete against the zh key set. */
export const en = {
'search.placeholder': 'Search…',
'search.aria': 'Filter options',
'status.loading': 'Loading options…',
'status.applying': 'Applying…',
'status.empty': 'No options',
'overlay.aria': '/{command} options',
'listbox.aria': '/{command} matches',
} satisfies Record<CommandKey, string>

View File

@@ -13,6 +13,7 @@ import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
import type { CommandServiceContract } from '../src/client/contract.ts'
import type { PopupSelectInjected } from '../src/client/PopupSelectView.tsx'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { apply, CommandService, inject } from '../src/client/index.ts'
const sid = (k: string): SessionId => k as SessionId
@@ -41,6 +42,7 @@ async function bench() {
},
})
ctx.provide('conversation', {})
ctx.provide('locale', new LocaleService(ctx))
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
const mint = (key: string) => {
@@ -53,7 +55,7 @@ async function bench() {
describe('apply', () => {
it('declares the services it binds', () => {
expect(inject).toEqual(['slash', 'sessions', 'connection'])
expect(inject).toEqual(['slash', 'sessions', 'connection', 'locale'])
})
it('mounts ctx.command, registers the source and the overlay entry, and folds up on disposal', async () => {

View File

@@ -14,6 +14,12 @@ import type { SelectOption } from '../src/client/contract.ts'
import type { PopupSpec, TokenSegment } from '../src/client/popup.ts'
import { PopupSelectController } from '../src/client/popup.ts'
import { PopupSelectView } from '../src/client/PopupSelectView.tsx'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { zh } from '../src/client/locales.ts'
// The framework-injected t seat, stubbed over the zh dictionaries (the default locale).
const t: Parameters<typeof PopupSelectView>[0]['t'] = makeTranslate(zh, commonZh)
// jsdom has no scrollIntoView; the view calls it on the highlighted row.
const scrollIntoView = vi.fn()
@@ -47,12 +53,12 @@ async function mountOpen(overrides: Partial<PopupSpec<string>> = {}, consumeResu
const consume = vi.fn((_segment: TokenSegment) => consumeResult)
const focusComposer = vi.fn()
const popup = new PopupSelectController<string>({ consume, focusComposer })
const view = render(<PopupSelectView popup={popup} />)
const view = render(<PopupSelectView popup={popup} t={t} />)
await act(async () => {
popup.open('theme', spec(overrides), 'ctx-A', SEGMENT)
await Promise.resolve()
})
return { popup, view, consume, focusComposer, search: screen.getByRole('textbox', { name: 'Filter options' }) }
return { popup, view, consume, focusComposer, search: screen.getByRole('textbox', { name: '筛选选项' }) }
}
function rowLabels(): string[] {
@@ -62,13 +68,13 @@ function rowLabels(): string[] {
describe('PopupSelectView', () => {
it('renders null while closed, opens with focus in the search input', async () => {
const popup = new PopupSelectController<string>({ consume: () => true, focusComposer: () => {} })
const view = render(<PopupSelectView popup={popup} />)
const view = render(<PopupSelectView popup={popup} t={t} />)
expect(view.container.childElementCount).toBe(0)
await act(async () => {
popup.open('theme', spec(), 'ctx-A', SEGMENT)
await Promise.resolve()
})
const search = screen.getByRole('textbox', { name: 'Filter options' })
const search = screen.getByRole('textbox', { name: '筛选选项' })
expect(document.activeElement).toBe(search)
expect(rowLabels()).toEqual(['Dark', 'Light', 'Sepia'])
})
@@ -82,7 +88,7 @@ describe('PopupSelectView', () => {
expect(options).toHaveBeenCalledTimes(1)
act(() => { fireEvent.change(search, { target: { value: 'zzz' } }) })
expect(screen.queryByRole('option')).toBeNull()
expect(screen.queryByText('No options')).not.toBeNull()
expect(screen.queryByText('无选项')).not.toBeNull()
})
it('ArrowUp/Down move the filtered highlight; ArrowLeft/Right are left to the native caret', async () => {
@@ -110,13 +116,13 @@ describe('PopupSelectView', () => {
it('caps the card height at the design maximum when the composer sits low enough', async () => {
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ bottom: 800 } as DOMRect)
await mountOpen()
expect(screen.getByLabelText('/theme options').style.maxHeight).toBe('320px')
expect(screen.getByLabelText('/theme 选项').style.maxHeight).toBe('320px')
})
it('clamps the card height to the space above the composer minus the safe margin', async () => {
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ bottom: 200 } as DOMRect)
await mountOpen()
expect(screen.getByLabelText('/theme options').style.maxHeight).toBe('188px')
expect(screen.getByLabelText('/theme 选项').style.maxHeight).toBe('188px')
})
it('Enter selects the highlighted row: onSelect, consume, close, focusComposer', async () => {
@@ -148,7 +154,7 @@ describe('PopupSelectView', () => {
const onSelect = vi.fn(() => new Promise<void>((resolve) => { release = resolve }))
const { search, consume } = await mountOpen({ onSelect })
await act(async () => { fireEvent.keyDown(search, { key: 'Enter' }) })
expect(screen.queryByText('Applying…')).not.toBeNull()
expect(screen.queryByText('正在应用…')).not.toBeNull()
expect((search as HTMLInputElement).readOnly).toBe(true)
await act(async () => {
fireEvent.keyDown(search, { key: 'Enter' })
@@ -162,7 +168,7 @@ describe('PopupSelectView', () => {
expect(consume).toHaveBeenCalledTimes(1)
})
it('a failed options load shows the error with a Retry button that reloads', async () => {
it('a failed options load shows the error with a retry button that reloads', async () => {
let attempts = 0
await mountOpen({
options: () => {
@@ -172,7 +178,7 @@ describe('PopupSelectView', () => {
})
expect(screen.getByRole('alert').textContent).toContain('directory down')
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
fireEvent.click(screen.getByRole('button', { name: '重试' }))
await Promise.resolve()
})
expect(attempts).toBe(2)
@@ -183,7 +189,7 @@ describe('PopupSelectView', () => {
const { search, consume } = await mountOpen({ onSelect: () => Promise.reject(new Error('host rejected')) })
await act(async () => { fireEvent.keyDown(search, { key: 'Enter' }) })
expect(screen.getByRole('alert').textContent).toContain('host rejected')
expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull()
expect(screen.queryByRole('button', { name: '重试' })).toBeNull()
expect(consume).not.toHaveBeenCalled()
expect(screen.getAllByRole('option').length).toBe(3)
})

View File

@@ -14,6 +14,9 @@
{
"path": "../connection"
},
{
"path": "../locale"
},
{
"path": "../runtime"
},

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
README.md: 60000c35c3e30883c4b29cc8410d30e58021318c
README.zh.md: 8f9fe7278d2d4bf5251582867de44290d0727710
README.md: 2f3e545bfd7d29dbdbb0e23d833c2c19ee685a9d
README.zh.md: 12c043f78a242730a6f1e622df997ec5cbacc8fd

View File

@@ -14,17 +14,21 @@ Logged non-user messages render as a default-collapsed `上下文注入` disclos
Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file with the host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering.
A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed for this intent alone; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)).
A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed per render intent — the terminal and web cards, each with its own bound; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)).
A tool call declaring the `web` render intent renders its web retrieval inline, at both conversation render sites, through ui-primitives' `WebBlock`. `contract/web-card-model.ts` is the single derivation from the snapshot's `resultView`, mirroring the terminal card, so the sites cannot disagree about what a web call shows; it yields null — the generic path — for a running call, a non-web result view, a generic result view, a `card` tag this client version does not know, or a web card whose `kind` this client version does not know (a newer host's value, which the wire cannot be trusted to be `search` or `fetch`). The keyed `WebRow` registers one component under both `web_search` and `web_fetch`, discriminating on the tool name only for its icon and title; a web-declaring tool without a keyed row lands on the `GenericToolCard` fallback, which grows the same resident card, and the details panel renders it at the primitive's full source allowance and, below the card, the flattened model-visible result content — a fetch body is readable only there, since its card carries only the URL and status. Rows cap at `CHAT_WEB_MAX_SOURCES` (8) against the panel's 16, the same summary-versus-reading split the terminal card draws ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md)).
The chat flow projects consecutive model-retry nodes across retry turns into one stable, muted status row updated to the latest attempt; every retry event remains in the runtime snapshot and session log. Its frontend countdown anchors the scheduled delay to client receipt, avoiding host/browser clock skew, rounds remaining time up to seconds, and has a one-second floor. The latest unresolved retry uses a left-to-right text shimmer. Subsequent turn facts distinguish an attempt that started from one cancelled during backoff, while the Host running bit only controls the live animation; the row then shows a static completed or cancelled label. Normal policy rows show the finite retry maximum; always policy rows show `∞`. Activating the row reveals the latest exact retry delay and failure message. The client runtime removes each failed step's streaming tail before its retry node arrives, while the status remains visible after a later attempt succeeds.
Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders).
The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`<done>/<total> 已完成 · <active item>` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: 10`between Goal and Queue — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus `"<done>/<total> tasks · <n> in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.
The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`<done>/<total> 已完成 · <active item>` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1`above the queue rows — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus `"<done>/<total> tasks · <n> in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.
`QueueDock` is the terminal input-dock entry at `order: 20`. It hides while empty, renders one pending row directly, and defaults two or more rows to a collapsed `"<n> 条排队消息"` header whose button expands or collapses the complete list. The header exposes `aria-expanded` and `aria-controls`; the expanded list scrolls within a 180px height bound. An active edit or mutation keeps its rows visible, and emptying the queue restores the collapsed default for the next queue. Each visible row remains a single-line preview with its exact-occurrence edit and delete actions.
Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks.
The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `command.hint` locale namespace this package registers and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar renders inert (machine faces absent, `disabled` owner prop) instead of swapping in a parallel disabled tree, so the textarea DOM survives the workspace pick; the strict-session control seats simply stay empty until a session exists.
The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. The leading plus button is a Command launcher, not an attachment surface: it asks the session's `SlashController` to open only the `/` trigger's `command` source over the current textarea selection, while ui-slash's existing `MenuView` remains the sole floating menu and pick path. No file row, file input, upload protocol, or second menu component is introduced. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `conversation` locale namespace this package registers (the `placeholder.plan` / `hint.plan` keys) and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar renders inert (machine faces absent, `disabled` owner prop) instead of swapping in a parallel disabled tree, so the textarea DOM survives the workspace pick; the strict-session control seats simply stay empty until a session exists.
`src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath).
@@ -40,7 +44,7 @@ None; this package neither assembles nor sends a provider request.
- **Stats-line durations cover the in-window flow only** — LLM and tool wall times fold the snapshot's assistant `timing` and tool call/result pairs, so nodes outside the loaded event window (older history) are not counted.
- **Details panel is the minimal form and currently has no entry point** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred. Tool rows stopped being details-panel click targets and nothing replaced that gesture, so `ChatViewInjected.openDetails` is implemented but uncalled and the panel (including its terminal card) is unreachable in the assembled application; its rendering stays covered by mounting it with a selection directly.
- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / branch / clock) ships under text output only; branch forks through the turn containing that message, increments the inherited title on the client, and then opens the child, while a fork or rename failure leaves the source selected.
- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / branch / clock) ships under the last content-text assistant of each turn only; mid-turn narration and Think-only nodes stay chrome-free. Branch forks through the turn containing that message, increments the inherited title on the client, and then opens the child, while a fork or rename failure leaves the source selected.
- **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.
- **The approval panel's "Always allow this type" is deferred** — durable grants need a grant-storage design; only allow-once/reject answer today.
- **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline.

View File

@@ -12,19 +12,23 @@
通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击后通过宿主操作系统的默认应用打开文件(`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect``Mount temporary Plugin``Unmount temporary Plugin`mount 行保留 code 变体的可展开源码渲染。
声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView``resultView` 对推导的唯一位置因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null落回通用路径。因此两个渲染点也都显示卡片的运行状态点它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`8面板为 16正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出只对该意图开放;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。
声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView``resultView` 对推导的唯一位置因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null落回通用路径。因此两个渲染点也都显示卡片的运行状态点它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`8面板为 16正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出按渲染意图开放——终端卡片与 web 卡片,各有自己的上限;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。
声明 `web` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `WebBlock` 内联渲染其 web 检索。`contract/web-card-model.ts` 是从快照的 `resultView` 推导的唯一位置,镜像终端卡片,因此两个渲染点不可能对一次 web 调用的显示产生分歧;对运行中的调用、非 web 的 result view、generic result view、本客户端版本不认识的 `card` 标签,或本客户端版本不认识 `kind` 的 web 卡片(更新的 host 发来的值wire 上不可信其为 `search``fetch`),它返回 null落回通用路径。键控的 `WebRow` 把一个组件注册在 `web_search``web_fetch` 两个键下,仅根据工具名判别以选取图标与标题;没有自己键控行的 web 声明工具落到 `GenericToolCard` 兜底,它长出同一张常驻卡片,详情面板则以原语的完整 source 额度渲染它并在卡片下方渲染摊平的模型可见结果内容——fetch 正文只在此处可读,因为其卡片只携带 URL 和状态。行的上限是 `CHAT_WEB_MAX_SOURCES`8面板为 16与终端卡片所画的摘要面对阅读面的同一划分[决策](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md))。
聊天流会将跨重试轮次连续出现的模型重试节点投影为一个稳定的弱化状态行,并用最新一次尝试更新该行;每个重试事件仍保留在运行时快照与会话日志中。前端倒计时以客户端收到事件的时刻为计划延迟的起点,避免 Host 与浏览器的时钟偏差;剩余时间向上取整到秒,且下限为 1 秒。最近一次尚未完成的重试会显示从左到右的文字渐变动画。后续轮次事实用于区分已开始的尝试与在退避期间取消的尝试Host 的 running 位只控制实时动画随后该行会显示静态的已完成或已取消标签。normal 策略行显示有限重试上限always 策略行显示 `∞`。激活该行会显示最近一次重试的精确延迟和失败消息。客户端运行时会在相应重试节点到达前移除每个失败步骤的流式输出尾部;后续某次尝试成功后,该状态仍保持可见。
工具行同样是 slot独立工具环`ToolViewRegistry``ctx.toolviews`outlet已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位Session scopekey 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps``callId``toolName``block``openFile``ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seamapply 在聊天注册后挂载 ConversationService因此服务存在即可保证 slot 已声明Session 区分在组件内部完成(`useSessions` 读取 `parentId`bash 示例是第三方姿态的范例。Trajectory/waterfall 工具视图 slot 共享此形状并随各自的渲染点落地RendersCheck 会拒绝没有任何渲染方的声明)。
审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位未实例化会话同样点亮镜像该阻塞状态其优先级高于运行中圆环直至问题解决。未决等待完全离开消息流问题ui-question与审批ApprovalPanel都经编辑器接管作答不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数key 缺席即隐藏 chipchip 打开 Menu 原语下拉kebab-case 预设名渲染为 Title Case 标签(与 `/permission` popup 的显示变换孪生),选中会经由输入栏注入的 `command` 回调提交 `/permission <preset>` 命令行。
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']``TodoRow` 占用 `'conversation.chat.toolview'``todo_write` key摘要该次调用「试图写入」的内容从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock``order: 10` 占用 `'conversation.input.dock'` 列表 slot位于 Goal 和 Queue 之间),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · <n> in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock包括这条计划条。
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']``TodoRow` 占用 `'conversation.chat.toolview'``todo_write` key摘要该次调用「试图写入」的内容从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock``order: -1` 占用 `'conversation.input.dock'` 列表 slot位于队列行之上),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · <n> in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock包括这条计划条。
`QueueDock``order: 20` 的末端 input-dock 条目。队列为空时隐藏;只有一个待处理项时直接渲染该行;存在两个或更多待处理项时,默认收起为 `"<n> 条排队消息"` 表头,其按钮可展开或收起完整列表。表头暴露 `aria-expanded``aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。每条可见行仍是单行预览,并提供针对精确单次入队项的编辑和删除操作。
逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store`stores.ts` `createChatStore`InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession``sessionId`、全局 `useSessions``useWorkspaces`,以及输入状态机的 `useInput``inputActions`store 表层与 inject factory 提供其余状态和回调。
输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。当 `plan` 投影的有效目标为 plan mode 时InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `command.hint` locale 命名空间本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent智能体仍能收到回答没有待处理交互时活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 以不可交互状态渲染machine face 均缺席、`disabled` owner prop而不是换入一棵平行的 disabled 树,因此选择 workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。
输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。前置加号按钮是 Command launcher而非附件入口它要求当前会话的 `SlashController` 基于 textarea 当前 selection只打开 `/` trigger 的 `command` source同时 ui-slash 既有的 `MenuView` 仍是唯一的浮层菜单与 pick 路径。不引入 File 行、file input、上传协议或第二套菜单组件。`plan` 投影的有效目标为 plan mode 时InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `conversation` locale 命名空间`placeholder.plan` / `hint.plan` 键)本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent智能体仍能收到回答没有待处理交互时活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 以不可交互状态渲染machine face 均缺席、`disabled` owner prop而不是换入一棵平行的 disabled 树,因此选择 workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。
`src/client/` 按未来的包拆分组织:`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明 + 组合后的 slot props包括工具行契约、`views.ts` 共享原语、`tool-call-model.ts``skeleton/``chat/``toolviews/`(示例注册方)领域目录只导入 contract 文件,彼此绝不导入;`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply``inject`、两个服务类和 `contract/` 类型家族;实现组件(骨架、聊天行)与 store factory 保持内部状态,只能通过 apply 的 slot 注册到达页面(测试通过 `./src/*` 子路径获取它们)。
@@ -40,7 +44,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
- **统计行的耗时只覆盖窗口内消息流**LLM大语言模型与工具墙钟时间由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入。
- **详情面板是最小形态,且当前没有入口**以原始形式显示已选择调用的参数结果Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。工具行已不再是详情面板的点击目标,且没有任何手势接替它,因此 `ChatViewInjected.openDetails` 虽已实现却无人调用,该面板(含其终端卡片)在组装后的应用中不可达;其渲染仍由直接以选中态挂载它来覆盖。
- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/分支/时钟)只挂在 text 输出下;分支会 fork 到包含该消息的轮次末尾,在 client 端递增继承标题后打开子会话,而 fork 或改名失败时源会话保持选中。
- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/分支/时钟)只挂在每个轮次中最后一条带 text 内容的 assistant 下;轮次中间的叙述与纯 Think 节点不带 chrome。分支会 fork 到包含该消息的轮次末尾,在 client 端递增继承标题后打开子会话,而 fork 或改名失败时源会话保持选中。
- **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。
- **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。
- **TodoPanel 将过长条目截成单行省略号**figma 条没有换行或展开入口,完整文本无法在行内读完。

View File

@@ -1,6 +1,6 @@
/** Registers the conversation components, shared store, and service callbacks. */
import type { Context } from 'cordis'
import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
import { resolveSlotLabel, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
import type { ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
@@ -20,6 +20,7 @@ import { InputBar } from './skeleton/InputBar.tsx'
import { ChatView } from './chat/ChatView.tsx'
import { StatsLine } from './chat/StatsLine.tsx'
import { bashToolviewSample } from './toolviews/bash-sample.tsx'
import { webToolview } from './toolviews/web-row.tsx'
import { ApprovalPanel } from './skeleton/ApprovalPanel.tsx'
import { todoToolview } from './toolviews/todo-row.tsx'
import { askQuestionToolview } from './toolviews/ask-question-row.tsx'
@@ -28,6 +29,14 @@ import { queueDockEntry } from './queue/QueueDock.tsx'
import { ConversationRoot } from './skeleton/ConversationRoot.tsx'
import { ConversationSession } from './skeleton/ConversationSession.tsx'
import { DetailsPanel } from './skeleton/DetailsPanel.tsx'
import { en, NS, zh, type ConversationKey } from './locales.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
/** The conversation surfaces' copy (skeleton, chat view, toolviews, docks). */
conversation: ConversationKey
}
}
/** Services required by the conversation plugin. */
export const inject = ['slots', 'layout', 'sessions', 'workspaces', 'locale']
@@ -44,6 +53,10 @@ const ABSENT_LEXICON = {
getSnapshot: () => EMPTY_LEXICON,
subscribe: () => () => {},
}
const ABSENT_MENU_LAUNCHER = {
getSnapshot: (): string | null => null,
subscribe: () => () => {},
}
/** Resolve the session-scoped conversation face (scope-addressed send/cancel), failing loud. */
function scopedConversation(sessions: ISessions, id: SessionId): IConversation {
@@ -68,42 +81,27 @@ export function apply(ctx: Context): void {
const layout = ctx.layout
const slots = ctx.slots
// Command hint locale: friendly placeholder text for claimed commands. The
// claimed /plan hint and the plan-mode textarea placeholder share one
// string: both describe the same next action.
const HINT_NS = 'command.hint'
const PLAN_HINT_ZH = '描述你的任务以生成计划'
const PLAN_HINT_EN = 'describe your task to generate plan'
ctx.effect(() => {
const disposers = [
ctx.locale.register(HINT_NS, 'zh', {
plan: PLAN_HINT_ZH,
goal: '输入目标,智能体将持续执行',
'goal.active': '当前目标进行中。可输入 edit 修改 / pause 暂停 / resume 继续 / clear 清除',
'placeholder.plan': PLAN_HINT_ZH,
'placeholder.default': '给智能体发消息',
}),
ctx.locale.register(HINT_NS, 'en', {
plan: PLAN_HINT_EN,
goal: 'describe the objective for a long-running task',
'goal.active': 'goal active — edit / pause / resume / clear',
'placeholder.plan': PLAN_HINT_EN,
'placeholder.default': 'Message the agent',
}),
]
return () => { for (const dispose of disposers) dispose() }
}, 'ui-conversation: command hint dictionaries')
const translateHint = ctx.locale.bind(HINT_NS)
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-conversation: dictionaries')
// Registration-time text (the view tab label) reads through the bound
// translate as a thunk, so it follows the active locale without
// re-registration; components read the standard `t` seat instead.
const t = ctx.locale.bind(NS)
// Apply-time construction keeps store identity bound to this fiber.
const chatStore = createChatStore()
// Chat scroll offsets by session, surviving view switches (the chat view
// unmounts under the tab ring). Deliberately not persisted: a fresh page
// load should keep the open-jump-to-bottom default.
const chatScrollTops = new Map<SessionId, number>()
const viewTabs = (): ViewTab[] => {
const tabs: ViewTab[] = []
for (const entry of slots.entries('conversation.view')) {
/* v8 ignore next -- unreachable: list registration validates id at load. */
if (entry.options.id === undefined) continue
tabs.push({ id: entry.options.id, label: entry.options.label ?? entry.options.id })
tabs.push({ id: entry.options.id, label: resolveSlotLabel(entry.options.label) ?? entry.options.id })
}
return tabs
}
@@ -132,6 +130,7 @@ export function apply(ctx: Context): void {
// frame while strict session slots fill only their session-bound regions.
slots.register({
name: 'conversation',
locale: NS,
children: {
'conversation.session': { kind: 'single', scope: 'session' },
'conversation.composer': { kind: 'chain', scope: 'session' },
@@ -163,6 +162,7 @@ export function apply(ctx: Context): void {
// the resident parent keeps Hero and composer layout identity stable.
slots.register({
name: 'conversation.session',
locale: NS,
children: { 'conversation.view': { kind: 'list', scope: 'session' } },
store: chatStore,
inject: (sessionId: SessionId, _actions: BoundActions<typeof chatStore>): ConversationSessionInjected => ({
@@ -185,6 +185,7 @@ export function apply(ctx: Context): void {
// observableHook caching and hook order stay stable across transitions).
slots.register({
name: 'conversation.composer.bar',
locale: NS,
// The two named control seats in the bar's tool row (plan beside the
// access control, model right); empty until their owning plugins
// register (B ruling).
@@ -196,15 +197,28 @@ export function apply(ctx: Context): void {
if (sessionId === undefined) {
return {
keyboard: undefined,
toggleCommandMenu: undefined,
stop: undefined,
command: undefined,
translateHint,
hooks: { notices: ABSENT_NOTICES, lexicon: ABSENT_LEXICON },
hooks: { notices: ABSENT_NOTICES, lexicon: ABSENT_LEXICON, menuLauncher: ABSENT_MENU_LAUNCHER },
}
}
const shell = inputHub.shell(sessionId)
const slash = inputHub.slash(sessionId)
return {
keyboard: shell,
toggleCommandMenu: slash === undefined
? undefined
: (selection) => {
shell.dismissPopup()
const snapshot = shell.snapshot
slash.toggleSource('command', {
trigger: '/',
query: '',
position: snapshot.draft.slice(0, selection.start).trim() === '' ? 'leading' : 'inline',
span: { ...selection, draftRev: snapshot.draftRev },
})
},
stop: () => {
scopedConversation(sessions, sessionId).cancel().catch(() => {
// Stop failure surfaces via snapshot.promptError; nothing to restore.
@@ -216,8 +230,11 @@ export function apply(ctx: Context): void {
const result = await session.command(line)
return result.ok && result.value.matched
},
translateHint,
hooks: { notices: shell.notices, lexicon: shell.lexicon },
hooks: {
notices: shell.notices,
lexicon: shell.lexicon,
menuLauncher: slash?.launcher ?? ABSENT_MENU_LAUNCHER,
},
}
},
}, InputBar)
@@ -230,7 +247,7 @@ export function apply(ctx: Context): void {
// pending — a question is a conversation the model is waiting on, while an
// approval only blocks one tool call; answering the question first cannot
// strand the approval (it re-elects the moment the question resolves).
slots.register({ name: 'conversation.composer', select: selectApproval, priority: 1 }, ApprovalPanel)
slots.register({ name: 'conversation.composer', select: selectApproval, priority: 1, locale: NS }, ApprovalPanel)
// The chat view: first entry of the ring this package just declared.
// Declaring the keyed toolview hole here is claiming it: ChatView is the
@@ -241,7 +258,8 @@ export function apply(ctx: Context): void {
name: 'conversation.view',
id: 'chat',
order: 0,
label: 'Chat',
label: () => t('view.chat'),
locale: NS,
children: {
'conversation.chat.toolview': { kind: 'keyed', scope: 'session' },
'conversation.chat.commandview': { kind: 'keyed', scope: 'session' },
@@ -262,6 +280,19 @@ export function apply(ctx: Context): void {
})
},
loadOlder: () => { void scoped.loadOlder() },
// Unregistered 'trajectory' id is safe: the tab ring falls back to
// the first view, and the untouched inspect target stays inert.
inspectCall: (callId) => {
actions.setInspect({ callId })
actions.setView('trajectory')
},
chatScroll: {
save: (top) => {
if (top === null) chatScrollTops.delete(sessionId)
else chatScrollTops.set(sessionId, top)
},
read: () => chatScrollTops.get(sessionId) ?? null,
},
forkAt: (seq) => {
sessions.fork({ sessionId, atSeq: seq, increaseTitle: true })
.then((childId) => { sessions.open(childId) })
@@ -288,6 +319,11 @@ export function apply(ctx: Context): void {
// (ToolRow-matching Bash · {description} chrome; scoped badge in child sessions).
ctx.plugin(bashToolviewSample)
// The web rows ride the same seam: one WebRow registered under both
// web_search and web_fetch, rendering the completed retrieval's web card
// resident under the summary (a product registration, not a sample).
ctx.plugin(webToolview)
// The todo_write row rides the same seam (a product registration, not a sample).
ctx.plugin(todoToolview)
@@ -303,6 +339,7 @@ export function apply(ctx: Context): void {
slots.register({
name: 'details',
locale: NS,
store: chatStore,
inject: (): DetailsInjected => ({
closeDetails: () => { layout.closeDetails() },

View File

@@ -4,14 +4,16 @@
// view groups them into tool rows through its keyed toolview slot (figma
// step-summary flow). Shared by finalized nodes and the streaming partial;
// the turn-level loading dots live in the chat view's tail, not here.
// Finalized content (text) nodes append IconActions once streaming ends;
// Think / tool-head-only nodes stay chrome-free.
// Finalized turn-tail content (text) nodes append IconActions once streaming
// ends (`time` is omitted for mid-turn narration); Think / tool-head-only
// nodes stay chrome-free.
import { memo } from 'react'
import { memo, useMemo } from 'react'
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
import {
IconThinkOutline14, JsonBlock, MarkdownText,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { MessageIconActions } from './MessageIconActions.tsx'
import { ToolRow } from './ToolRow.tsx'
import css from './AssistantMarkdown.module.css'
@@ -19,14 +21,17 @@ import css from './AssistantMarkdown.module.css'
export interface AssistantMarkdownProps {
blocks: readonly AssistantBlock[]
streaming: boolean
/** Frozen partial of an aborted turn: rendered with a 已停止 marker. */
/** Frozen partial of an aborted turn: rendered with a stopped marker. */
interrupted?: boolean | undefined
/** Unix epoch ms for the finalized IconActions clock; omitted while streaming. */
/** Unix epoch ms for the IconActions clock; omitted while streaming or when
* the parent withholds chrome (mid-turn content assistants). */
time?: number | undefined
/** Event sequence used as the fork boundary; omitted while streaming. */
seq?: number | undefined
/** Fork the session through the turn containing this finalized message. */
onFork?: ((seq: number) => void) | undefined
/** The owning view's locale seat, passed down as a plain prop. */
t: ChatViewSlotProps['t']
}
function firstLine(text: string): string {
@@ -49,23 +54,26 @@ function hasContentText(blocks: readonly AssistantBlock[]): boolean {
}
/** Reasoning block as the Think variant summary row (figma 39:28304). */
function ThinkRow({ text, running }: { text: string; running: boolean }) {
function ThinkRow({ text, running, t }: { text: string; running: boolean; t: AssistantMarkdownProps['t'] }) {
return (
<ToolRow
t={t}
variant="think"
icon={<IconThinkOutline14 size={14} />}
title="Think"
summary={firstLine(text)}
body={text}
state={running ? 'running' : 'ok'}
expandOnRowClick
/>
)
}
export const AssistantMarkdown = memo(function AssistantMarkdown({
blocks, streaming, interrupted, time, seq, onFork,
blocks, streaming, interrupted, time, seq, onFork, t,
}: AssistantMarkdownProps) {
// Stable per locale revision (t identity changes on switch): a fresh object
// per render would rebuild MarkdownText's component table every chunk.
const codeLabels = useMemo(() => ({ copyLabel: t('copy'), copiedLabel: t('copied') }), [t])
const last = blocks.length - 1
// Tool-call heads render as tool rows in the chat view's grouping pass, so
// a node that is only those heads (or empty) would paint an empty root
@@ -81,14 +89,23 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
<div className={css.body}>
{blocks.map((block, i) => {
switch (block.kind) {
case 'text': return <MarkdownText key={i} text={block.text} streaming={streaming} />
case 'reasoning': return <ThinkRow key={i} text={block.text} running={streaming && i === last} />
case 'text': return (
<MarkdownText key={i} text={block.text} streaming={streaming} codeLabels={codeLabels} />
)
case 'reasoning': return <ThinkRow key={i} text={block.text} running={streaming && i === last} t={t} />
// Grouped into tool rows by ChatView; hasVisible above skips an empty shell.
case 'tool-call': return null
default: return <JsonBlock key={i} label="未知内容块" payload={block.block} />
default: return (
<JsonBlock
key={i}
label={t('message.unknownBlock')}
payload={block.block}
truncatedLabel={total => t('json.truncated', { total })}
/>
)
}
})}
{interrupted && <span className={css.stopped}></span>}
{interrupted && <span className={css.stopped}>{t('message.stopped')}</span>}
</div>
{showActions && (
<MessageIconActions
@@ -97,6 +114,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
clock="end"
onBranch={onFork === undefined || seq === undefined ? undefined : () => { onFork(seq) }}
className={css.actions}
t={t}
/>
)}
</div>

View File

@@ -30,7 +30,7 @@ import type {
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { deriveChatFlow, type ChatFlowItem } from './chat-flow.ts'
import { assistantActionsSeqs, deriveChatFlow, type ChatFlowItem } from './chat-flow.ts'
import { AssistantMarkdown } from './AssistantMarkdown.tsx'
import { GenericCommandCard } from './GenericCommandCard.tsx'
import { GenericToolCard } from './GenericToolCard.tsx'
@@ -46,6 +46,8 @@ function scrollerOf(from: HTMLElement): HTMLElement {
type OpenFile = (path: string) => void
type InspectCall = (callId: string) => void
/** The declared toolview hole's render share (stable framework binding, passed through memoized rows). */
type RenderToolRow = ChatViewSlotProps['renderSlot']
@@ -53,27 +55,41 @@ type RenderToolRow = ChatViewSlotProps['renderSlot']
* chat view narrows once to the runtime snapshot the binding actually feeds. */
type UseConversation = SnapshotSelectorHook<ConversationSnapshot>
function activeRetrySeq(nodes: readonly ConversationNode[], running: boolean): number | null {
if (!running) return null
for (let index = nodes.length - 1; index >= 0; index -= 1) {
const node = nodes[index]
if (node === undefined) continue
if (node.kind === 'model-retry') return node.retryState === 'cancelled' ? null : node.seq
if (node.kind === 'assistant' || node.kind === 'user') return null
}
return null
}
/** One `run_code` sub-dispatch row: the identical keyed-slot dispatch as a
* top-level call (same registrations, same fallback), nested by the parent.
* A started-but-unsettled sub-call arrives as the RunningToolCall shape and
* renders the running state exactly as a native in-flight row. */
const SubCallRow = memo(function SubCallRow({ renderSlot, node, openFile, selected, cwd }: {
const SubCallRow = memo(function SubCallRow({ renderSlot, node, openFile, selected, cwd, inspectCall, t }: {
renderSlot: RenderToolRow
node: CodeSubCall
openFile: OpenFile
selected: boolean
cwd: string | undefined
inspectCall: InspectCall
t: ChatViewSlotProps['t']
}) {
const settled = 'kind' in node
const toolName = settled ? node.call?.name ?? '' : node.name
const owner = useMemo(() => ({
callId: node.callId, toolName, block: node, openFile, cwd,
}), [node, toolName, openFile, cwd])
inspect: () => { inspectCall(node.callId) },
}), [node, toolName, openFile, cwd, inspectCall])
return (
<div className={css.callRow} data-selected={selected || undefined}>
{renderSlot('conversation.chat.toolview', owner, {
entryKey: toolName,
fallback: <GenericToolCard {...owner} />,
fallback: <GenericToolCard {...owner} t={t} />,
})}
</div>
)
@@ -85,7 +101,7 @@ const SubCallRow = memo(function SubCallRow({ renderSlot, node, openFile, select
* renders its logged sub-dispatches as always-visible indented rows —
* each one the same keyed-slot dispatch as a native top-level call. */
const CallRow = memo(function CallRow({
renderSlot, callId, toolName, block, openFile, selected, subCalls, selectedCallId, cwd,
renderSlot, callId, toolName, block, openFile, selected, subCalls, selectedCallId, cwd, inspectCall, t,
}: {
renderSlot: RenderToolRow
callId: string
@@ -100,15 +116,18 @@ const CallRow = memo(function CallRow({
selectedCallId?: string | undefined
/** Session workspace root for path-relative summaries. */
cwd: string | undefined
inspectCall: InspectCall
t: ChatViewSlotProps['t']
}) {
const owner = useMemo(() => ({
callId, toolName, block, openFile, cwd,
}), [callId, toolName, block, openFile, cwd])
inspect: () => { inspectCall(callId) },
}), [callId, toolName, block, openFile, cwd, inspectCall])
return (
<div className={css.callRow} data-selected={selected || undefined}>
{renderSlot('conversation.chat.toolview', owner, {
entryKey: toolName,
fallback: <GenericToolCard {...owner} />,
fallback: <GenericToolCard {...owner} t={t} />,
})}
{subCalls !== undefined && subCalls.length > 0 && (
<div className={css.subCalls} data-subcalls>
@@ -120,6 +139,8 @@ const CallRow = memo(function CallRow({
openFile={openFile}
selected={node.callId === selectedCallId}
cwd={cwd}
inspectCall={inspectCall}
t={t}
/>
))}
</div>
@@ -129,7 +150,7 @@ const CallRow = memo(function CallRow({
})
/** Consecutive tool results as one step-run group (uniform 16px rhythm). */
const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selectedCallId, codeDispatches, cwd }: {
const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selectedCallId, codeDispatches, cwd, inspectCall, t }: {
renderSlot: RenderToolRow
results: readonly ToolResultNode[]
openFile: OpenFile
@@ -139,6 +160,8 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selec
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
/** Session workspace root for path-relative summaries. */
cwd: string | undefined
inspectCall: InspectCall
t: ChatViewSlotProps['t']
}) {
return (
<div className={css.toolGroup}>
@@ -154,6 +177,8 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selec
subCalls={codeDispatches.get(node.callId)}
selectedCallId={selectedCallId}
cwd={cwd}
inspectCall={inspectCall}
t={t}
/>
))}
</div>
@@ -163,16 +188,17 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selec
/** One command lifecycle row: keyed dispatch on the command name with the
* generic card as the render-site fallback (zero registration required). A
* run-less cross-window node has no name and always lands on the fallback. */
const CommandRow = memo(function CommandRow({ renderSlot, node }: {
const CommandRow = memo(function CommandRow({ renderSlot, node, t }: {
renderSlot: RenderToolRow
node: CommandNode
t: ChatViewSlotProps['t']
}) {
const owner = useMemo(() => ({ node }), [node])
return (
<div className={css.callRow}>
{renderSlot('conversation.chat.commandview', owner, {
entryKey: node.name ?? '',
fallback: <GenericCommandCard {...owner} />,
fallback: <GenericCommandCard {...owner} t={t} />,
})}
</div>
)
@@ -214,23 +240,26 @@ function TurnDots() {
/** The streaming partial, isolated so chunk batches re-render only this tail.
* onGrow lets the scroll owner follow content the parent never re-renders for. */
function StreamingTail({ useSession, onGrow }: {
function StreamingTail({ useSession, onGrow, t }: {
useSession: UseConversation
onGrow: () => void
t: ChatViewSlotProps['t']
}) {
const partial = useSession(s => s.partial)
useLayoutEffect(() => {
onGrow()
})
if (partial === null) return null
return <AssistantMarkdown blocks={partial.blocks} streaming />
return <AssistantMarkdown blocks={partial.blocks} streaming t={t} />
}
/**
* The chat view slot entry: pure component over the composed props (tool rows
* render through the declared keyed hole's renderSlot share).
*/
export function ChatView({ useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, forkAt }: ChatViewSlotProps) {
export function ChatView({
useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt, t,
}: ChatViewSlotProps) {
const nodes = useSession(s => s.nodes)
// Workspace root off the session list row: path summaries display relative to it.
const cwd = useSessions(s => s.byId[sessionId]?.cwd)
@@ -238,12 +267,16 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
const runningCalls = useSession(s => s.runningCalls)
const codeDispatches = useSession(s => s.codeDispatches)
const openState = useSession(s => s.openState)
const openErrorMessage = useSession(s => s.openError === null ? null : `${s.openError.message}${s.openError.code}`)
const openError = useSession(s => s.openError)
const hasMore = useSession(s => s.hasMore)
const loadingOlder = useSession(s => s.loadingOlder)
const selectedCallId = useStore(s => s.selection?.callId)
const items = useMemo(() => deriveChatFlow(nodes), [nodes])
const activeRetry = useMemo(() => activeRetrySeq(nodes, running), [nodes, running])
// Only the last content assistant of each turn owns IconActions; mid-turn
// text (before tools) omits `time` so AssistantMarkdown stays chrome-free.
const actionSeqs = useMemo(() => assistantActionsSeqs(nodes), [nodes])
const listRef = useRef<HTMLDivElement | null>(null)
const atBottomRef = useRef(true)
@@ -274,10 +307,20 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
/* v8 ignore next -- ref-null guard: React attaches the ref before layout effects run. */
if (local === null) return
const el = scrollerOf(local)
// Open completed: jump to the bottom once.
// Open completed: jump to the bottom once — unless a scroll position
// survives from a previous mount (view-tab switch away and back), which
// is restored instead of snapping the reader back to the floor.
if (openState === 'open' && !openedRef.current) {
openedRef.current = true
toBottom(el)
const saved = chatScroll.read()
if (saved === null) {
toBottom(el)
} else {
el.scrollTop = saved
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1
atBottomRef.current = isAtBottom
setAtBottom(isAtBottom)
}
firstSeqRef.current = firstSeq
lastKeyRef.current = lastKey
followSigRef.current = followSig
@@ -315,6 +358,9 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1
atBottomRef.current = isAtBottom
setAtBottom(isAtBottom)
// Continuous save (unmount happens after ref detach, so saving there is
// too late); pinned-to-bottom clears so a remount keeps following.
chatScroll.save(isAtBottom ? null : el.scrollTop)
}
// Bind scroll to the resolved scrollport (host or local) once per mount.
@@ -365,6 +411,8 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
selectedCallId={inGroup ? selectedCallId : undefined}
codeDispatches={codeDispatches}
cwd={cwd}
inspectCall={inspectCall}
t={t}
/>
)
}
@@ -376,35 +424,48 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
blocks={node.blocks}
streaming={false}
interrupted={node.interrupted}
time={node.time}
time={actionSeqs.has(node.seq) ? node.time : undefined}
seq={node.seq}
onFork={forkAt}
t={t}
/>
)
}
if (node.kind === 'command') {
return <CommandRow key={item.key} renderSlot={renderSlot} node={node} />
return <CommandRow key={item.key} renderSlot={renderSlot} node={node} t={t} />
}
/* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */
if (node.kind === 'tool-result') return null
return <MessageItem key={item.key} node={node} onFork={forkAt} />
return (
<MessageItem
key={item.key}
node={node}
retryActive={node.kind === 'model-retry' && node.seq === activeRetry}
onFork={forkAt}
t={t}
/>
)
}
return (
<div className={css.root}>
<div ref={listRef} className={css.scroll}>
<div className={css.column}>
{openState === 'loading' && <div className={css.hint}></div>}
{openState === 'error' && <div className={css.openError}>{openErrorMessage}</div>}
{openState === 'loading' && <div className={css.hint}>{t('chat.loadingHistory')}</div>}
{openState === 'error' && openError !== null && (
<div className={css.openError}>
{t('chat.loadError', { message: openError.message, code: openError.code })}
</div>
)}
{hasMore && (
<div className={css.older}>
<button type="button" disabled={loadingOlder} onClick={loadOlderAnchored}>
{loadingOlder ? '加载中…' : '加载更早'}
{loadingOlder ? t('loading') : t('chat.loadOlder')}
</button>
</div>
)}
{items.map(renderItem)}
<StreamingTail useSession={useSession} onGrow={onGrow} />
<StreamingTail useSession={useSession} onGrow={onGrow} t={t} />
{runningCalls.length > 0 && (
<div className={css.toolGroup}>
{runningCalls.map(call => (
@@ -419,6 +480,8 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
subCalls={codeDispatches.get(call.callId)}
selectedCallId={selectedCallId}
cwd={cwd}
inspectCall={inspectCall}
t={t}
/>
))}
</div>
@@ -435,7 +498,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
<button
type="button"
className={css.toBottom}
aria-label="回到底部"
aria-label={t('chat.toBottom')}
onClick={() => {
const local = listRef.current
/* v8 ignore next -- ref-null guard: the button only renders alongside the mounted list. */

View File

@@ -1,5 +1,6 @@
import { useMemo, useState } from 'react'
import type { ContextMessageNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { IconBrowseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import { DisclosureRow } from './DisclosureRow.tsx'
import css from './ContextInjectionRow.module.css'
@@ -47,6 +48,8 @@ function inlineJson(payload: unknown): string {
export interface ContextInjectionRowProps {
content: ContextMessageNode['content']
source: ContextMessageNode['source']
/** The owning view's locale seat, passed down as a plain prop. */
t: ChatViewSlotProps['t']
}
/**
@@ -54,22 +57,22 @@ export interface ContextInjectionRowProps {
* @param props - Durable content and source provenance.
* @returns A collapsed context row with a bounded JSON body.
*/
export function ContextInjectionRow({ content, source }: ContextInjectionRowProps) {
export function ContextInjectionRow({ content, source, t }: ContextInjectionRowProps) {
const [open, setOpen] = useState(false)
const body = useMemo(() => {
if (!open) return ''
const text = inlineJson({ content, source })
return text.length > MAX_CHARS
? `${text.slice(0, MAX_CHARS)}\n… 已截断,共 ${text.length} 字符`
? `${text.slice(0, MAX_CHARS)}\n${t('json.truncated', { total: text.length })}`
: text
}, [content, open, source])
}, [content, open, source, t])
return (
<DisclosureRow
className={css.root}
icon={<IconBrowseOutline16 size={14} />}
chevronClassName={css.chevron}
title="上下文注入"
title={t('message.contextInjection')}
open={open}
expandable
expandOnRowClick

View File

@@ -14,6 +14,8 @@ export interface DisclosureRowProps {
expandOnRowClick?: boolean | undefined
/** Replaces the collapsed icon with a chevron while the row is hovered. */
previewChevron?: boolean | undefined
/** Keeps `collapsedContent` inline while open (ToolRow's summary stays readable next to the expanded card). */
keepContentWhenOpen?: boolean | undefined
collapsedContent?: ReactNode
children?: ReactNode
className?: string | undefined
@@ -36,6 +38,7 @@ export function DisclosureRow({
onToggle,
expandOnRowClick = false,
previewChevron = expandable,
keepContentWhenOpen = false,
collapsedContent,
children,
className,
@@ -93,7 +96,7 @@ export function DisclosureRow({
</span>
)}
<span className={clsx(css.title, titleClassName)}>{title}</span>
{!open && collapsedContent}
{(keepContentWhenOpen || !open) && collapsedContent}
</div>
{open && children}
</div>

View File

@@ -6,7 +6,7 @@
import { ToolRow } from './ToolRow.tsx'
import type { ToolRowState } from '../contract/tool-call-model.ts'
import type { CommandRowOwnerProps } from '../contract/slots.ts'
import type { ChatViewSlotProps, CommandRowOwnerProps } from '../contract/slots.ts'
import { IconApiOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
/** Node state → row state semantic (running while unsettled; outcome kind after). */
@@ -15,20 +15,26 @@ function stateOf(outcome: CommandRowOwnerProps['node']['outcome']): ToolRowState
return outcome.kind === 'error' ? 'error' : 'ok'
}
export function GenericCommandCard({ node }: CommandRowOwnerProps) {
/** Card props: the owner payload plus the render site's locale seat (plain prop). */
export interface GenericCommandCardProps extends CommandRowOwnerProps {
t: ChatViewSlotProps['t']
}
export function GenericCommandCard({ node, t }: GenericCommandCardProps) {
const text = node.outcome?.text
const summary = node.outcome === null
? '执行中…'
: text ?? (node.outcome.kind === 'error' ? '命令失败' : '已完成')
? t('command.running')
: text ?? (node.outcome.kind === 'error' ? t('command.failed') : t('command.done'))
// Title is the bare command name: the row already reads `name · outcome`,
// and the dispatched line's own `/` and arguments only restate what the
// settlement text says (`permission · preset workspace-write`). A
// cross-window node whose run page fell out of the window has no name.
const title = node.name ?? '命令'
const title = node.name ?? t('command.title')
return (
<ToolRow
t={t}
variant="others"
icon={<IconApiOutline14 size={16} />}
icon={<IconApiOutline14 size={14} />}
title={title}
summary={summary}
// Expandable only when the outcome text overflows a one-line summary.

View File

@@ -0,0 +1,15 @@
/* The generic card grows a resident web card under its summary row when the
tool declares the `web` render intent but has no keyed row of its own (the
web_search/web_fetch rows register their own WebRow). A column around the
ToolRow keeps the row's own 24px height. */
.card {
display: flex;
flex-direction: column;
}
/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap),
and replaces the primitive's standalone vertical margin with the flow's. */
.web {
margin: 4px 0 4px 22px;
}

View File

@@ -7,12 +7,14 @@
import type { ReactNode } from 'react'
import {
IconApiOutline14, IconBrowseOutline16, IconCodeOutline16, IconEditOutline16, IconSearchOutline16, IconSparkle16,
IconThinkOutline14,
IconThinkOutline14, WebBlock,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolRowOwnerProps } from '../contract/slots.ts'
import { terminalCardModel } from '../contract/terminal-card-model.ts'
import type { ChatViewSlotProps, ToolRowOwnerProps } from '../contract/slots.ts'
import { terminalCardModel, terminalFailed } from '../contract/terminal-card-model.ts'
import { CHAT_WEB_MAX_SOURCES, webCardModel } from '../contract/web-card-model.ts'
import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.ts'
import { ToolRow } from './ToolRow.tsx'
import css from './GenericToolCard.module.css'
/** Variant leading icons (figma table); all glyphs render at 14 inside the 16px leading box. */
const VARIANT_ICONS: Record<ToolRowVariant, ReactNode> = {
@@ -26,12 +28,24 @@ const VARIANT_ICONS: Record<ToolRowVariant, ReactNode> = {
others: <IconSparkle16 size={14} />,
}
export function GenericToolCard({ toolName, block, cwd, openFile }: ToolRowOwnerProps) {
/** Card props: the owner payload plus the render site's locale seat (plain prop). */
export interface GenericToolCardProps extends ToolRowOwnerProps {
t: ChatViewSlotProps['t']
}
export function GenericToolCard({ toolName, block, cwd, openFile, inspect, t }: GenericToolCardProps) {
const model = toolRowModel(toolName, block, cwd)
const terminal = terminalCardModel(block, cwd)
const web = webCardModel(block)
// A failing exit status is the terminal card's own error signal (the call
// itself settles isError:false), surfaced as the row's red state dot.
const state = model.state === 'ok' && terminal !== null && terminalFailed(terminal)
? 'error'
: model.state
const singleFile = model.filePath !== undefined
return (
const row = (
<ToolRow
t={t}
variant={model.variant}
toolName={toolName}
icon={VARIANT_ICONS[model.variant]}
@@ -39,12 +53,23 @@ export function GenericToolCard({ toolName, block, cwd, openFile }: ToolRowOwner
// A terminal presenter's description is the contract's above-card text, so
// it outranks the args-derived summary here exactly as it does in BashRow.
summary={terminal?.description ?? model.summary}
// Single-file tools never expose an args body — the path link is the only action.
body={singleFile ? null : model.body}
body={model.body}
output={model.output}
errorSummary={model.errorSummary}
terminal={terminal}
state={model.state}
state={state}
filePath={model.filePath}
onOpenFile={singleFile ? openFile : undefined}
inspect={inspect}
/>
)
// A web-declaring tool without its own keyed row lands here; its card is
// resident under the summary, mirroring WebRow (and BashRow's terminal card).
if (web === null) return row
return (
<div className={css.card}>
{row}
<WebBlock {...web} maxSources={CHAT_WEB_MAX_SOURCES} className={css.web} />
</div>
)
}

View File

@@ -6,6 +6,7 @@ import { useCallback } from 'react'
import {
IconBranchOutline16, IconCopyOutline16, IconEditOutline16, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { formatMessageClock, writeClipboard } from './message-chrome.ts'
import { useCalendarDay } from './use-calendar-day.ts'
import css from './MessageIconActions.module.css'
@@ -23,6 +24,8 @@ export interface MessageIconActionsProps {
onBranch?: (() => void) | undefined
/** Parent layout class composed onto the actions row. */
className?: string | undefined
/** The owning view's locale seat, passed down as a plain prop. */
t: ChatViewSlotProps['t']
}
/**
@@ -31,7 +34,7 @@ export interface MessageIconActionsProps {
* @returns The actions row element.
*/
export function MessageIconActions({
text, time, clock, edit, onBranch, className,
text, time, clock, edit, onBranch, className, t,
}: MessageIconActionsProps) {
const day = useCalendarDay()
const onCopy = useCallback(() => {
@@ -39,25 +42,25 @@ export function MessageIconActions({
}, [text])
const clockEl = (
<span className={clock === 'start' ? css.timeStart : css.timeEnd}>
{formatMessageClock(time, day)}
{formatMessageClock(time, t, day)}
</span>
)
return (
<div className={className === undefined ? css.actions : `${css.actions} ${className}`}>
{clock === 'start' ? clockEl : null}
<Tooltip label="复制" side="bottom">
<button type="button" className={css.action} aria-label="复制" onClick={onCopy}>
<Tooltip label={t('copy')} side="bottom">
<button type="button" className={css.action} aria-label={t('copy')} onClick={onCopy}>
<IconCopyOutline16 />
</button>
</Tooltip>
<Tooltip label="在新对话中分支" side="bottom">
<button type="button" className={css.action} aria-label="在新对话中分支" onClick={onBranch}>
<Tooltip label={t('message.branch')} side="bottom">
<button type="button" className={css.action} aria-label={t('message.branch')} onClick={onBranch}>
<IconBranchOutline16 />
</button>
</Tooltip>
{edit === true && (
<Tooltip label="编辑" side="bottom">
<button type="button" className={css.action} aria-label="编辑">
<Tooltip label={t('edit')} side="bottom">
<button type="button" className={css.action} aria-label={t('edit')}>
<IconEditOutline16 />
</button>
</Tooltip>

View File

@@ -34,6 +34,106 @@
padding: 2px 0;
}
.retryRow {
color: var(--dsw-alias-label-tertiary);
font-size: 13px;
line-height: 20px;
}
.retrySummary {
display: inline-flex;
align-items: center;
width: fit-content;
padding: 2px 0;
gap: 7px;
border-radius: 3px;
color: inherit;
cursor: pointer;
list-style: none;
user-select: none;
}
.retrySummary::-webkit-details-marker {
display: none;
}
.retrySummary::after {
width: 6px;
height: 6px;
border-right: 1.5px solid currentcolor;
border-bottom: 1.5px solid currentcolor;
content: '';
opacity: 0.8;
transform: rotate(-45deg);
transition: transform 120ms ease;
}
.retrySummary:hover {
color: var(--dsw-alias-label-secondary);
}
.retrySummary:focus-visible {
outline: 1.5px solid var(--dsw-alias-button-info-fill);
outline-offset: 2px;
}
.retryText {
color: inherit;
}
.retryRow[data-active] .retryText {
background:
linear-gradient(
90deg,
var(--dsw-alias-label-tertiary) 0%,
var(--dsw-alias-label-tertiary) 40%,
var(--dsw-alias-label-secondary) 50%,
var(--dsw-alias-label-tertiary) 60%,
var(--dsw-alias-label-tertiary) 100%
);
background-position: 100% 50%;
background-size: 200% 100%;
background-clip: text;
color: transparent;
animation: retry-shimmer 1.6s ease-in-out infinite;
}
.retryRow[open] .retrySummary::after {
transform: rotate(45deg);
}
.retryDetails {
display: grid;
gap: 2px;
margin-top: 3px;
padding-left: 14px;
overflow-wrap: anywhere;
font-size: 12px;
line-height: 18px;
}
.retryDetailLabel {
color: var(--dsw-alias-label-secondary);
}
@keyframes retry-shimmer {
from {
background-position: 100% 50%;
}
to {
background-position: 0 50%;
}
}
@media (prefers-reduced-motion: reduce) {
.retryRow[data-active] .retryText {
background: none;
color: inherit;
animation: none;
}
}
/* Reference chip projection inside a user bubble (`<skill>name</skill>` model
spans render as chips; free geometry — no textarea pairing here). */
.refChip {

View File

@@ -1,23 +1,25 @@
// MessageItem: the four simple node kinds — user bubble (right-aligned, with
// MessageItem: simple chat nodes — user bubble (right-aligned, with
// clock + copy / branch / edit IconActions), steering (badged bubble), context
// injection and unknown-surface JSON rows. Props are frozen node slices off
// the snapshot cache; memo holds across streaming because unchanged nodes
// keep their references.
// injection, retry disclosure, and unknown-surface JSON rows.
import { memo } from 'react'
import { memo, useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import type {
ContextMessageNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode,
ContextMessageNode, ModelRetryNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { ContextInjectionRow } from './ContextInjectionRow.tsx'
import { MessageIconActions } from './MessageIconActions.tsx'
import css from './MessageItem.module.css'
export interface MessageItemProps {
node: UserMessageNode | SteeringMessageNode | ContextMessageNode | UnknownSurfaceNode
node: UserMessageNode | SteeringMessageNode | ContextMessageNode | ModelRetryNode | UnknownSurfaceNode
retryActive?: boolean
/** Fork the session through the turn containing this message (user-bubble branch action). */
onFork?: (seq: number) => void
/** The owning view's locale seat, passed down as a plain prop. */
t: ChatViewSlotProps['t']
}
function contentText(content: readonly unknown[]): { text: string; rest: unknown[] } {
@@ -31,6 +33,80 @@ function contentText(content: readonly unknown[]): { text: string; rest: unknown
return { text: texts.join(''), rest }
}
function retrySeconds(milliseconds: number): number {
return Math.max(1, Math.ceil(milliseconds / 1_000))
}
interface RetryCountdown {
deadline: number
seconds: number
}
function ModelRetryItem({ node, active, t }: {
node: ModelRetryNode
active: boolean
t: ChatViewSlotProps['t']
}) {
// Anchor the host-scheduled delay to this browser's first render of the
// retry node. Host event time and Date.now() may belong to different clocks.
const deadline = useMemo(() => Date.now() + node.delayMs, [node.delayMs, node.seq])
const scheduledSeconds = retrySeconds(node.delayMs)
const maximum = node.mode === 'normal' ? node.maxRetries : '∞'
const [countdown, setCountdown] = useState<RetryCountdown>(() => ({
deadline,
seconds: retrySeconds(deadline - Date.now()),
}))
const remainingSeconds = countdown.deadline === deadline
? countdown.seconds
: retrySeconds(deadline - Date.now())
useEffect(() => {
if (!active) return
const updateCountdown = (): number => {
const next = retrySeconds(deadline - Date.now())
setCountdown(current => (
current.deadline === deadline && current.seconds === next
? current
: { deadline, seconds: next }
))
return next
}
if (updateCountdown() === 1) return
const timer = window.setInterval(() => {
if (updateCountdown() === 1) window.clearInterval(timer)
}, 250)
return () => { window.clearInterval(timer) }
}, [active, deadline])
const label = active
? t('message.retry.active')
: node.retryState === 'cancelled'
? t('message.retry.cancelled')
: node.retryState === 'started'
? t('message.retry.started')
: t('message.retry.scheduled')
const seconds = active ? remainingSeconds : scheduledSeconds
return (
<details className={css.retryRow} data-active={active || undefined}>
<summary className={css.retrySummary}>
<span className={css.retryText} role="status">
{t('message.retry.status', { label, retry: node.retry, maximum, seconds })}
</span>
</summary>
<div className={css.retryDetails}>
<div>
<span className={css.retryDetailLabel}>{t('message.retry.delay')}</span>
{Math.round(node.delayMs)}ms
</div>
<div>
<span className={css.retryDetailLabel}>{t('message.retry.failure')}</span>
{node.failure.message}
</div>
</div>
</details>
)
}
/**
* Display projection of reference forms in a user bubble (free geometry — no
* textarea alignment constraint here); everything else stays plain text. The
@@ -63,7 +139,10 @@ function projectUserText(text: string): ReactNode {
return <>{parts}</>
}
export const MessageItem = memo(function MessageItem({ node, onFork }: MessageItemProps) {
export const MessageItem = memo(function MessageItem({
node, retryActive = false, onFork, t,
}: MessageItemProps) {
const truncated = (total: number): string => t('json.truncated', { total })
switch (node.kind) {
case 'user': {
const { text, rest } = contentText(node.content)
@@ -71,7 +150,7 @@ export const MessageItem = memo(function MessageItem({ node, onFork }: MessageIt
<div className={css.userRow}>
<div className={css.bubble}>
{projectUserText(text)}
{rest.map((block, i) => <JsonBlock key={i} label="附加内容块" payload={block} />)}
{rest.map((block, i) => <JsonBlock key={i} label={t('message.extraBlock')} payload={block} truncatedLabel={truncated} />)}
</div>
<MessageIconActions
text={text}
@@ -80,6 +159,7 @@ export const MessageItem = memo(function MessageItem({ node, onFork }: MessageIt
edit
onBranch={onFork === undefined ? undefined : () => { onFork(node.seq) }}
className={css.actions}
t={t}
/>
</div>
)
@@ -89,21 +169,23 @@ export const MessageItem = memo(function MessageItem({ node, onFork }: MessageIt
return (
<div className={css.userRow}>
<div className={css.bubble}>
<span className={css.badge}></span>
<span className={css.badge}>{t('message.steering')}</span>
{projectUserText(text)}
{rest.map((block, i) => <JsonBlock key={i} label="附加内容块" payload={block} />)}
{rest.map((block, i) => <JsonBlock key={i} label={t('message.extraBlock')} payload={block} truncatedLabel={truncated} />)}
</div>
</div>
)
}
case 'context':
return (
<ContextInjectionRow content={node.content} source={node.source} />
<ContextInjectionRow content={node.content} source={node.source} t={t} />
)
case 'model-retry':
return <ModelRetryItem node={node} active={retryActive} t={t} />
default:
return (
<div className={css.contextRow}>
<JsonBlock label={`未知 surface 事件:${node.type}`} payload={node.data} />
<JsonBlock label={t('message.unknownSurface', { type: node.type })} payload={node.data} truncatedLabel={truncated} />
</div>
)
}

View File

@@ -56,6 +56,10 @@
background: var(--dsw-alias-state-business-primary);
}
.chevron {
color: var(--dsw-alias-label-secondary);
}
.title {
font-weight: 400;
}
@@ -103,8 +107,65 @@
text-decoration: underline;
}
/* Expanded body: pad-left 22 indented gray text, no border, no fill. */
.body {
/* Error row's collapsed summary: the failure's first line in the error color. */
.errorSummary {
color: var(--dsw-alias-state-error-primary);
}
/* Expanded body + Inspect pill wrapper (sibling of .row: clicks never toggle). */
.bodyWrap {
display: flex;
flex-direction: column;
}
/* Hover-revealed jump to the trajectory record: a small pill in real flow
under the expanded body's bottom-left corner (it reserves its line, so
revealing never shifts layout); revealed by hovering anywhere on the tool
call — title row included — or by keyboard focus. */
.inspectButton {
display: inline-flex;
align-self: flex-start;
align-items: center;
gap: 4px;
margin: 4px 0 2px 4px;
padding: 2px 8px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 999px;
/* Base background, not bg-overlay: the overlay token is a raised dark
surface and reads too heavy for a quiet in-flow affordance. */
background: var(--dsw-alias-bg-base);
color: var(--dsw-alias-label-secondary);
font-size: 11px;
line-height: 16px;
cursor: pointer;
opacity: 0;
transition: opacity 100ms ease;
}
.root:hover .inspectButton,
.inspectButton:focus-visible {
opacity: 1;
}
/* Solid hover fill (a translucent token would let content bleed through). */
.inspectButton:hover {
background: var(--dsw-alias-interactive-bg-hover-solid);
color: var(--dsw-alias-label-primary);
}
/* Expanded-body scroll wrapper for the run_code CodeBlock; the IN/OUT card
and the terminal card scroll INSIDE their own surface instead, so the
scrollbar sits within the rounded card. */
.bodyScroll {
max-height: 260px;
overflow-y: auto;
}
/* Think expanded body: plain indented gray reasoning prose — no IN/OUT card
(the reasoning is not an input payload), pre-wrapped at the row's indent.
Uncapped: reasoning reads as message prose, so it flows with the page
instead of scrolling in a box. */
.thinkBody {
padding: 4px 0 4px 22px;
font-size: 14px;
line-height: 24px;
@@ -113,6 +174,78 @@
color: var(--dsw-alias-label-tertiary);
}
/* Expanded input/output card (figma 1249:35657): the code-block surface and
radius from the TerminalBlock/CodeBlock family. The card itself is a plain
column — the padding and the IN/OUT gutter-label grid live on each section
so the divider spans the full card width and each section scrolls alone. */
.ioCard {
display: flex;
flex-direction: column;
margin: 4px 0 4px 4px;
border: 1px solid var(--dsw-alias-border-l1);
border-radius: 12px;
background: var(--dsw-alias-markdown-code-block);
font: var(--dsw-font-markdown-code-block-small);
}
/* One card section (IN or OUT): the gutter-label grid, capped and scrolling
independently so a long input never buries a short output (and vice versa). */
.ioSection {
display: grid;
grid-template-columns: max-content 1fr;
column-gap: 14px;
align-items: baseline;
padding: 12px 16px;
max-height: 150px;
overflow-y: auto;
}
/* Card-internal scrollbar: a 2px transparent border clips the thumb inward so
it floats off the rounded card edge instead of hugging it (the terminal
card's own output scroller carries the same treatment in TerminalBlock). */
.ioSection::-webkit-scrollbar-thumb {
border: 2px solid transparent;
background-clip: padding-box;
border-radius: 6px;
}
/* Track end-margins keep the thumb's travel out of the rounded corners. */
.ioSection::-webkit-scrollbar-track {
margin: 6px 0;
}
/* Caption (not tertiary): one step dimmer than the payload text so the
gutter labels read as labels, not as part of the content. Sticky against
the section's own scroll so the label stays readable while its payload
scrolls underneath (top 0 = the section's padding edge inside the
scrollport; start-aligned because sticky needs a block-start anchor). */
.ioLabel {
position: sticky;
top: 0;
align-self: start;
color: var(--dsw-alias-label-caption);
}
/* l2 hairline between the IN and OUT sections, spanning the full card width
(it sits between the padded sections, not inside their grid). */
.ioDivider {
flex: none;
height: 1px;
background: var(--dsw-alias-border-l2);
}
.ioText {
min-width: 0;
white-space: pre-wrap;
word-break: break-word;
color: var(--dsw-alias-label-secondary);
}
/* A failed call's OUT text shares the collapsed summary's error color. */
.ioText[data-error] {
color: var(--dsw-alias-state-error-primary);
}
/* The two block-shaped expanded bodies: the code variant's run_code program
through CodeBlock (shiki-highlighted TypeScript) and a terminal card's
command output through TerminalBlock. Both are drawn by the shared
@@ -121,15 +254,21 @@
flow's row rhythm. */
.codeBody,
.terminalBody {
margin: 4px 0 4px 22px;
margin: 4px 0 4px 4px;
}
/* Indented to the body's own column so the description reads as the card's
heading rather than as another summary row, and sits tight against the card
below it. Its own rule: grouping it with a body would put description
typography on a `CodeBlock` wrapper and change that body's spacing. */
.terminalDescription {
margin: 4px 0 0 22px;
color: var(--dsw-alias-label-secondary);
font: var(--dsw-font-xs-13);
/* In-row code renders at the smaller code size (12/18) via each primitive's
rebindable content-font seam; standalone markdown code blocks keep 13/22. */
.codeBody {
--dsl-code-block-content-font: var(--dsw-font-markdown-code-block-small);
}
/* The terminal card scrolls its OUTPUT inside its own surface (same l1
hairline as the IN/OUT card): the banner stays pinned and the scrollbar
never rides over it. 224px = the 260px card cap minus the ~36px banner. */
.terminalBody {
--dsl-terminal-font: var(--dsw-font-markdown-code-block-small);
--dsl-terminal-line-height: 18px;
--dsl-terminal-output-max-height: 224px;
border: 1px solid var(--dsw-alias-border-l1);
}

View File

@@ -1,21 +1,33 @@
// ToolRow: the single-line tool summary row (figma component set 122:9479) —
// 16px leading slot (state dot / tool icon, chevron on hover or expanded) + title +
// separator dot + FILL-truncated summary. The collapsed row is always one
// line; the expanded body is indented gray text, the run_code program through
// CodeBlock, or — for a call whose render intent is a terminal card — the
// command's own output through TerminalBlock, capped at
// CHAT_TERMINAL_MAX_LINES so the message flow stays scannable. Expand state is
// component-local view state. File-tool summaries are path links that open
// through the host; the row itself is not a details-panel control.
// separator dot + FILL-truncated summary, drawn through the shared
// DisclosureRow chrome with the whole row as the expand toggle (click /
// Enter / Space, icon→chevron hover preview). The collapsed row is always
// one line; every row with body, output, or terminal material is expandable;
// the summary stays inline while open, except Think, whose body opens with
// the same first line and would repeat it.
// The expanded body — an IN/OUT gutter-labeled card (figma 1249:35657) for
// text input/output, the run_code program through CodeBlock, or a terminal
// card's command output through TerminalBlock — lives in a max-height scroll
// container so a long payload scrolls internally instead of taking over the
// message flow; Think's prose is the exception and flows uncapped like
// message text. Expand state is component-local view state. File-tool
// summaries are path links that open through the host (stopPropagation keeps
// the two gestures independent); an error row's collapsed summary is the
// failure's first line in the error color.
import { useState, type MouseEvent, type ReactNode } from 'react'
import clsx from 'clsx'
import { CodeBlock, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import { CHAT_TERMINAL_MAX_LINES, type TerminalCardModel } from '../contract/terminal-card-model.ts'
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
import { terminalBlockLabels, type TerminalCardModel } from '../contract/terminal-card-model.ts'
import type { ToolRowState, ToolRowVariant } from '../contract/tool-call-model.ts'
import { DisclosureRow } from './DisclosureRow.tsx'
import css from './ToolRow.module.css'
export interface ToolRowProps {
/** The render site's conversation locale seat (terminal/code body copy). */
t: TranslateNS<'conversation'>
variant: ToolRowVariant
/** Wire tool name for tool-owned styling layered over the generic variant. */
toolName?: string | undefined
@@ -23,18 +35,20 @@ export interface ToolRowProps {
icon: ReactNode
title: string
summary: string
/** Expanded-body text; null = no text body (`terminal` is the other body source). */
/** Expanded-body input text; null = no input section. */
body: string | null
/** Flattened result text for the expanded Output section; null/absent = no output section. */
output?: string | null | undefined
/** Error first line shown as the collapsed summary on an error row; null/absent = keep `summary`. */
errorSummary?: string | null | undefined
/**
* Terminal-card material for a call whose render intent is a terminal card
* (derived by `terminalCardModel`); it replaces the text body when present.
* Null or absent leaves the text body, and a row with neither is not
* expandable (its leading slot never toggles).
* (derived by `terminalCardModel`); it replaces the text sections when
* present. A row with no body, no output, and no terminal material is not
* expandable.
*/
terminal?: TerminalCardModel | null | undefined
state: ToolRowState
/** Makes the row itself the expand control instead of only its leading icon. */
expandOnRowClick?: boolean | undefined
/**
* Filesystem path from tool args; when set with onOpenFile, the summary
* renders as a hover-underline link that opens the host default app.
@@ -42,6 +56,21 @@ export interface ToolRowProps {
filePath?: string | undefined
/** Open the path with the host OS default application (already cwd-resolved). */
onOpenFile?: ((path: string) => void) | undefined
/**
* Jump to this call in the trajectory view: a hover-revealed Inspect pill
* over the expanded body. Absent = no affordance (rows without a call
* identity, like Think).
*/
inspect?: (() => void) | undefined
}
/** The Inspect pill's code glyph (user-supplied 16×16), fill follows text color. */
function IconInspect() {
return (
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden>
<path d="M16 8L10.8571 12V10.552L14.1383 8L10.8571 5.448V4L16 8ZM5.14286 10.552L1.86171 8L5.14286 5.448V4L0 8L5.14286 12V10.552ZM9.02514 4L5.59657 12H6.84057L10.2691 4H9.02514Z" fill="currentColor" />
</svg>
)
}
/** Leading-slot state substitution: the tool icon yields to the terminal state
@@ -56,32 +85,32 @@ function leadingFor(state: ToolRowState, icon: ReactNode): ReactNode {
}
export function ToolRow({
t,
variant,
toolName,
icon,
title,
summary,
body,
output,
errorSummary,
terminal,
state,
expandOnRowClick = false,
filePath,
onOpenFile,
inspect,
}: ToolRowProps) {
const [expanded, setExpanded] = useState(false)
const terminalBody = terminal ?? null
// A row that names a single file keeps one interaction (open that path);
// args expand is off whether or not the open callback is wired yet. Terminal
// material still expands: only the file variants carry a path, so a terminal
// card and a file link never land on the same row.
const singleFile = filePath !== undefined
const fileLink = singleFile && onOpenFile !== undefined
const expandable = (body !== null && !singleFile) || terminalBody !== null
// The text arms take the empty string for a null body: a row expandable
// only through its terminal material renders the terminal body instead, so
// this substitution never shows.
const text = body ?? ''
const outputText = output ?? null
const expandable = body !== null || outputText !== null || terminalBody !== null
const open = expanded && expandable
// An error row's collapsed summary IS the failure: the first error line in
// the error color outranks both the args summary and a terminal description.
const failureLine = state === 'error' ? errorSummary ?? null : null
const summaryText = failureLine ?? summary
// The failure line is error prose, not the path: no open-file affordance.
const fileLink = filePath !== undefined && onOpenFile !== undefined && failureLine === null
const toggleExpand = () => {
setExpanded(v => !v)
}
@@ -89,20 +118,33 @@ export function ToolRow({
event.stopPropagation()
if (filePath !== undefined) onOpenFile?.(filePath)
}
// Think reasoning is prose, not an input payload: expanded, it renders as
// plain indented text (no IN/OUT card) and the inline summary — the body's
// own first line — yields to avoid repeating itself.
const isThink = variant === 'think'
// The code variant's program renders through CodeBlock (shiki), so only its
// output joins the IN/OUT card; every other variant's input does too.
const cardBody = variant === 'code' ? null : body
// The state substitution rides the idle icon slot, so an expandable error
// row keeps DisclosureRow's icon→chevron hover preview (its default) instead
// of losing it with the icon.
return (
<div className={css.root} data-variant={variant} data-tool={toolName} data-state={state}>
<DisclosureRow
rowClassName={css.row}
leadingClassName={css.leading}
titleClassName={css.title}
chevronClassName={css.chevron}
icon={leadingFor(state, icon)}
title={title}
open={open}
expandable={expandable}
expandOnRowClick={expandOnRowClick}
previewChevron={expandable && state !== 'error' && state !== 'stopped'}
expandOnRowClick
keepContentWhenOpen={!isThink}
onToggle={toggleExpand}
collapsedContent={(
collapsedContent={summaryText !== '' && (
/* An empty summary drops the separator with it (a row that is only
its title shows no trailing dot). */
<>
<span className={css.sep} aria-hidden />
{fileLink ? (
@@ -111,24 +153,71 @@ export function ToolRow({
className={css.fileLink}
onClick={openFile}
>
{summary}
{summaryText}
</button>
) : (
<span className={css.summary}>{summary}</span>
<span className={clsx(css.summary, failureLine !== null && css.errorSummary)}>
{summaryText}
</span>
)}
</>
)}
>
{/* The terminal presenter's description belongs above the card per
the render-intent contract. */}
{terminalBody?.description !== undefined && (
<div className={css.terminalDescription}>{terminalBody.description}</div>
)}
{terminalBody !== null
? <TerminalBlock {...terminalBody.card} maxLines={CHAT_TERMINAL_MAX_LINES} className={css.terminalBody} />
: variant === 'code'
? <CodeBlock code={text} lang="typescript" className={css.codeBody} />
: <div className={css.body}>{text}</div>}
{/* The wrapper (sibling of the header row, so clicks inside never
toggle it) carries the expanded body and the Inspect pill below. */}
<div className={css.bodyWrap}>
{terminalBody !== null
? (
<TerminalBlock
{...terminalBody.card}
maxLines={Infinity}
labels={terminalBlockLabels(t)}
className={css.terminalBody}
/>
)
: isThink
? <div className={css.thinkBody}>{body}</div>
: (
<>
{variant === 'code' && body !== null && (
<div className={css.bodyScroll}>
<CodeBlock code={body} lang="typescript" copyLabel={t('copy')} copiedLabel={t('copied')} className={css.codeBody} />
</div>
)}
{(cardBody !== null || outputText !== null) && (
<div className={css.ioCard}>
{cardBody !== null && (
<div className={css.ioSection}>
<span className={css.ioLabel}>IN</span>
<span className={css.ioText}>{cardBody}</span>
</div>
)}
{cardBody !== null && outputText !== null && (
<span className={css.ioDivider} aria-hidden />
)}
{outputText !== null && (
<div className={css.ioSection}>
<span className={css.ioLabel}>OUT</span>
<span className={css.ioText} data-error={state === 'error' || undefined}>
{outputText}
</span>
</div>
)}
</div>
)}
</>
)}
{inspect !== undefined && (
<button
type="button"
className={css.inspectButton}
onClick={inspect}
>
<IconInspect />
Inspect
</button>
)}
</div>
</DisclosureRow>
</div>
)

View File

@@ -1,17 +1,27 @@
/**
* Chat flow derivation: ConversationSnapshot nodes -> render items. Tool
* results group into consecutive-run tool groups (figma step-summary flow,
* VERTICAL gap10) alternating with narration; everything else passes through.
* VERTICAL gap10) alternating with narration. Consecutive retry notices
* reuse the first notice's row while projecting the latest retry turn.
* Item identity keys are stable across snapshots so the list parent can
* subscribe to keys only while rows subscribe to content.
* subscribe to keys only while rows subscribe to content. IconActions ownership
* (last content assistant per turn) is derived here too so ChatView and the
* flow share one gate.
*/
import type { ConversationNode, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type {
AssistantBlock, ConversationNode, ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
/** One renderable flow item; key is the React key and the parent's identity unit. */
export type ChatFlowItem =
| { kind: 'node'; key: string; node: ConversationNode }
| { kind: 'tool-group'; key: string; results: readonly ToolResultNode[] }
/** True when the node has model-visible text content worth IconActions chrome. */
function hasContentText(blocks: readonly AssistantBlock[]): boolean {
return blocks.some(block => block.kind === 'text' && block.text.trim() !== '')
}
/** An assistant node that renders nothing: only tool-call heads (rows render
* via the grouping pass) and blank text/reasoning. Skipped by the flow so it
* neither costs column gaps nor splits a tool-row run. Interrupted nodes
@@ -22,10 +32,25 @@ function rendersNothing(node: ConversationNode): boolean {
|| ((b.kind === 'text' || b.kind === 'reasoning') && b.text.trim() === ''))
}
/**
* Seq set of assistants that own IconActions: the last content-text assistant
* in each turn. Mid-turn narration (text before tools) stays chrome-free.
* @param nodes - snapshot nodes (surface order).
* @returns Seq values ChatView may pass as `time` into AssistantMarkdown.
*/
export function assistantActionsSeqs(nodes: readonly ConversationNode[]): ReadonlySet<number> {
const lastByTurn = new Map<number, number>()
for (const node of nodes) {
if (node.kind !== 'assistant' || !hasContentText(node.blocks)) continue
lastByTurn.set(node.turn, node.seq)
}
return new Set(lastByTurn.values())
}
/**
* Group finalized nodes into the step-summary flow.
* @param nodes - snapshot nodes (surface order).
* @returns flow items; consecutive tool-results merged into one group keyed by the first seq.
* @returns flow items; consecutive tool results and retry notices reuse their first key.
*/
export function deriveChatFlow(nodes: readonly ConversationNode[]): ChatFlowItem[] {
const items: ChatFlowItem[] = []
@@ -39,6 +64,17 @@ export function deriveChatFlow(nodes: readonly ConversationNode[]): ChatFlowItem
} else {
group.push(node)
}
} else if (node.kind === 'model-retry') {
group = null
const previous = items[items.length - 1]
if (
previous?.kind === 'node'
&& previous.node.kind === 'model-retry'
) {
items[items.length - 1] = { ...previous, node }
} else {
items.push({ kind: 'node', key: `n${node.seq}`, node })
}
} else {
group = null
items.push({ kind: 'node', key: `n${node.seq}`, node })

View File

@@ -1,6 +1,11 @@
// Shared chrome helpers for user/assistant IconActions rows: clipboard write
// and the compact date+clock label from a session-event epoch.
import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
/** The date-template share of the conversation dictionary the clock consumes. */
export type ClockTranslate = Translate<'clock.md' | 'clock.ymd'>
/**
* Best-effort clipboard write; rejections stay swallowed (no success chrome).
* @param text - Plain text to place on the clipboard.
@@ -67,14 +72,16 @@ export function msUntilNextLocalMidnight(ms: number): number {
}
/**
* Compact local timestamp for message IconActions.
* Same calendar day → `HH:mm`; earlier this year → `M月D日 HH:mm`;
* other years → `YYYY年M月D日 HH:mm`.
* Compact local timestamp for message IconActions. Same calendar day →
* `HH:mm`; earlier this year → the `clock.md` date template + clock; other
* years → the `clock.ymd` template + clock. Pure: the date templates arrive
* through the caller's locale seat.
* @param time - Unix epoch ms from the source session event.
* @param t - translate seat supplying the `clock.md` / `clock.ymd` templates.
* @param now - Reference instant for the day/year cut (defaults to wall clock).
* @returns Date-aware clock string (24-hour, zero-padded time).
*/
export function formatMessageClock(time: number, now: number = Date.now()): string {
export function formatMessageClock(time: number, t: ClockTranslate, now: number = Date.now()): string {
const d = new Date(time)
const n = new Date(now)
const clock = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`
@@ -85,7 +92,7 @@ export function formatMessageClock(time: number, now: number = Date.now()): stri
) {
return clock
}
const md = `${d.getMonth() + 1}${d.getDate()}`
if (d.getFullYear() === n.getFullYear()) return `${md} ${clock}`
return `${d.getFullYear()}${md} ${clock}`
const params = { y: d.getFullYear(), m: d.getMonth() + 1, d: d.getDate() }
const md = d.getFullYear() === n.getFullYear() ? t('clock.md', params) : t('clock.ymd', params)
return `${md} ${clock}`
}

View File

@@ -1,11 +1,11 @@
/** Conversation slot declarations and their composed component props. */
import type { ReactNode, RefObject } from 'react'
import type {
InjectFace, MaybeSnapshotSelectorHook, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook,
InjectFace, MaybeSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook,
} from '@deepseek-ai/dsh-client-ui-slots'
import type { CommandNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import type { ComposerKeyboard, InputActions, InputNotice, InputState } from '../input/contract.ts'
import type { ComposerKeyboard, EditSelection, InputActions, InputNotice, InputState } from '../input/contract.ts'
import type { createChatStore } from '../stores.ts'
import type { CallId, SelectionTarget, ViewTab } from './views.ts'
@@ -147,13 +147,17 @@ export interface InputZone {
}
/**
* View-slot owner share: deliberately empty — ConversationRoot supplies
* nothing at its renderSlot site (sessionId and the snapshot hook arrive as
* View-slot owner share: the cross-view inspect handoff (otherwise views need
* nothing from the render site sessionId and the snapshot hook arrive as
* framework-standard props; tool rows go through each view's own declared
* toolview hole). Kept as the named owner seat so a future cross-view
* payload has a home.
* toolview hole).
*/
export interface ConvViewOwnerProps {}
export interface ConvViewOwnerProps {
/** One-shot inspect request from another view (chat's Inspect button); null when idle. */
inspect?: { callId: CallId } | null
/** Acknowledge the inspect request once applied (clears the store field). */
onInspectDone?: () => void
}
/**
* Owner share of a per-view toolview slot: the call material the rendering
@@ -176,6 +180,11 @@ export interface ToolRowOwnerProps {
* The chat view resolves relative paths against the session cwd.
*/
openFile: (path: string) => void
/**
* Jump to this call's record in the trajectory view (the expanded row's
* hover Inspect affordance). Undefined when no trajectory jump is wired.
*/
inspect?: (() => void) | undefined
}
/**
@@ -265,14 +274,14 @@ export interface ComposerBarOwnerProps {
rightItems?: ReactNode
/** composer.dock entries (stats line), rendered under the card inside the bar's width column. */
footer?: ReactNode
onAdd?: () => void
addLabel?: string
}
/** Injected share of the composer-bar entry (package-internal faces). */
export interface ComposerBarInjected {
/** The InputBar-exclusive keyboard/DOM command face (decision 20 private plane); absent with the session. */
keyboard: ComposerKeyboard | undefined
/** Toggle the shared slash menu with only its command source; absent without ui-slash or a session. */
toggleCommandMenu: ((selection: EditSelection) => void) | undefined
/** Cancel the in-flight turn; absent with the session. */
stop: (() => void) | undefined
/**
@@ -282,8 +291,6 @@ export interface ComposerBarInjected {
* Resolves admission: false = rejected/unmatched/transport failure.
*/
command: ((line: string) => Promise<boolean>) | undefined
/** Locale-aware hint translator for claimed command placeholders (session-independent — always present). */
translateHint: (key: string) => string
/**
* Registrant hooks compartment: the renderer binds these to
* useNotices/useLexicon (static absent sources without a session — hook
@@ -294,6 +301,8 @@ export interface ComposerBarInjected {
notices: ObservableSnapshot<InputNotice | null>
/** Hot plain-text reference lexicon for the decoration scan (decision 21). */
lexicon: ObservableSnapshot<ReadonlyMap<'/' | '@', readonly string[]>>
/** Source name opened by the programmatic menu launcher, or null. */
menuLauncher: ObservableSnapshot<string | null>
}
}
@@ -306,11 +315,12 @@ export interface InputControlOwnerProps {
locked: boolean
}
/** Full composer-bar component props: standard kit & owner share & control-seat render share & injected share (hooks compartment bound). */
/** Full composer-bar props: standard kit & owner share & control-seat render share & injected share (hooks bound) & locale seat. */
export type ComposerBarProps =
PropsRuntime<'conversation.composer.bar'>
& PropsRenderSlots<'conversation.input.plan' | 'conversation.input.model'>
& InjectFace<ComposerBarInjected>
& PropsLocale<'conversation'>
/**
* Composer chain currency: what ConversationRoot dispatches at its
@@ -325,7 +335,8 @@ export interface ComposerChainProps {
/**
* Full conversation-slot component props: runtime & child-render (view ring
* + composer chain/bar + input-region + hero picker slots) & store & injected shares.
* + composer chain/bar + input-region + hero picker slots) & store & injected
* shares & the locale seat.
*/
export type ConversationSlotProps =
PropsRuntime<'conversation'> & PropsRenderSlots<
@@ -336,13 +347,15 @@ export type ConversationSlotProps =
| 'conversation.hero.workspace'
>
& ConversationInjected
& PropsLocale<'conversation'>
/** Full strict-session content props: per-session store, view ring, and callbacks. */
/** Full strict-session content props: per-session store, view ring, callbacks, and the locale seat. */
export type ConversationSessionSlotProps =
PropsRuntime<'conversation.session'>
& PropsRenderSlots<'conversation.view'>
& PropsStore<ChatStore>
& ConversationSessionInjected
& PropsLocale<'conversation'>
/** The pending approval carrier the owner dispatches into the composer chain. */
export type ApprovalWait = PendingWait<'approval'>
@@ -400,11 +413,13 @@ export class PendingApproval {
/**
* Full approval-composer 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 approval carrier. No injected
* share: the carrier plus the domain face above carry the whole behavior
* surface; the paired command line derives from useSession in-component.
* selector result, already narrowed to the approval carrier — plus the
* standard locale seat. No injected share: the carrier plus the domain face
* above carry the whole behavior surface; the paired command line derives
* from useSession in-component.
*/
export type ApprovalComposerProps = PropsRuntime<'conversation.composer'> & { matched: ApprovalWait }
export type ApprovalComposerProps =
PropsRuntime<'conversation.composer'> & { matched: ApprovalWait } & PropsLocale<'conversation'>
/**
* Injected share of the chat view entry: the two callbacks whose targets live
@@ -419,14 +434,27 @@ export interface ChatViewInjected {
*/
openFile: (path: string) => void
loadOlder: () => void
/** Hand a call off to the trajectory view: write the one-shot inspect target and switch tabs. */
inspectCall: (callId: CallId) => void
/**
* Per-session scroll memory surviving view switches (in-memory, never
* persisted): the view saves on every scroll and restores on remount; a
* fresh page load starts empty and keeps the open-jump-to-bottom default.
*/
chatScroll: {
/** Record the scroll offset; null clears it (pinned to bottom). */
save: (top: number | null) => void
/** Last recorded offset, or null when pinned or never recorded. */
read: () => number | null
}
/** Fork the session through the turn containing the message at `seq`, then open the child. */
forkAt: (seq: number) => void
}
/** Full chat-view component props: runtime share & the declared toolview/commandview holes' render share & store share & injected share. */
/** Full chat-view component props: runtime & the declared toolview/commandview holes' render share & store & injected & locale seat. */
export type ChatViewSlotProps =
PropsRuntime<'conversation.view'> & PropsRenderSlots<'conversation.chat.toolview' | 'conversation.chat.commandview'>
& PropsStore<ChatStore> & ChatViewInjected
& PropsStore<ChatStore> & ChatViewInjected & PropsLocale<'conversation'>
/**
* Injected share of the details slot: the panel is otherwise a pure reader of
@@ -437,8 +465,8 @@ export interface DetailsInjected {
closeDetails: () => void
}
/** Full details-slot component props: selection arrives through the shared store, call material through useSession. */
export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore<ChatStore> & DetailsInjected
/** Full details-slot component props: selection rides the shared store, call material useSession; copy the locale seat. */
export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore<ChatStore> & DetailsInjected & PropsLocale<'conversation'>
/** Owner share common to the hero / New-Session Workspace pickers. */
export interface EmptyWorkspaceOwnerProps {

View File

@@ -8,19 +8,34 @@
* are derived once.
* @module
*/
import type { TerminalBlockProps } from '@deepseek-ai/dsh-client-ui-primitives'
import type { TerminalBlockLabels, TerminalBlockProps } from '@deepseek-ai/dsh-client-ui-primitives'
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
import { resolveToolPath, type ToolCallBlock } from './tool-call-model.ts'
/**
* Output lines the chat row's expanded terminal body shows before collapsing
* the middle — half the primitive's own default, which the details panel
* keeps. A chat row is a summary surface inside the message flow: the flow
* must stay scannable across many calls, while the details panel is the
* single-call reading surface. A design constant of this UI's row geometry,
* not a deployment choice, so it is fixed here rather than a plugin Config
* field.
* Build the TerminalBlock display copy from the conversation locale seat —
* the one place the primitive's label surface pairs with this package's
* dictionary, shared by every terminal render site (chat row, bash row,
* details panel).
* @param t - the render site's conversation locale seat.
* @returns the full label set for {@link TerminalBlockProps}'s `labels`.
*/
export const CHAT_TERMINAL_MAX_LINES = 8
export function terminalBlockLabels(t: TranslateNS<'conversation'>): TerminalBlockLabels {
return {
signal: signal => t('terminal.signal', { signal }),
exitCode: code => t('terminal.exitCode', { code }),
running: t('terminal.running'),
failed: t('terminal.failed'),
done: t('terminal.done'),
copy: t('copy'),
copied: t('copied'),
noOutput: t('terminal.noOutput'),
collapseAria: t('terminal.collapseAria'),
collapse: t('collapse'),
expandAria: hidden => t('terminal.expandAria', { n: hidden }),
expand: hidden => t('terminal.expandRest', { n: hidden }),
}
}
/**
* The {@link TerminalBlock} props this derivation owns. Picked off the
@@ -44,6 +59,20 @@ export interface TerminalCardModel {
description: string | undefined
}
/**
* True when a settled terminal card reports a failing exit — a non-zero code
* or a terminating signal. The bash tool settles a failing command as a
* completed call (`isError` stays false: the exit status is result data), so
* this is the collapsed row's only failure signal; without it the red exit
* pill would be visible only after expanding the card.
* @param model - a derived terminal card.
* @returns whether the card's exit status is a failure.
*/
export function terminalFailed(model: TerminalCardModel): boolean {
const { exitCode, signal, running } = model.card
return running !== true && ((exitCode !== undefined && exitCode !== 0) || signal !== undefined)
}
/**
* Resolve a terminal view's working directory the way the render-intent
* contract assigns to the UI bridge: an absolute path is used as-is, a relative

View File

@@ -1,14 +1,15 @@
/**
* Pure row-model derivation for tool summary rows: variant classification,
* one-line summary and expanded-body text from the frozen call slice. This
* derivation reads the call ARGUMENTS only; a call whose render intent is a
* terminal card gets its expanded body from the views instead, through
* one-line summary, expanded-body text, and flattened result output from the
* frozen call slice. Input material comes from the call ARGUMENTS; output and
* error material from the settled result node. A call whose render intent is
* a terminal card gets its expanded body from the views instead, through
* `terminalCardModel` in terminal-card-model.ts.
*/
// The block union's defining home is runtime (fold-product types); this
// contract only forwards it (type-definition authority stays with the layer
// that produces the values).
import type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
import type { ToolCallBlock, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
export type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
@@ -70,11 +71,34 @@ export interface ToolRowModel {
* relative values against the session cwd before opening.
*/
filePath: string | undefined
/** Expanded-body text (pretty args); null = row not expandable. */
/** Expanded-body input text (pretty args); null = no input section. */
body: string | null
/** Flattened result text ({@link resultText}); null while running or when the result carries no text. */
output: string | null
/** First line of the result text on an error row; null for every other state. */
errorSummary: string | null
state: ToolRowState
}
/**
* Flatten a settled result's content blocks to display text: text blocks
* verbatim, other block shapes as pretty JSON. Empty content on a failed call
* falls back to the structured error's `name: code` line.
* @param node - the settled result node.
* @returns the flattened result text (may be empty).
*/
export function resultText(node: ToolResultNode): string {
const parts: string[] = []
for (const block of node.content) {
if (block.type === 'text') parts.push(block.text)
else parts.push(JSON.stringify(block, null, 2))
}
if (parts.length === 0 && node.error !== undefined) {
parts.push(`${node.error.name}: ${node.error.code}`)
}
return parts.join('\n')
}
function parseArgs(argsRaw: string): unknown {
try {
return JSON.parse(argsRaw)
@@ -192,12 +216,19 @@ export function toolRowModel(toolName: string, block: ToolCallBlock, cwd?: strin
const summary = variant === 'others' && toolName !== '' && toolTitle === undefined
? `${toolName} · ${base}`
: base
// The empty string is "no text" for both derived result fields: a settled
// call with blank content has nothing to expand, and a blank first line
// would erase the collapsed error row's summary slot.
const output = done ? (resultText(block) || null) : null
const errorSummary = state === 'error' && output !== null ? firstLine(output) : null
return {
variant,
title: toolTitle ?? VARIANT_TITLES[variant],
summary,
filePath: deriveFilePath(variant, argsRaw),
body: deriveBody(variant, argsRaw),
output,
errorSummary,
state,
}
}

View File

@@ -23,4 +23,10 @@ export interface ChatStoreState {
draft: string
/** Active conversation view id ('conversation.view' entry id); null falls back to the first view. */
view: string | null
/**
* One-shot inspect handoff: chat writes the call to reveal, the trajectory
* view consumes it and acknowledges by clearing. Read with `?? null` —
* persisted snapshots from before this field rehydrate without it.
*/
inspect: { callId: CallId } | null
}

View File

@@ -0,0 +1,84 @@
/**
* Pure derivation of the web-card props from a frozen call slice: the
* `card:'web'` render intent the `web_search`/`web_fetch` tools declare at
* result time arrives on the snapshot as `resultView`, and this is the one
* place that turns it into what {@link WebBlock} draws. Both conversation
* render sites (the chat tool row's resident/expanded body and the details
* panel's Output section) call this, so the sources and fetch summary they
* show are derived once.
*
* The web card is result-only by contract: those tools keep a generic pending
* call view, so there is nothing to derive while the call is still running and
* a running call always takes the generic path.
* @module
*/
import type { WebBlockProps } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolCallBlock } from './tool-call-model.ts'
/**
* Sources the chat row's web body shows before collapsing the middle — half
* the primitive's own default, which the details panel keeps. A chat row is a
* summary surface inside the message flow: the flow must stay scannable across
* many calls, while the details panel is the single-call reading surface. A
* design constant of this UI's row geometry, not a deployment choice, so it is
* fixed here rather than a plugin Config field.
*/
export const CHAT_WEB_MAX_SOURCES = 8
/**
* Derive the web-card props for a tool call, or null when this call is not a
* web card and belongs on the generic path.
*
* The result side supplies the whole card: the sources and answer for a
* `search`, the URL and status for a `fetch`. Cases producing null, all of
* them the documented generic-card default:
*
* - A running call (no `resultView` yet): the web tools keep a generic pending
* card, so nothing web-shaped exists until the call settles.
* - A settled call whose result view is not a web card — including a `card`
* value this UI version does not know, which arrives over the wire and so
* cannot be trusted to be one of the compiled variants, and a generic result
* view (a web tool's error path returns the generic card, whose text the
* generic path preserves).
* - A web card whose `kind` this UI version does not know (a newer host's
* value): the wire cannot be trusted to be `search` or `fetch`, so it takes
* the generic path rather than rendering as a malformed fetch.
* @param block - RunningToolCall or ToolResultNode off the snapshot caches.
* @returns the web-card props, or null for the generic path.
*/
export function webCardModel(block: ToolCallBlock): WebBlockProps | null {
// Running calls have no result view; the web card is result-only.
if (!('kind' in block)) return null
const result = block.resultView
if (result?.card !== 'web') return null
if (result.kind === 'search') {
return {
kind: 'search',
answer: result.answer,
sources: result.sources.map(source => ({
url: source.url,
title: source.title,
snippet: source.snippet,
publishedAt: source.publishedAt,
})),
truncated: result.truncated,
}
}
// Discriminate `fetch` explicitly rather than treating it as the else of
// `search`: a `kind` this UI version does not know arrives over the wire from
// a newer host, and reading it as a fetch would draw an empty URL and
// `HTTP undefined`. It takes the generic path, the same wire-boundary default
// an unknown `card` tag takes above. The static union narrows `kind` to
// `'fetch'` here, but the runtime value is off the wire, so the guard and its
// null fallthrough are load-bearing despite the type.
// oxlint-disable-next-line typescript/no-unnecessary-condition
if (result.kind === 'fetch') {
return {
kind: 'fetch',
url: result.url,
statusCode: result.statusCode,
truncated: result.truncated,
}
}
return null
}

View File

@@ -11,6 +11,7 @@ export type {
CallId, ChatStoreState, SelectionTarget, ViewTab,
} from './contract/views.ts'
export type { ToolCallBlock } from './contract/tool-call-model.ts'
export type { ConversationKey } from './locales.ts'
export type {
ChatStore, ChatViewInjected, ChatViewSlotProps, CommandRowOwnerProps, CommandRowProps, ComposerBarInjected,
ComposerChainProps, ConversationInjected,

View File

@@ -106,6 +106,17 @@ export class InputHub implements InputService {
return this.shell(id)
}
/**
* Resolve the optional slash controller for composer chrome that launches
* the shared candidate menu without typing a trigger.
* @param id - session id.
* @returns the resident controller, or undefined when ui-slash is absent.
*/
slash(id: SessionId): SlashController | undefined {
const actx = this.sessions().scope(id)
return actx === undefined ? undefined : this.controller(actx)
}
/**
* Default sink: optimistic clear + prompt. The session is always a real
* host entity (materialized when its workspace was picked), so there is

View File

@@ -0,0 +1,184 @@
/** `conversation` namespace dictionaries. */
/** Dictionary namespace owned by this plugin. */
export const NS = 'conversation'
// The claimed /plan hint and the plan-mode textarea placeholder share one
// string: both describe the same next action.
const PLAN_NEXT_ACTION_ZH = '描述你的任务以生成计划'
const PLAN_NEXT_ACTION_EN = 'describe your task to generate plan'
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'view.chat': '对话',
'hint.plan': PLAN_NEXT_ACTION_ZH,
'hint.goal': '输入目标,智能体将持续执行',
'hint.goal.active': '当前目标进行中。可输入 edit 修改 / pause 暂停 / resume 继续 / clear 清除',
'placeholder.plan': PLAN_NEXT_ACTION_ZH,
'placeholder.default': '给智能体发消息',
'placeholder.unavailable': '会话不可用',
'placeholder.hero': '描述你想要构建的内容',
'placeholder.workspace': '选择一个工作区开始',
'input.commands': '命令',
'input.stop': '停止生成',
'input.send': '发送消息',
'input.accessMode': '访问模式,当前:{name}',
'hero.headline': '开始构建吧',
'hero.chooseWorkspace': '选择工作区',
'session.hierarchy': '会话层级',
'details.title': '详情',
'details.close': '关闭详情',
'details.empty': '点击消息流中的工具行查看详情',
'details.notInWindow': '该调用不在当前窗口内',
'details.input': '输入',
'details.output': '输出',
'details.running': '运行中…',
'todo.title': '任务清单',
'todo.progress': '{done}/{total} 项任务 · {active} 项进行中',
'todo.rowTitle': '更新任务清单',
'todo.completed': '{done}/{total} 已完成',
'chat.loadingHistory': '载入历史…',
'chat.loadError': '历史加载失败:{message}{code}',
'chat.loadOlder': '加载更早',
'chat.toBottom': '回到底部',
'message.extraBlock': '附加内容块',
'message.steering': '插话',
'message.contextInjection': '上下文注入',
'message.unknownSurface': '未知 surface 事件:{type}',
'message.unknownBlock': '未知内容块',
'message.stopped': '已停止',
'message.branch': '在新对话中分支',
'message.retry.active': '正在重试模型请求',
'message.retry.cancelled': '模型请求重试已取消',
'message.retry.started': '已重试模型请求',
'message.retry.scheduled': '等待重试模型请求',
'message.retry.status': '{label}{retry}/{maximum} · {seconds}s',
'message.retry.delay': '重试延迟:',
'message.retry.failure': '失败原因:',
'command.running': '执行中…',
'command.failed': '命令失败',
'command.done': '已完成',
'command.title': '命令',
'approval.waiting': '等待审批',
'approval.detail.aria': '审批详情',
'approval.escalation': '工具 {toolName} 请求越权执行',
'approval.reject': '拒绝',
'approval.allowOnce': '允许一次',
'ask.rowTitle': '提问',
'ask.waiting': '等待回答',
'ask.cancelled': '已取消',
'ask.interrupted': '已中断',
'ask.answered': '{answered}/{total} 已回答',
'bash.running': '运行中',
'bash.failed': '失败',
'bash.stopped': '已停止',
'queue.count': '{n} 条排队消息',
'queue.edit': '编辑排队消息',
'queue.edit.unsupported': '包含非文本内容,暂不支持编辑',
'queue.save': '保存排队消息',
'queue.cancelEdit': '取消编辑',
'queue.remove': '删除排队消息',
'queue.editFailed': '编辑失败:这条消息可能已经开始发送。',
'queue.removeFailed': '删除失败:这条消息可能已经开始发送。',
'terminal.signal': '信号 {signal}',
'terminal.exitCode': '退出码 {code}',
'terminal.running': '运行中',
'terminal.failed': '失败',
'terminal.done': '已完成',
'terminal.noOutput': '无输出',
'terminal.collapseAria': '收起输出',
'terminal.expandAria': '展开其余 {n} 行输出',
'terminal.expandRest': '… 其余 {n} 行',
'json.truncated': '… 已截断,共 {total} 字符',
'clock.md': '{m}月{d}日',
'clock.ymd': '{y}年{m}月{d}日',
} satisfies Record<string, string>
/** The conversation namespace key union. */
export type ConversationKey = keyof typeof zh
/** English dictionary, checked complete against the zh key set. */
export const en = {
'view.chat': 'Chat',
'hint.plan': PLAN_NEXT_ACTION_EN,
'hint.goal': 'describe the objective for a long-running task',
'hint.goal.active': 'goal active — edit / pause / resume / clear',
'placeholder.plan': PLAN_NEXT_ACTION_EN,
'placeholder.default': 'Message the agent',
'placeholder.unavailable': 'Session unavailable',
'placeholder.hero': 'Describe what you want to build',
'placeholder.workspace': 'Choose a workspace to start',
'input.commands': 'Commands',
'input.stop': 'Stop generating',
'input.send': 'Send message',
'input.accessMode': 'Access mode, current: {name}',
'hero.headline': 'Let\'s start building',
'hero.chooseWorkspace': 'Choose workspace',
'session.hierarchy': 'Session hierarchy',
'details.title': 'Details',
'details.close': 'Close details',
'details.empty': 'Click a tool row in the message flow to view its details',
'details.notInWindow': 'This call is outside the current window',
'details.input': 'Input',
'details.output': 'Output',
'details.running': 'Running…',
'todo.title': 'To-dos',
'todo.progress': '{done}/{total} tasks · {active} in progress',
'todo.rowTitle': 'Update to-do list',
'todo.completed': '{done}/{total} completed',
'chat.loadingHistory': 'Loading history…',
'chat.loadError': 'Failed to load history: {message} ({code})',
'chat.loadOlder': 'Load earlier',
'chat.toBottom': 'Back to bottom',
'message.extraBlock': 'Extra content block',
'message.steering': 'Interjection',
'message.contextInjection': 'Context injection',
'message.unknownSurface': 'Unknown surface event: {type}',
'message.unknownBlock': 'Unknown content block',
'message.stopped': 'Stopped',
'message.branch': 'Branch into a new conversation',
'message.retry.active': 'Retrying model request',
'message.retry.cancelled': 'Model request retry cancelled',
'message.retry.started': 'Retried model request',
'message.retry.scheduled': 'Waiting to retry model request',
'message.retry.status': '{label} ({retry}/{maximum}) · {seconds}s',
'message.retry.delay': 'Retry delay: ',
'message.retry.failure': 'Failure reason: ',
'command.running': 'Running…',
'command.failed': 'Command failed',
'command.done': 'Completed',
'command.title': 'Command',
'approval.waiting': 'Waiting for approval',
'approval.detail.aria': 'Approval details',
'approval.escalation': 'Tool {toolName} requests privileged execution',
'approval.reject': 'Reject',
'approval.allowOnce': 'Allow once',
'ask.rowTitle': 'Ask question',
'ask.waiting': 'waiting',
'ask.cancelled': 'cancelled',
'ask.interrupted': 'interrupted',
'ask.answered': '{answered}/{total} answered',
'bash.running': 'Running',
'bash.failed': 'Failed',
'bash.stopped': 'Stopped',
'queue.count': '{n} queued messages',
'queue.edit': 'Edit queued message',
'queue.edit.unsupported': 'Contains non-text content; editing is not supported yet',
'queue.save': 'Save queued message',
'queue.cancelEdit': 'Cancel editing',
'queue.remove': 'Remove queued message',
'queue.editFailed': 'Edit failed: this message may have already started sending.',
'queue.removeFailed': 'Removal failed: this message may have already started sending.',
'terminal.signal': 'signal {signal}',
'terminal.exitCode': 'exit code {code}',
'terminal.running': 'Running',
'terminal.failed': 'Failed',
'terminal.done': 'Done',
'terminal.noOutput': 'No output',
'terminal.collapseAria': 'Collapse output',
'terminal.expandAria': 'Expand the remaining {n} output lines',
'terminal.expandRest': '… {n} more lines',
'json.truncated': '… truncated, {total} characters total',
'clock.md': '{m}/{d}',
'clock.ymd': '{y}-{m}-{d}',
} satisfies Record<ConversationKey, string>

View File

@@ -5,13 +5,14 @@
// ../contract/slots.ts beside the other input-region slots.
import type { Context } from 'cordis'
import { useEffect, useId, useState } from 'react'
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import {
IconCheckOutline16, IconChevronDownOutline14, IconChevronUpOutline14,
IconCloseOutline16, IconEditOutline16, IconTrashOutline16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { QueueAction, QueueItemId } from '../contract/queue.ts'
import { NS } from '../locales.ts'
import css from './QueueDock.module.css'
/** Queue operations injected by the session-scoped registration. */
@@ -20,14 +21,14 @@ export interface QueueDockInjected {
notify: (level: 'info' | 'error', text: string) => void
}
/** Full props of a dock entry: InputZone owner share + session standard kit + global seat. */
export type QueueDockProps = PropsRuntime<'conversation.input.dock'> & QueueDockInjected
/** Full props of a dock entry: InputZone owner share + session standard kit + global seat + the locale seat. */
export type QueueDockProps = PropsRuntime<'conversation.input.dock'> & QueueDockInjected & PropsLocale<'conversation'>
/**
* Queue strip: one item renders directly; multiple items default to a
* collapsible count header; an empty queue renders nothing.
*/
export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) {
export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps) {
const queue = useSession(s => s.queue)
const [editing, setEditing] = useState<{ id: QueueItemId; text: string } | null>(null)
const [busy, setBusy] = useState<QueueItemId | null>(null)
@@ -67,7 +68,7 @@ export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) {
if (await applyAction(
editing.id,
{ kind: 'edit', content: [{ type: 'text', text: editing.text }] },
'编辑失败:这条消息可能已经开始发送。',
t('queue.editFailed'),
)) setEditing(null)
}
@@ -83,7 +84,7 @@ export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) {
disabled={interactionActive}
onClick={() => { setCollapsed(value => !value) }}
>
<span className={css.count}>{queue.length} </span>
<span className={css.count}>{t('queue.count', { n: queue.length })}</span>
<span className={css.chevron} aria-hidden>
{expanded ? <IconChevronDownOutline14 /> : <IconChevronUpOutline14 />}
</span>
@@ -97,7 +98,7 @@ export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) {
<input
autoFocus
className={css.editor}
aria-label="编辑排队消息"
aria-label={t('queue.edit')}
value={editing.text}
onChange={(event) => { setEditing({ id: row.id, text: event.currentTarget.value }) }}
onKeyDown={(event) => {
@@ -120,8 +121,8 @@ export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) {
<button
type="button"
className={css.action}
aria-label="保存排队消息"
title="保存排队消息"
aria-label={t('queue.save')}
title={t('queue.save')}
disabled={busy !== null || editing.text.trim() === ''}
onClick={() => { void saveEdit() }}
>
@@ -130,8 +131,8 @@ export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) {
<button
type="button"
className={css.action}
aria-label="取消编辑"
title="取消编辑"
aria-label={t('queue.cancelEdit')}
title={t('queue.cancelEdit')}
disabled={busy !== null}
onClick={() => { setEditing(null) }}
>
@@ -144,8 +145,8 @@ export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) {
<button
type="button"
className={css.action}
aria-label="编辑排队消息"
title={row.text === null ? '包含非文本内容,暂不支持编辑' : '编辑排队消息'}
aria-label={t('queue.edit')}
title={row.text === null ? t('queue.edit.unsupported') : t('queue.edit')}
disabled={busy !== null || row.text === null}
onClick={() => {
if (row.text !== null) setEditing({ id: row.id, text: row.text })
@@ -156,14 +157,14 @@ export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) {
<button
type="button"
className={css.action}
aria-label="删除排队消息"
title="删除排队消息"
aria-label={t('queue.remove')}
title={t('queue.remove')}
disabled={busy !== null}
onClick={() => {
void applyAction(
row.id,
{ kind: 'remove' },
'删除失败:这条消息可能已经开始发送。',
t('queue.removeFailed'),
)
}}
>
@@ -196,6 +197,7 @@ export const queueDockEntry = {
name: 'conversation.input.dock',
id: 'queue',
order: 20,
locale: NS,
inject: (sessionId: SessionId): QueueDockInjected => {
const actx = ctx.sessions.scope(sessionId)
if (actx === undefined) throw new Error(`queue dock: session "${sessionId}" resolved no scope`)

View File

@@ -41,10 +41,14 @@ export function ApprovalPanel(props: ApprovalComposerProps) {
const approval = useMemo(() => new PendingApproval(props.matched), [props.matched])
const command = props.useSession(s => commandOf(
approval.callId === undefined ? undefined : s.runningCalls.find(call => call.callId === approval.callId)))
return <ApprovalFlow key={approval.key} pending={approval} {...command === undefined ? {} : { command }} />
return <ApprovalFlow key={approval.key} pending={approval} t={props.t} {...command === undefined ? {} : { command }} />
}
function ApprovalFlow({ pending, command }: { pending: PendingApproval; command?: string }) {
function ApprovalFlow({ pending, command, t }: {
pending: PendingApproval
command?: string
t: ApprovalComposerProps['t']
}) {
// Local one-shot latch: the panel leaves only when the resolved frame
// lands; until then the buttons must not re-fire. An answer failure
// (rejected receipt / transport) re-arms them for retry.
@@ -56,20 +60,20 @@ function ApprovalFlow({ pending, command }: { pending: PendingApproval; command?
return (
<div className={css.root} data-approval-key={pending.key}>
<div className={css.card}>
<div className={css.strip}><span className={css.dot} /></div>
<div className={css.strip}><span className={css.dot} />{t('approval.waiting')}</div>
{/* Tab stop: the region scrolls once the command passes the cap and
holds nothing focusable of its own, so without one a keyboard-only
user cannot reach the command's tail before answering. */}
<div className={css.body} data-approval-scroll="" tabIndex={0} role="group" aria-label="审批详情">
<div className={css.headline}>{pending.reason ?? `工具 ${pending.toolName} 请求越权执行`}</div>
<div className={css.body} data-approval-scroll="" tabIndex={0} role="group" aria-label={t('approval.detail.aria')}>
<div className={css.headline}>{pending.reason ?? t('approval.escalation', { toolName: pending.toolName })}</div>
{command !== undefined && <div className={css.command}>{command}</div>}
</div>
<div className={css.actionRow}>
<button type="button" className={css.reject} disabled={answered} onClick={() => { answer('rejected') }}>
{t('approval.reject')}
</button>
<button type="button" className={css.allow} disabled={answered} onClick={() => { answer('allowed-once') }}>
{t('approval.allowOnce')}
</button>
</div>
</div>

View File

@@ -14,7 +14,7 @@ export type ConversationRootProps = ConversationSlotProps
export function ConversationRoot({
sessionId, useSession, useSessions, useWorkspaces, useInput,
renderSlot, renderSlotChain, selectWorkspace,
renderSlot, renderSlotChain, selectWorkspace, t,
}: ConversationRootProps) {
const openState = useSession(s => s.openState)
const composerPhase = useSession(s => s.composerPhase)
@@ -94,6 +94,7 @@ export function ConversationRoot({
label={chipTitle}
menuOpen={pickerOpen}
onClick={() => { setPickerOpen(open => !open) }}
t={t}
/>
{renderSlot('conversation.hero.workspace', {
open: pickerOpen,
@@ -120,8 +121,8 @@ export function ConversationRoot({
const inputBar = renderSlot('conversation.composer.bar', {
variant: hero ? 'hero' : 'composer',
...(inert
? { disabled: true, placeholder: 'Choose a workspace to start' }
: hero ? { placeholder: 'Describe what you want to build' } : {}),
? { disabled: true, placeholder: t('placeholder.workspace') }
: hero ? { placeholder: t('placeholder.hero') } : {}),
overlay: renderSlot('conversation.input.overlay', {}),
leftItems: zone === undefined ? null : renderSlot('conversation.input.left', zone),
rightItems: zone === undefined ? null : renderSlot('conversation.input.right', zone),
@@ -133,7 +134,7 @@ export function ConversationRoot({
const composerBar = (
<div className={clsx(css.composerStack, hero && css.composerHero)}>
{hero && <HeroGlow className={css.heroGlow} />}
{hero && <HeroShell />}
{hero && <HeroShell t={t} />}
{hero && heroWorkspaceRow}
{!hero && zone !== undefined && renderSlot('conversation.input.dock', zone)}
{inputBar}

View File

@@ -24,7 +24,7 @@ function deriveAncestry(list: SessionListState, id: SessionId): readonly Session
export function ConversationSession({
sessionId, useSession, useSessions, useInput, inputActions, useStore, actions,
renderSlot, views, bindDraftMirror, open, wrapActiveBody,
renderSlot, views, bindDraftMirror, open, wrapActiveBody, t,
}: ConversationSessionProps) {
useSyncExternalStore(views.subscribe, views.version)
const tabs = views.list()
@@ -35,6 +35,8 @@ export function ConversationSession({
const blank = useSession(s => s.blank)
const inputState = useInput(s => s)
const storedDraft = useStore(s => s.draft)
// `?? null`: persisted snapshots from before the inspect field rehydrate without it.
const inspect = useStore(s => s.inspect ?? null)
useEffect(() => {
if (inputState.draft === '' && storedDraft !== '') inputActions.setDraft(storedDraft)
@@ -52,7 +54,10 @@ export function ConversationSession({
const view: ReactNode = hideChrome ? null : (
<div className={css.viewArea}>
{active !== undefined && renderSlot('conversation.view', {}, { only: active.id })}
{active !== undefined && renderSlot('conversation.view', {
inspect,
onInspectDone: () => { actions.setInspect(null) },
}, { only: active.id })}
</div>
)
@@ -65,7 +70,7 @@ export function ConversationSession({
{!hideChrome && (
<>
<div className={css.crumbRow}>
<nav className={css.crumbs} aria-label="Session hierarchy">
<nav className={css.crumbs} aria-label={t('session.hierarchy')}>
{ancestry.map((summary, index) => {
const last = index === ancestry.length - 1
return (

View File

@@ -106,3 +106,9 @@
.terminal {
margin: 0;
}
/* Same rule for the web card: it sits under the section label, so the section
owns the spacing rather than the primitive's own vertical margin. */
.web {
margin: 0;
}

View File

@@ -7,12 +7,13 @@
// share the store seat exists for) and derives the call material from the
// session snapshot — no data of its own.
import { CodeBlock, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import { CodeBlock, TerminalBlock, WebBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { DetailsSlotProps } from '../contract/slots.ts'
import { terminalCardModel } from '../contract/terminal-card-model.ts'
import type { ToolCallBlock } from '../contract/tool-call-model.ts'
import { terminalBlockLabels, terminalCardModel } from '../contract/terminal-card-model.ts'
import { webCardModel } from '../contract/web-card-model.ts'
import { resultText, type ToolCallBlock } from '../contract/tool-call-model.ts'
import css from './DetailsPanel.module.css'
/** Full props composed by reference from the contract (automatic shares & injected share). */
@@ -68,7 +69,7 @@ function pretty(raw: string): string {
}
}
export function DetailsPanel({ useSession, useSessions, sessionId, useStore, closeDetails }: DetailsPanelProps) {
export function DetailsPanel({ useSession, useSessions, sessionId, useStore, closeDetails, t }: DetailsPanelProps) {
const selection = useStore(s => s.selection)
// Session workspace root: an omitted or relative terminal cwd resolves
// against it, which the pure presenter cannot see.
@@ -84,10 +85,10 @@ export function DetailsPanel({ useSession, useSessions, sessionId, useStore, clo
<div className={css.root}>
<div className={css.header}>
<div className={css.title}>
{selection === null ? '详情' : material?.name ?? selection.toolName ?? '详情'}
{selection === null ? t('details.title') : material?.name ?? selection.toolName ?? t('details.title')}
</div>
<button
type="button" className={css.close} aria-label="关闭详情"
type="button" className={css.close} aria-label={t('details.close')}
onClick={() => { closeDetails() }}
>
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden>
@@ -97,24 +98,24 @@ export function DetailsPanel({ useSession, useSessions, sessionId, useStore, clo
</div>
<div className={css.body}>
{selection === null || callId === undefined
? <div className={css.empty}></div>
? <div className={css.empty}>{t('details.empty')}</div>
: material === null
? <div className={css.empty}></div>
? <div className={css.empty}>{t('details.notInWindow')}</div>
: (
<>
{material.argsRaw !== null && (
<section className={css.section}>
<div className={css.sectionLabel}>Input</div>
<CodeBlock code={pretty(material.argsRaw)} lang="json" />
<div className={css.sectionLabel}>{t('details.input')}</div>
<CodeBlock code={pretty(material.argsRaw)} lang="json" copyLabel={t('copy')} copiedLabel={t('copied')} />
</section>
)}
<section className={css.section}>
<div className={css.sectionLabel}>Output</div>
<div className={css.sectionLabel}>{t('details.output')}</div>
{/* Keyed by the selected call: the body owns per-call view
state (the terminal card's expand and copy), which React
would otherwise carry into the next selection because the
panel does not unmount between calls. */}
<OutputBody key={callId} material={material} cwd={sessionCwd} />
<OutputBody key={callId} material={material} cwd={sessionCwd} t={t} />
</section>
</>
)}
@@ -127,13 +128,16 @@ export function DetailsPanel({ useSession, useSessions, sessionId, useStore, clo
* The Output section's body for the selected call. A terminal-card call — a
* shell command's call/result views — renders through the shared TerminalBlock
* at the primitive's own full height allowance, so column-aligned output keeps
* its alignment and scrolls sideways instead of folding. Every other call, and
* a running call with no terminal card yet, keeps the flattened text form.
* its alignment and scrolls sideways instead of folding. A web-card call — a
* `web_search`/`web_fetch` result — renders through WebBlock at its own full
* source-list allowance. Every other call, and a running call with no card
* yet, keeps the flattened text form.
* @param props.material - the selected call's material from {@link materialFor}.
* @param props.cwd - the session workspace root, resolving the terminal view's cwd.
* @param props.t - the panel's locale seat, passed down as a plain prop.
* @returns the Output section's body element.
*/
function OutputBody({ material, cwd }: { material: CallMaterial; cwd: string | undefined }) {
function OutputBody({ material, cwd, t }: { material: CallMaterial; cwd: string | undefined; t: DetailsPanelProps['t'] }) {
const terminal = terminalCardModel(material.block, cwd)
if (terminal !== null) {
// The contract renders the presenter's description above the card, and the
@@ -143,30 +147,35 @@ function OutputBody({ material, cwd }: { material: CallMaterial; cwd: string | u
{terminal.description !== undefined && (
<div className={css.terminalDescription}>{terminal.description}</div>
)}
<TerminalBlock {...terminal.card} className={css.terminal} />
<TerminalBlock {...terminal.card} labels={terminalBlockLabels(t)} className={css.terminal} />
</>
)
}
const web = webCardModel(material.block)
// Full source-list allowance here (the panel is the single-call reading
// surface); the chat rows cap it at CHAT_WEB_MAX_SOURCES. Below the card the
// panel also renders the flattened result content — the model-visible text
// the card does not carry verbatim (a web_fetch card shows only the URL and
// status, so its fetched body lives only here; a search card's answer and
// sources are structured, so the flattened form repeats them as the raw text
// the model saw).
if (web !== null) {
const settled = 'kind' in material.block ? material.block : null
const body = settled === null ? '' : resultText(settled)
return (
<>
<WebBlock {...web} className={css.web} />
{body !== '' && <pre className={css.code}>{body}</pre>}
</>
)
}
// A settled call always carries the result node the flattened form needs;
// the running shape has no result to flatten.
if (!('kind' in material.block)) return <div className={css.empty}></div>
if (!('kind' in material.block)) return <div className={css.empty}>{t('details.running')}</div>
const result = material.block
return (
<pre className={css.code} data-error={result.isError || undefined}>
{renderResult(result)}
{resultText(result)}
</pre>
)
}
/** Flatten result content blocks to display text (text blocks verbatim, others as JSON). */
function renderResult(node: ToolResultNode): string {
const parts: string[] = []
for (const block of node.content) {
if (block.type === 'text') parts.push(block.text)
else parts.push(JSON.stringify(block, null, 2))
}
if (parts.length === 0 && node.error !== undefined) {
parts.push(`${node.error.name}: ${node.error.code}`)
}
return parts.join('\n')
}

View File

@@ -10,8 +10,12 @@ import {
FishLogo, IconChevronDownOutline14, IconFolderClose16, IconFolderOpen16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { workspaceTitleOf } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSlotProps } from '../contract/slots.ts'
import css from './HeroShell.module.css'
/** The owner's locale seat type, passed to hero chrome as a plain prop. */
type HeroTranslate = ConversationSlotProps['t']
/**
* Basename label for the workspace chip (the shared derivation);
* separator-only paths echo the raw cwd.
@@ -34,18 +38,19 @@ export function workspaceLabel(cwd: string): string {
* @param props.onClick - menu toggle.
* @returns the chip button element.
*/
export function WorkspaceChip({ buttonRef, label, menuOpen = false, onClick }: {
export function WorkspaceChip({ buttonRef, label, menuOpen = false, onClick, t }: {
buttonRef?: RefObject<HTMLButtonElement>
label?: string | undefined
menuOpen?: boolean
onClick?: () => void
t: HeroTranslate
}) {
return (
<button
ref={buttonRef}
type="button"
className={css.workspace}
aria-label="Choose workspace"
aria-label={t('hero.chooseWorkspace')}
aria-haspopup="menu"
aria-expanded={menuOpen}
onClick={onClick}
@@ -53,7 +58,7 @@ export function WorkspaceChip({ buttonRef, label, menuOpen = false, onClick }: {
{label === undefined
? <IconFolderClose16 className={css.folder} size={16} />
: <IconFolderOpen16 className={css.folder} size={16} />}
<span className={css.workspaceLabel}>{label ?? 'Choose workspace'}</span>
<span className={css.workspaceLabel}>{label ?? t('hero.chooseWorkspace')}</span>
<IconChevronDownOutline14 className={css.chevron} size={12} />
</button>
)
@@ -95,6 +100,8 @@ export function HeroGlow({ className }: { className?: string | undefined }) {
/** Hero chrome props. The workspace row rides the InputBar accessory hole, not here. */
export interface HeroShellProps {
/** The owner's locale seat, passed down as a plain prop. */
t: HeroTranslate
/** Overlay content after the stack (modals). */
children?: ReactNode
}
@@ -105,14 +112,14 @@ export interface HeroShellProps {
* @param props - see {@link HeroShellProps}.
* @returns the centered hero element tree.
*/
export function HeroShell({ children }: HeroShellProps) {
export function HeroShell({ t, children }: HeroShellProps) {
return (
<div className={css.root}>
<div className={css.stack}>
<div className={css.headline}>
{/* figma 34:10412: fish 34×25 leading the headline, gap 10. */}
<FishLogo size={34} className={css.fish} />
Let&apos;s start building
{t('hero.headline')}
</div>
<div className={css.body}>
{/* The resident composer (ConversationRoot wrapActiveBody seat; the

View File

@@ -15,6 +15,7 @@ import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type {} from '@deepseek-ai/dsh-plan-mode/client'
// Type-only: the `goal` projection key merge (hint disambiguation).
import type {} from '@deepseek-ai/dsh-goal/client'
import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
import type { ComposerBarProps } from '../contract/slots.ts'
import { deriveDecorations } from '../input/decorations.ts'
import type { DraftDecorations } from '../input/decorations.ts'
@@ -33,13 +34,14 @@ export interface InputBarError {
export type InputBarProps = ComposerBarProps
export function InputBar({
useSession, useInput, inputActions, keyboard, stop, command, translateHint, renderSlot, useNotices, useLexicon,
useSession, useInput, inputActions, keyboard, toggleCommandMenu, stop, command, t,
renderSlot, useNotices, useLexicon, useMenuLauncher,
useProjection, sessionId, variant, disabled: inert = false, placeholder, accessory, overlay, leftItems, rightItems, footer,
onAdd, addLabel = 'Add attachment',
}: InputBarProps) {
const input = useInput(s => s)
const notice = useNotices(s => s)
const lexicon = useLexicon(s => s)
const commandMenuOpen = useMenuLauncher(source => source === 'command')
const promptError = useSession(s => s.promptError) ?? null
const running = useSession(s => s.running) ?? false
const removed = useSession(s => s.removed) ?? false
@@ -256,7 +258,12 @@ export function InputBar({
inputRef.current?.focus()
}
const primaryLabel = running ? 'Stop generating' : 'Send message'
const onToggleCommandMenu = (): void => {
const el = inputRef.current
if (el !== null) toggleCommandMenu?.(selectionOf(el))
}
const primaryLabel = running ? t('input.stop') : t('input.send')
const onPrimary = (): void => {
if (inputActions === undefined || stop === undefined) return // absent machine: the button is disabled
if (running) {
@@ -272,7 +279,7 @@ export function InputBar({
// or while the command face is absent with the session).
const accessSelect: ReactNode = command === undefined
? null
: <PermissionSelect value={permissions} locked={locked} command={command} />
: <PermissionSelect value={permissions} locked={locked} command={command} t={t} />
// Mirror-layer decorations: a visible backdrop with transparent text. The
// claim token highlights through behind the textarea glyphs; each U+FFFC
@@ -341,8 +348,10 @@ export function InputBar({
if (deco.hint !== null) {
// Claim tokens are shaped `/name ` (trailing space); trim to the bare name.
const commandName = input?.claim?.token.slice(1).trim() ?? ''
const hintKey = commandName === 'goal' && hasGoal ? 'goal.active' : commandName
const translated = translateHint(hintKey)
const hintKey = `hint.${commandName === 'goal' && hasGoal ? 'goal.active' : commandName}`
// Dynamic lookup by claimed command name: unknown commands miss the
// dictionary and keep the machine's own hint, so the call is wide.
const translated = (t as Translate)(hintKey)
const displayHint = translated !== hintKey ? translated : deco.hint
backdrop.push(<span key="hint" className={css.hint} data-decoration="hint">{displayHint}</span>)
}
@@ -376,8 +385,8 @@ export function InputBar({
readOnly={machineBusy}
data-phase={input?.phase ?? 'inert'}
placeholder={placeholder ?? (disabled
? 'Session unavailable'
: planActive ? translateHint('placeholder.plan') : translateHint('placeholder.default'))}
? t('placeholder.unavailable')
: planActive ? t('placeholder.plan') : t('placeholder.default'))}
rows={2}
onChange={onChange}
onKeyDown={onKeyDown}
@@ -395,11 +404,13 @@ export function InputBar({
<button
type="button"
className={css.add}
aria-label={addLabel}
title={addLabel}
disabled={locked}
aria-label={t('input.commands')}
title={t('input.commands')}
aria-haspopup="listbox"
aria-expanded={commandMenuOpen}
disabled={locked || toggleCommandMenu === undefined}
onMouseDown={keepFocus}
onClick={onAdd}
onClick={onToggleCommandMenu}
>
<IconPlusOutline16 size={14} />
</button>

View File

@@ -2,6 +2,7 @@ import { useState } from 'react'
import type { PermissionSelect as PermissionSelectValue } from '@deepseek-ai/dsh-permission/client'
import { Menu } from '@deepseek-ai/dsh-client-ui-primitives'
import type { MenuEntry } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ComposerBarProps } from '../contract/slots.ts'
import css from './PermissionSelect.module.css'
/**
@@ -19,9 +20,11 @@ export interface PermissionSelectProps {
value: PermissionSelectValue | undefined
locked: boolean
command: (line: string) => Promise<boolean>
/** The owning bar's locale seat, passed down as a plain prop. */
t: ComposerBarProps['t']
}
export function PermissionSelect({ value, locked, command }: PermissionSelectProps) {
export function PermissionSelect({ value, locked, command, t }: PermissionSelectProps) {
const [pick, setPick] = useState<string | null>(null)
const [open, setOpen] = useState(false)
@@ -56,7 +59,7 @@ export function PermissionSelect({ value, locked, command }: PermissionSelectPro
<button
type="button"
className={css.trigger}
aria-label={`Access mode, current: ${displayName(current?.name ?? currentValue)}`}
aria-label={t('input.accessMode', { name: displayName(current?.name ?? currentValue) })}
title={current?.description}
disabled={locked || busy}
onClick={() => { setOpen(!open) }}

View File

@@ -7,18 +7,21 @@
import { useId, useState } from 'react'
import type { Context } from 'cordis'
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
// The domain's client-namespace pure-type outlet: one import edge delivers
// the `todos` projection-key merge (single source, no consumer-side restated
// declare) and the payload type. Type-only by construction — the outlet is
// free of host value imports, so no host Context merge enters this program.
import type { TodoItem } from '@deepseek-ai/dsh-tool-todo/client'
import { IconChevronDownOutline14, IconChevronUpOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import { NS } from '../locales.ts'
import css from './TodoPanel.module.css'
export interface TodoPanelProps {
/** The session's current plan (empty renders nothing) — selected by the dock adapter. */
todos: readonly TodoItem[]
/** The dock entry's locale seat, passed down as a plain prop. */
t: TodoDockProps['t']
}
/** Local exhaustiveness helper — client packages do not depend on `dsh-llm`. */
@@ -76,18 +79,18 @@ function StatusGlyph({ status }: { status: TodoItem['status'] }) {
}
/** Header summary: "<done>/<total> tasks · <n> in progress". */
function progressLabel(todos: readonly TodoItem[]): string {
const done = todos.filter(t => t.status === 'completed').length
const active = todos.filter(t => t.status === 'in_progress').length
return `${done}/${todos.length} tasks · ${active} in progress`
function progressLabel(todos: readonly TodoItem[], t: TodoPanelProps['t']): string {
const done = todos.filter(item => item.status === 'completed').length
const active = todos.filter(item => item.status === 'in_progress').length
return t('todo.progress', { done, total: todos.length, active })
}
export function TodoPanel({ todos }: TodoPanelProps) {
export function TodoPanel({ todos, t }: TodoPanelProps) {
const [collapsed, setCollapsed] = useState(true)
if (todos.length === 0) return null
return (
<section className={css.root} data-testid="todo-panel" aria-label="To-dos">
<section className={css.root} data-testid="todo-panel" aria-label={t('todo.title')}>
<div className={css.body}>
<button
type="button"
@@ -95,8 +98,8 @@ export function TodoPanel({ todos }: TodoPanelProps) {
aria-expanded={!collapsed}
onClick={() => { setCollapsed(v => !v) }}
>
<span className={css.title}>To-dos</span>
<span className={css.progress}>{progressLabel(todos)}</span>
<span className={css.title}>{t('todo.title')}</span>
<span className={css.progress}>{progressLabel(todos, t)}</span>
<span className={css.chevron} aria-hidden>
{collapsed ? <IconChevronUpOutline14 /> : <IconChevronDownOutline14 />}
</span>
@@ -116,13 +119,13 @@ export function TodoPanel({ todos }: TodoPanelProps) {
)
}
/** Full props of a dock entry: InputZone owner share + session standard kit + global seat. */
export type TodoDockProps = PropsRuntime<'conversation.input.dock'>
/** Full props of a dock entry: InputZone owner share + session standard kit + global seat + the locale seat. */
export type TodoDockProps = PropsRuntime<'conversation.input.dock'> & PropsLocale<'conversation'>
/** Dock adapter: reads the host-computed 'todos' projection (whole list; absent or null renders nothing). */
export function TodoDock({ useProjection }: TodoDockProps) {
export function TodoDock({ useProjection, t }: TodoDockProps) {
const todos = useProjection('todos')
return <TodoPanel todos={todos ?? []} />
return <TodoPanel todos={todos ?? []} t={t} />
}
/**
@@ -139,6 +142,6 @@ export const todoDockEntry = {
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
*/
apply(ctx: Context): void {
ctx.slots.register({ name: 'conversation.input.dock', id: 'todo', order: 10 }, TodoDock)
ctx.slots.register({ name: 'conversation.input.dock', id: 'todo', order: 10, locale: NS }, TodoDock)
},
}

View File

@@ -3,7 +3,7 @@
* The plugin creates its handle at apply time so identity follows the fiber.
*/
import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client'
import type { ChatStoreState, SelectionTarget } from './contract/views.ts'
import type { CallId, ChatStoreState, SelectionTarget } from './contract/views.ts'
/** Declared action shape used to give the exported factory a stable return type. */
type ChatActions = {
@@ -12,6 +12,7 @@ type ChatActions = {
clearDraft: (draft: ChatStoreState) => void
restoreDraft: (draft: ChatStoreState, text: string) => void
setView: (draft: ChatStoreState, view: string) => void
setInspect: (draft: ChatStoreState, target: { callId: CallId } | null) => void
}
/**
@@ -20,7 +21,7 @@ type ChatActions = {
*/
export function createChatStore(): EngineStoreHandle<ChatStoreState, ChatActions> {
return defineStore({
init: (): ChatStoreState => ({ selection: null, draft: '', view: null }),
init: (): ChatStoreState => ({ selection: null, draft: '', view: null, inspect: null }),
persist: 'dsh.conversation.chat',
actions: {
select: (d, target: SelectionTarget | null) => { d.selection = target },
@@ -30,6 +31,7 @@ export function createChatStore(): EngineStoreHandle<ChatStoreState, ChatActions
// since the clear (send choreography lives in the inject factory).
restoreDraft: (d, text: string) => { if (d.draft === '') d.draft = text },
setView: (d, view: string) => { d.view = view },
setInspect: (d, target: { callId: CallId } | null) => { d.inspect = target },
},
})
}

View File

@@ -1,16 +1,18 @@
// ask_user_question toolview: question-flavored summary row replacing the
// generic "Tool call" card, registered into the keyed
// 'conversation.chat.toolview' hole like todo-row. The row composes ToolRow
// (chrome, running sweep, leading expansion) and swaps in the interaction
// (chrome, running sweep, whole-row expand) and swaps in the interaction
// outcome — `waiting` while pending, answered-count once settled, `cancelled`
// when the user dismissed the whole set — because the questions themselves
// render in the composer takeover.
import { IconQuestionOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context } from 'cordis'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { ToolRowProps } from '../contract/slots.ts'
import { toolRowModel } from '../contract/tool-call-model.ts'
import { ToolRow } from '../chat/ToolRow.tsx'
import { NS } from '../locales.ts'
/** One parsed answer entry, shape-checked (result JSON crosses the wire). */
interface AnswerEntry { selected?: unknown; custom?: unknown }
@@ -19,9 +21,9 @@ function isAnswer(value: unknown): value is AnswerEntry {
return typeof value === 'object' && value !== null
}
/** `${answered}/${total} answered` off the result JSON (a skipped question has
/** Answered-count summary off the result JSON (a skipped question has
* empty `selected` and no `custom`); null on unexpected shape (generic fallback). */
function answeredSummary(text: string): string | null {
function answeredSummary(text: string, t: AskQuestionRowProps['t']): string | null {
let parsed: unknown
try {
parsed = JSON.parse(text)
@@ -34,11 +36,15 @@ function answeredSummary(text: string): string | null {
const answered = answers.filter(a =>
(Array.isArray(a.selected) && a.selected.length > 0)
|| (typeof a.custom === 'string' && a.custom !== '')).length
return `${answered}/${answers.length} answered`
return t('ask.answered', { answered, total: answers.length })
}
/** One-line question-interaction row (leading toggle expands the raw args). */
export function AskQuestionRow({ toolName, block }: ToolRowProps) {
/** Full row props: the toolview runtime share plus the standard locale seat. */
type AskQuestionRowProps = ToolRowProps & PropsLocale<'conversation'>
/** One-line question-interaction row (the whole row toggles the call's
* Input/Output sections, ToolRow's unified expand). */
export function AskQuestionRow({ toolName, block, inspect, t }: AskQuestionRowProps) {
const model = toolRowModel(toolName, block)
// Composer verdicts settle the call as specific UserInteractionErrors
// (apiproxy ask_user_question handler): 'ASK_CANCELLED' is the user's own
@@ -50,25 +56,28 @@ export function AskQuestionRow({ toolName, block }: ToolRowProps) {
let summary = model.summary
let state = model.state
if (code === 'ASK_CANCELLED') {
summary = 'cancelled'
summary = t('ask.cancelled')
} else if (code === 'ASK_ABORTED') {
summary = 'interrupted'
summary = t('ask.interrupted')
state = 'stopped'
} else if (model.state === 'running') {
summary = 'waiting'
summary = t('ask.waiting')
} else if ('kind' in block && model.state === 'ok') {
const text = block.content.filter(b => b.type === 'text').map(b => b.text).join('')
summary = answeredSummary(text) ?? model.summary
summary = answeredSummary(text, t) ?? model.summary
}
return (
<ToolRow
t={t}
variant={model.variant}
toolName={toolName}
icon={<IconQuestionOutline14 />}
title="Ask question"
title={t('ask.rowTitle')}
summary={summary}
body={model.body}
output={model.output}
state={state}
inspect={inspect}
/>
)
}
@@ -87,6 +96,6 @@ export const askQuestionToolview = {
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
*/
apply(ctx: Context): void {
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'ask_user_question' }, AskQuestionRow)
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'ask_user_question', locale: NS }, AskQuestionRow)
},
}

View File

@@ -1,5 +1,5 @@
/* Bash toolview: same geometry/tokens as ToolRow (figma Bash · description),
plus the terminal card the row stacks under its summary line. */
plus the expand-gated terminal card under the summary line. */
/* Summary line over the terminal card; the summary row keeps its own 24px
height, so the card is a column around it rather than a change to it. */
@@ -8,10 +8,23 @@
flex-direction: column;
}
/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap),
and replaces the primitive's standalone vertical margin with the flow's. */
/* Expanded terminal card, matching ToolRow's terminalBody: 4px indent, l1
hairline, and the max-height scroll on the card's own OUTPUT (banner stays
pinned; 224px = the 260px card cap minus the ~36px banner); the margin
replaces the primitive's standalone vertical margin with the flow's. */
.terminal {
margin: 4px 0 4px 22px;
--dsl-terminal-font: var(--dsw-font-markdown-code-block-small);
--dsl-terminal-line-height: 18px;
--dsl-terminal-output-max-height: 224px;
margin: 4px 0 4px 4px;
border: 1px solid var(--dsw-alias-border-l1);
}
/* ToolRow's unified expand interaction, replicated per the registrant
posture: pointer on the expandable row (the icon→chevron hover preview is
the affordance, no row fill). */
.root[data-expandable] {
cursor: pointer;
}
.root {
@@ -47,6 +60,7 @@
}
.leading {
position: relative; /* .chevronHover overlay anchor */
flex: none;
width: 16px;
height: 16px;
@@ -57,6 +71,34 @@
color: var(--dsw-alias-label-tertiary);
}
.chevron {
color: var(--dsw-alias-label-secondary);
}
/* Hover preview on the expandable row: the idle icon crossfades (100ms) into
a down chevron before the row is opened — same overlay as ToolRow. */
.iconIdle {
display: inline-flex;
opacity: 1;
transition: opacity 100ms ease;
}
.chevronHover {
position: absolute;
inset: 0;
margin: auto;
opacity: 0;
transition: opacity 100ms ease;
}
.root:hover .iconIdle {
opacity: 0;
}
.root:hover .chevronHover {
opacity: 1;
}
.scopeBadge {
flex: none;
margin-right: 8px;
@@ -95,6 +137,52 @@
color: var(--dsw-alias-label-tertiary);
}
/* Error row's collapsed summary: the failure's first line in the error color. */
.errorSummary {
color: var(--dsw-alias-state-error-primary);
}
/* Hover-revealed Inspect pill under the expanded terminal's bottom-left —
ToolRow's .bodyWrap/.inspectButton treatment, replicated per the registrant
posture: real flow (it reserves its line), revealed by hovering anywhere on
the tool call — title row included — or by keyboard focus. */
.bodyWrap {
display: flex;
flex-direction: column;
}
.inspectButton {
display: inline-flex;
align-self: flex-start;
align-items: center;
gap: 4px;
margin: 4px 0 2px 4px;
padding: 2px 8px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 999px;
/* Base background, not bg-overlay: the overlay token reads too heavy. */
background: var(--dsw-alias-bg-base);
color: var(--dsw-alias-label-secondary);
font-size: 11px;
line-height: 16px;
cursor: pointer;
opacity: 0;
transition: opacity 100ms ease;
}
.card:hover .inspectButton,
.inspectButton:focus-visible {
opacity: 1;
}
/* Solid hover fill: the pill floats over terminal output, so a translucent
hover token would let the text underneath bleed through. */
.inspectButton:hover {
background: var(--dsw-alias-interactive-bg-hover-solid);
color: var(--dsw-alias-label-primary);
}
.visuallyHidden {
position: absolute;
width: 1px;

View File

@@ -4,22 +4,31 @@
// Child sessions keep a scoped badge so session-dimension differentiation stays
// observable inside the component (no parallel registry).
//
// A bash call declares the terminal render intent, so this row also renders
// the command's own output through TerminalBlock. This row has no expand
// control and is not a details-panel target either (tool rows stopped being
// one), so its terminal body is resident rather than expand-gated as in
// ToolRow, and the card's own copy and expand controls are the row's only
// interactions. CHAT_TERMINAL_MAX_LINES is passed as `maxLines` — the chat
// flow's tighter cap over the block's own default of 16 — and the block's
// internal expander keeps a long output from taking over the message flow.
// A bash call declares the terminal render intent, so this row renders the
// command's own output through TerminalBlock — expand-gated exactly like
// ToolRow's unified interaction: collapsed by default, the whole summary row
// is the toggle (click / Enter / Space, icon→chevron hover preview; the
// summary stays inline while open),
// and the expanded card max-height-scrolls inside its own surface with the
// full output (maxLines Infinity — no middle collapse). An error row's
// collapsed summary is the failure's first line in the error color.
import { useState, type KeyboardEvent } from 'react'
import type { Context } from 'cordis'
import { IconApiOutline14, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import clsx from 'clsx'
import {
IconApiOutline14, IconChevronDownOutline14, StateDot, TerminalBlock,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { ToolRowProps } from '../contract/slots.ts'
import { CHAT_TERMINAL_MAX_LINES, terminalCardModel } from '../contract/terminal-card-model.ts'
import { terminalBlockLabels, terminalCardModel, terminalFailed } from '../contract/terminal-card-model.ts'
import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts'
import { NS } from '../locales.ts'
import css from './bash-sample.module.css'
/** Bash row props: the toolview runtime share plus the standard locale seat. */
type BashRowProps = ToolRowProps & PropsLocale<'conversation'>
function leadingFor(state: ToolRowState) {
switch (state) {
case 'error': return <StateDot state="error" />
@@ -30,48 +39,99 @@ function leadingFor(state: ToolRowState) {
}
/** Visually hidden status — StateDot is aria-hidden; AT needs a text label. */
function stateStatus(state: ToolRowState): string | null {
function stateStatus(state: ToolRowState, t: BashRowProps['t']): string | null {
switch (state) {
case 'running': return '运行中'
case 'error': return '失败'
case 'stopped': return '已停止'
case 'running': return t('bash.running')
case 'error': return t('bash.failed')
case 'stopped': return t('bash.stopped')
default: return null
}
}
/**
* Bash row: icon + Bash · {description} in the shared ToolRow chrome, with the
* command's terminal card resident below it. The summary row is not a
* details-panel control (tool rows stopped being one), so the card's copy and
* expand controls are the row's only interactions.
* Bash row: icon + Bash · {description} in the shared ToolRow chrome, the
* whole row toggling the command's terminal card (ToolRow's unified
* expand interaction, replicated locally per the registrant posture).
*/
export function BashRow({ toolName, block, sessionId, useSessions }: ToolRowProps) {
export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }: BashRowProps) {
const model = toolRowModel(toolName, block)
// Session workspace root: the terminal view's cwd resolves against it (an
// omitted workdir IS the workspace), which the pure presenter cannot do.
const cwd = useSessions(list => list.byId[sessionId]?.cwd)
const terminal = terminalCardModel(block, cwd)
// A failing exit status is the terminal card's own error signal (the call
// itself settles isError:false), surfaced as the row's red state dot.
const state = model.state === 'ok' && terminal !== null && terminalFailed(terminal)
? 'error'
: model.state
const isChild = useSessions(list => list.byId[sessionId]?.parentId !== undefined)
const status = stateStatus(model.state)
const status = stateStatus(state, t)
const [expanded, setExpanded] = useState(false)
const expandable = terminal !== null
const open = expanded && expandable
const failureLine = model.state === 'error' ? model.errorSummary : null
const toggleExpand = () => {
setExpanded(v => !v)
}
const toggleFromKeyboard = (event: KeyboardEvent<HTMLDivElement>) => {
if (!expandable || (event.key !== 'Enter' && event.key !== ' ')) return
event.preventDefault()
toggleExpand()
}
const leading = open
? <IconChevronDownOutline14 className={css.chevron} />
: expandable
? (
<>
<span className={css.iconIdle}>{leadingFor(state)}</span>
<IconChevronDownOutline14 className={clsx(css.chevron, css.chevronHover)} />
</>
)
: leadingFor(state)
return (
<div className={css.card}>
<div
className={css.root}
data-sample={isChild ? 'bash-scoped' : 'bash-global'}
data-variant="bash"
data-state={model.state}
data-state={state}
data-expandable={expandable || undefined}
role={expandable ? 'button' : undefined}
tabIndex={expandable ? 0 : undefined}
aria-expanded={expandable ? open : undefined}
onClick={expandable ? toggleExpand : undefined}
onKeyDown={expandable ? toggleFromKeyboard : undefined}
>
<span className={css.leading}>{leadingFor(model.state)}</span>
<span className={css.leading}>{leading}</span>
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
{isChild && <span className={css.scopeBadge}>scoped</span>}
<span className={css.title}>{model.title}</span>
<span className={css.sep} aria-hidden />
{/* The terminal presenter's description is the contractual
above-card summary; it outranks the args-derived one. */}
<span className={css.summary}>{terminal?.description ?? model.summary}</span>
above-card summary; a failure's first line outranks both. */}
<span className={clsx(css.summary, failureLine !== null && css.errorSummary)}>
{failureLine ?? terminal?.description ?? model.summary}
</span>
</div>
{terminal !== null && (
<TerminalBlock {...terminal.card} maxLines={CHAT_TERMINAL_MAX_LINES} className={css.terminal} />
{terminal !== null && open && (
/* Same hover-Inspect posture as ToolRow's expanded body, replicated
locally per the registrant posture. */
<div className={css.bodyWrap}>
<TerminalBlock
{...terminal.card}
maxLines={Infinity}
labels={terminalBlockLabels(t)}
className={css.terminal}
/>
{inspect !== undefined && (
<button type="button" className={css.inspectButton} onClick={inspect}>
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden>
<path d="M16 8L10.8571 12V10.552L14.1383 8L10.8571 5.448V4L16 8ZM5.14286 10.552L1.86171 8L5.14286 5.448V4L0 8L5.14286 12V10.552ZM9.02514 4L5.59657 12H6.84057L10.2691 4H9.02514Z" fill="currentColor" />
</svg>
Inspect
</button>
)}
</div>
)}
</div>
)
@@ -91,6 +151,6 @@ export const bashToolviewSample = {
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
*/
apply(ctx: Context): void {
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'bash' }, BashRow)
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'bash', locale: NS }, BashRow)
},
}

View File

@@ -1,16 +1,21 @@
// todo_write toolview: plan-flavored summary row replacing the generic
// "Tool call" card, registered into the keyed 'conversation.chat.toolview'
// hole like the bash sample (a product registration, not a sample). The row
// composes ToolRow (chrome, running sweep, leading expansion) and swaps in a
// composes ToolRow (chrome, running sweep, whole-row expand) and swaps in a
// summary of the written list (counts + active item) from the call args; the
// durable list itself renders in the TodoPanel above the composer, so the
// row stays one line.
// row stays one line until expanded.
import { IconChecklistOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context } from 'cordis'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { ToolRowProps } from '../contract/slots.ts'
import { toolRowModel } from '../contract/tool-call-model.ts'
import { ToolRow } from '../chat/ToolRow.tsx'
import { NS } from '../locales.ts'
/** Todo row props: the toolview runtime share plus the standard locale seat. */
type TodoRowProps = ToolRowProps & PropsLocale<'conversation'>
/** One parsed args item, shape-checked (model JSON: any field may be missing or mistyped). */
interface TodoWriteItem { content?: unknown; status?: unknown }
@@ -19,7 +24,7 @@ function isItem(value: unknown): value is TodoWriteItem {
return typeof value === 'object' && value !== null
}
function summarize(argsRaw: string): string | null {
function summarize(argsRaw: string, t: TodoRowProps['t']): string | null {
let parsed: unknown
try {
parsed = JSON.parse(argsRaw)
@@ -32,30 +37,35 @@ function summarize(argsRaw: string): string | null {
if (typeof parsed !== 'object' || parsed === null) return null
const todos = (parsed as { todos?: unknown }).todos
if (!Array.isArray(todos) || !todos.every(isItem)) return null
const done = todos.filter(t => t.status === 'completed').length
const active = todos.find(t => t.status === 'in_progress')
const head = `${done}/${todos.length} 已完成`
const done = todos.filter(item => item.status === 'completed').length
const active = todos.find(item => item.status === 'in_progress')
const head = t('todo.completed', { done, total: todos.length })
return typeof active?.content === 'string' && active.content !== ''
? `${head} · ${active.content}`
: head
}
/** One-line plan update row (leading toggle expands the raw args). Non-ok
* execution states keep the shared row's dot semantics — a cancelled call
* wrote no todo/write, so it must not read as a completed update. */
export function TodoRow({ toolName, block }: ToolRowProps) {
/** One-line plan update row (the whole row toggles the call's Input/Output
* sections, ToolRow's unified expand). Non-ok execution states keep the
* shared row's dot semantics — a cancelled call wrote no todo/write, so it
* must not read as a completed update. */
export function TodoRow({ toolName, block, inspect, t }: TodoRowProps) {
const model = toolRowModel(toolName, block)
const argsRaw = ('kind' in block ? block.call?.argsRaw : block.argsRaw) ?? ''
const summary = summarize(argsRaw) ?? model.summary
const summary = summarize(argsRaw, t) ?? model.summary
return (
<ToolRow
t={t}
variant={model.variant}
toolName={toolName}
icon={<IconChecklistOutline14 />}
title="更新任务清单"
title={t('todo.rowTitle')}
summary={summary}
body={model.body}
output={model.output}
errorSummary={model.errorSummary}
state={model.state}
inspect={inspect}
/>
)
}
@@ -73,6 +83,6 @@ export const todoToolview = {
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
*/
apply(ctx: Context): void {
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'todo_write' }, TodoRow)
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'todo_write', locale: NS }, TodoRow)
},
}

View File

@@ -0,0 +1,95 @@
/* Web toolview: same geometry/tokens as ToolRow (figma icon · summary), plus
the web card the row stacks under its summary line, mirroring the bash row's
resident terminal card. */
/* Summary line over the web card; the summary row keeps its own 24px height,
so the card is a column around it rather than a change to it. */
.card {
display: flex;
flex-direction: column;
}
/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap),
and replaces the primitive's standalone vertical margin with the flow's. */
.web {
margin: 4px 0 4px 22px;
}
.root {
position: relative; /* sweep-glare overlay anchor */
overflow: hidden;
display: flex;
align-items: center;
height: 24px;
min-width: 0;
}
/* Running sweep glare — same deepsuite ShimmerText pattern as ToolRow. */
.root[data-state='running']::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 300px;
background: linear-gradient(
90deg,
transparent 0%,
color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%,
transparent 100%
);
animation: dsh-web-row-sweep 2.6s ease-out infinite;
pointer-events: none;
}
@keyframes dsh-web-row-sweep {
0% { left: -300px; }
90%, 100% { left: 100%; }
}
.leading {
flex: none;
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-right: 6px;
color: var(--dsw-alias-label-tertiary);
}
.title {
flex: none;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-secondary);
}
.sep {
flex: none;
width: 2px;
height: 2px;
border-radius: 1px;
margin: 0 8px;
background: var(--dsw-alias-label-caption);
}
.summary {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-tertiary);
}
.visuallyHidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}

View File

@@ -0,0 +1,92 @@
// Web toolview registrant: third-party posture over the keyed toolview hole
// (ctx.slots.register + ToolRowProps only — never imports the chat domain).
// Registered under BOTH web_search and web_fetch, since both declare the one
// `web` render intent and render through the one WebBlock family; the row
// discriminates on the toolName only to pick its icon and title.
//
// A web tool declares the `web` render intent at result time, so this row
// renders the completed retrieval through WebBlock resident below its summary,
// the same posture BashRow uses for the terminal card: no expand control on the
// row itself, not a details-panel target, and the block's own expander keeps a
// long source list from taking over the message flow (CHAT_WEB_MAX_SOURCES is
// passed as maxSources — the chat flow's tighter cap over the block's default
// of 16). Until the call settles there is no web card (the tools keep a generic
// pending view), so a running row is the summary line alone.
import type { Context } from 'cordis'
import { IconBrowseOutline16, IconSearchOutline16, StateDot, WebBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolRowProps } from '../contract/slots.ts'
import { CHAT_WEB_MAX_SOURCES, webCardModel } from '../contract/web-card-model.ts'
import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts'
import css from './web-row.module.css'
/** web_fetch reads one URL; web_search queries. Titles are figma literals. */
const WEB_TITLES: Record<string, string> = {
web_search: 'Search',
web_fetch: 'Fetch',
}
/** Leading icon per tool, yielding to the state semantic while failed/stopped. */
function leadingFor(toolName: string, state: ToolRowState) {
switch (state) {
case 'error': return <StateDot state="error" />
case 'stopped': return <StateDot state="warning" />
// Running keeps the icon — the row sweep carries the in-flight signal.
default: return toolName === 'web_fetch' ? <IconBrowseOutline16 size={14} /> : <IconSearchOutline16 size={14} />
}
}
/** Visually hidden status — StateDot is aria-hidden; AT needs a text label. */
function stateStatus(state: ToolRowState): string | null {
switch (state) {
case 'running': return '运行中'
case 'error': return '失败'
case 'stopped': return '已停止'
default: return null
}
}
/**
* Web row: icon + Search/Fetch · {summary} in the shared ToolRow chrome, with
* the completed retrieval's web card resident below it. The summary row is not
* a details-panel control (tool rows stopped being one), so the card's own
* links and expander are the row's only interactions.
*/
export function WebRow({ toolName, block }: ToolRowProps) {
const model = toolRowModel(toolName, block)
const web = webCardModel(block)
const status = stateStatus(model.state)
return (
<div className={css.card}>
<div className={css.root} data-variant="web" data-tool={toolName} data-state={model.state}>
<span className={css.leading}>{leadingFor(toolName, model.state)}</span>
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
<span className={css.title}>{WEB_TITLES[toolName] ?? model.title}</span>
<span className={css.sep} aria-hidden />
<span className={css.summary}>{model.summary}</span>
</div>
{web !== null && (
<WebBlock {...web} maxSources={CHAT_WEB_MAX_SOURCES} className={css.web} />
)}
</div>
)
}
/**
* The web rows as a plain registrant plugin, riding the same load-order seam as
* the bash sample: `inject: ['conversation']` guarantees the chat entry (and
* with it the 'conversation.chat.toolview' declaration) is on the ledger. One
* WebRow component registers under both web tool names.
*/
export const webToolview = {
name: 'web-toolview',
inject: ['slots', 'conversation'],
/**
* Register the web row under both web tool names' keyed toolview holes.
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
*/
apply(ctx: Context): void {
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'web_search' }, WebRow)
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'web_fetch' }, WebRow)
},
}

View File

@@ -50,7 +50,9 @@ async function bench() {
})
const layoutFake = { openDetails: vi.fn(), closeDetails: vi.fn() }
runtime.provide('layout', layoutFake)
runtime.provide('locale', new LocaleService(runtime.ctx))
const locale = new LocaleService(runtime.ctx)
runtime.provide('locale', locale)
runtime.slots.installLocale(locale)
// The AppFrame role: the conversation-package slots must be declared by a
// live entry before apply can contribute into them.
@@ -186,9 +188,11 @@ describe('conversation slot inject surface', () => {
// hooks compartment still present so the render side's hook order holds.
const absent = injectFn(undefined)
expect(absent.keyboard).toBeUndefined()
expect(absent.toggleCommandMenu).toBeUndefined()
expect(absent.stop).toBeUndefined()
expect(absent.hooks.notices.getSnapshot()).toBeNull()
expect(absent.hooks.lexicon.getSnapshot().size).toBe(0)
expect(absent.hooks.menuLauncher.getSnapshot()).toBeNull()
// A scope whose service tree lost 'conversation' (the feature fiber
// unloaded while a retained inject closure re-runs): fails loud too.
const stop = injectFn(ROOT).stop!
@@ -310,7 +314,7 @@ describe('conversation slot inject surface', () => {
// Label falls back to the id when a rider declares none.
const off2 = b.slots.register(
{ name: 'conversation.view', id: 'bare', order: 6 } as never, (() => null) as never)
expect(injected.views.list().map(v => v.label)).toEqual(['Chat', 'X', 'bare'])
expect(injected.views.list().map(v => v.label)).toEqual(['对话', 'X', 'bare'])
off()
off2()
unsub()

View File

@@ -10,9 +10,11 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
// Export discipline: packages/client/AGENTS.md.
import { AskQuestionRow, askQuestionToolview } from '../src/client/toolviews/ask-question-row.tsx'
import { zh } from '../src/client/locales.ts'
afterEach(cleanup)
@@ -28,13 +30,16 @@ const resultNode = (argsRaw: string, resultText: string | null, over?: Partial<T
const runningCall = (argsRaw: string) =>
({ callId: 'c1', name: 'ask_user_question', argsRaw, turn: 1, step: 1, time: 1_000, callView: null })
function rowProps(block: unknown): ToolRowProps {
// Standard locale seat stub mirroring the real ns → common → key chain.
const t = makeTranslate(zh, commonZh)
function rowProps(block: unknown): Parameters<typeof AskQuestionRow>[0] {
return {
callId: 'c1', toolName: 'ask_user_question', block,
callId: 'c1', toolName: 'ask_user_question', block, t,
openFile: vi.fn(),
sessionId: 's1',
useSessions: () => undefined,
} as unknown as ToolRowProps
} as unknown as Parameters<typeof AskQuestionRow>[0]
}
const answers = (entries: unknown[]): string => JSON.stringify({ answers: entries })
@@ -42,8 +47,8 @@ const answers = (entries: unknown[]): string => JSON.stringify({ answers: entrie
describe('AskQuestionRow', () => {
it('running call reads waiting (args-independent: the composer takeover shows the questions)', () => {
const view = render(<AskQuestionRow {...rowProps(runningCall(ARGS))} />)
expect(screen.getByText('Ask question')).toBeTruthy()
expect(screen.getByText('waiting')).toBeTruthy()
expect(screen.getByText('提问')).toBeTruthy()
expect(screen.getByText('等待回答')).toBeTruthy()
expect(view.container.querySelector('[data-state="running"]')).not.toBeNull()
})
@@ -53,7 +58,7 @@ describe('AskQuestionRow', () => {
{ id: 'b', selected: [], custom: 'freeform' },
{ id: 'c', selected: ['y', 'z'], custom: '' },
])))} />)
expect(screen.getByText('3/3 answered')).toBeTruthy()
expect(screen.getByText('3/3 已回答')).toBeTruthy()
})
it('skipped questions (no selection, no custom) stay out of the answered count', () => {
@@ -62,7 +67,7 @@ describe('AskQuestionRow', () => {
{ id: 'b', selected: [], custom: '' },
{ id: 'c' },
])))} />)
expect(screen.getByText('1/3 answered')).toBeTruthy()
expect(screen.getByText('1/3 已回答')).toBeTruthy()
expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull()
})
@@ -82,7 +87,7 @@ describe('AskQuestionRow', () => {
// ASK_CANCELLED: the apiproxy ask_user_question handler's cancel error.
const view = render(<AskQuestionRow {...rowProps(resultNode(ARGS, null,
{ isError: true, error: { name: 'UserInteractionError', code: 'ASK_CANCELLED' } }))} />)
expect(screen.getByText('cancelled')).toBeTruthy()
expect(screen.getByText('已取消')).toBeTruthy()
expect(view.container.querySelector('[data-state="error"]')).not.toBeNull()
})
@@ -90,7 +95,7 @@ describe('AskQuestionRow', () => {
// ASK_ABORTED: the apiproxy ask handler's turn-abort settlement.
const view = render(<AskQuestionRow {...rowProps(resultNode(ARGS, null,
{ isError: true, error: { name: 'UserInteractionError', code: 'ASK_ABORTED' } }))} />)
expect(screen.getByText('interrupted')).toBeTruthy()
expect(screen.getByText('已中断')).toBeTruthy()
expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull()
})
@@ -98,7 +103,7 @@ describe('AskQuestionRow', () => {
const view = render(<AskQuestionRow {...rowProps(resultNode(ARGS, null,
{ isError: true, error: { name: 'Interrupted', code: 'interrupted' } }))} />)
expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull()
expect(screen.queryByText('cancelled')).toBeNull()
expect(screen.queryByText('已取消')).toBeNull()
expect(screen.getByText(`ask_user_question · ${ARGS}`)).toBeTruthy()
})
@@ -124,6 +129,9 @@ describe('AskQuestionRow', () => {
expect(askQuestionToolview.inject).toEqual(['slots', 'conversation'])
const register = vi.fn()
askQuestionToolview.apply({ slots: { register } } as never)
expect(register).toHaveBeenCalledWith({ name: 'conversation.chat.toolview', key: 'ask_user_question' }, AskQuestionRow)
expect(register).toHaveBeenCalledWith(
{ name: 'conversation.chat.toolview', key: 'ask_user_question', locale: 'conversation' },
AskQuestionRow,
)
})
})

View File

@@ -82,7 +82,9 @@ const LAYOUT_CHILDREN = {
async function bench(nodes: ToolResultNode[], opts?: { blank?: boolean }) {
const runtime = await SlotTestRuntime.create()
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
runtime.provide('locale', new LocaleService(runtime.ctx))
const locale = new LocaleService(runtime.ctx)
runtime.provide('locale', locale)
runtime.slots.installLocale(locale)
await runtime.sessions.add({
id: SID,
summary: { title: 'S', displayTitle: 'S', cwd: '/proj' },
@@ -116,7 +118,7 @@ describe('todo_write assembly (product registrations, no outlet twins)', () => {
// (default-collapsed: the header summary shows; rows appear on expand).
const panel = view.container.querySelector('[data-testid="todo-panel"]')
expect(panel).not.toBeNull()
expect(panel!.textContent).toContain('1/3 tasks · 1 in progress')
expect(panel!.textContent).toContain('1/3 项任务 · 1 项进行中')
fireEvent.click(panel!.querySelector('button')!)
expect([...panel!.querySelectorAll('li')].map(li => li.getAttribute('data-status')))
.toEqual(['completed', 'in_progress', 'pending'])
@@ -134,7 +136,7 @@ describe('todo_write assembly (product registrations, no outlet twins)', () => {
})
describe('terminal card assembly', () => {
it('the keyed bash row carries a resident terminal card; the fallback row reaches one through expand', async () => {
it('both the keyed bash row and the fallback row reach the terminal card through the whole-row expand', async () => {
const runtime = await bench([
bashResult(3, 'c-keyed'),
// An unregistered tool with terminal views: GenericToolCard fallback.
@@ -142,15 +144,20 @@ describe('terminal card assembly', () => {
])
const view = runtime.renderRoot()
// Keyed BashRow renders the card residently (no expand gesture).
const keyed = view.container.querySelector('[data-sample="bash-global"]')?.parentElement
expect(keyed?.querySelector('[data-terminal]')).not.toBeNull()
// Keyed BashRow: collapsed by default, the whole summary row is the toggle.
const keyedRow = view.container.querySelector('[data-sample="bash-global"]')
const keyed = keyedRow?.parentElement
expect(keyed?.querySelector('[data-terminal]')).toBeNull()
fireEvent.click(keyedRow!)
await waitFor(() => {
expect(keyed!.querySelector('[data-terminal]')).not.toBeNull()
})
// Fallback row: card appears only after its expand control.
// Fallback row: same unified expand interaction.
const fallback = view.container.querySelector('[data-tool="fx-bash"]')
expect(fallback).not.toBeNull()
expect(fallback!.querySelector('[data-terminal]')).toBeNull()
fireEvent.click(fallback!.querySelector('button[aria-expanded]')!)
fireEvent.click(fallback!.querySelector('[data-expandable]')!)
await waitFor(() => {
expect(fallback!.querySelector('[data-terminal]')).not.toBeNull()
})
@@ -162,7 +169,9 @@ describe('resident composer', () => {
it('renders the locked view state while no session exists at all', async () => {
const runtime = await SlotTestRuntime.create()
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
runtime.provide('locale', new LocaleService(runtime.ctx))
const locale = new LocaleService(runtime.ctx)
runtime.provide('locale', locale)
runtime.slots.installLocale(locale)
await runtime.root.declare(LAYOUT_CHILDREN, AppRoot)
await runtime.mount({ inject: [...inject], apply })
const view = runtime.renderRoot()
@@ -171,7 +180,7 @@ describe('resident composer', () => {
const textarea = view.container.querySelector('textarea')
expect(textarea).not.toBeNull()
expect(textarea!.disabled).toBe(true)
expect(view.getByRole('button', { name: 'Choose workspace' })).toBeTruthy()
expect(view.getByRole('button', { name: '选择工作区' })).toBeTruthy()
await runtime.dispose()
})
@@ -204,7 +213,9 @@ describe('prompt rejection through the assembled composer', () => {
it('renders the promptError alert strip and keeps the draft in the machine', async () => {
const runtime = await SlotTestRuntime.create()
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
runtime.provide('locale', new LocaleService(runtime.ctx))
const locale = new LocaleService(runtime.ctx)
runtime.provide('locale', locale)
runtime.slots.installLocale(locale)
const prompt = vi.fn<ISession['prompt']>(async () => ({
ok: false, error: { code: 'agent-busy', message: 'prompt rejected before acceptance', details: { reason: 'busy' } },
}))
@@ -245,7 +256,7 @@ describe('title projection across assembled surfaces', () => {
const runtime = await bench([])
const view = runtime.renderRoot()
// The strict session header breadcrumb reads useSessions ancestry.
const crumb = within(view.container.querySelector('[aria-label="Session hierarchy"]') as HTMLElement)
const crumb = within(view.container.querySelector('[aria-label="会话层级"]') as HTMLElement)
expect(crumb.getByText('S')).toBeTruthy()
await runtime.sessions.updateSummary(SID, { displayTitle: '修订标题', title: '修订标题' })

View File

@@ -10,6 +10,7 @@
import { describe, expect, it, vi } from 'vitest'
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
@@ -23,7 +24,9 @@ async function bench() {
await runtime.sessions.add(
{ id: CHILD, summary: { title: 'C', displayTitle: 'C', parentId: ROOT } }, { current: false })
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
runtime.provide('locale', new LocaleService(runtime.ctx))
const locale = new LocaleService(runtime.ctx)
runtime.provide('locale', locale)
runtime.slots.installLocale(locale)
// Declared by ui-layout's root entry in production; the test root declares
// them here so the contributions land.
@@ -52,7 +55,8 @@ describe('apply wiring', () => {
const b = await bench()
const entries = b.slots.entries('conversation.view')
expect(entries.map(e => e.options.id)).toEqual(['chat'])
expect(entries[0]?.options.label).toBe('Chat')
// Label is a locale thunk resolving through the zh dictionary.
expect(resolveSlotLabel(entries[0]?.options.label)).toBe('对话')
expect(entries[0]?.options.order).toBe(0)
// Declaring is claiming: the chat entry's registration put the hole on
// the ledger with the contract's kind/scope.
@@ -80,12 +84,13 @@ describe('apply wiring', () => {
await b.runtime.dispose()
})
it('mounts the bash sample and the product rows as keyed entries through the load-order seam', async () => {
it('mounts the bash sample, the web rows, and the product rows as keyed entries through the load-order seam', async () => {
const b = await bench()
// Every registrant plugin's inject: ['slots', 'conversation'] resolved — the
// service being present implies the chat entry declared the hole first.
// service being present implies the chat entry declared the hole first. The
// web rows register one component under both web tool names.
const entries = b.slots.entries('conversation.chat.toolview')
expect(entries.map(e => e.options.key)).toEqual(['bash', 'todo_write', 'ask_user_question'])
expect(entries.map(e => e.options.key)).toEqual(['bash', 'web_search', 'web_fetch', 'todo_write', 'ask_user_question'])
// Stats stick with the composer (not inside ChatView).
expect(b.slots.entries('conversation.composer.dock').map(e => e.options.id)).toEqual(['stats'])
await b.runtime.dispose()

View File

@@ -8,14 +8,23 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import {
formatMessageClock, msUntilNextLocalMidnight, startOfLocalDay,
} from '../src/client/chat/message-chrome.ts'
import { MessageItem } from '../src/client/chat/MessageItem.tsx'
import { MessageItem, type MessageItemProps } from '../src/client/chat/MessageItem.tsx'
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
import { StatsLine, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
import { zh } from '../src/client/locales.ts'
afterEach(cleanup)
afterEach(() => {
cleanup()
vi.useRealTimers()
})
// Mirrors the real lookup chain (conversation namespace, then common).
const t: MessageItemProps['t'] = makeTranslate(zh, commonZh)
describe('MessageItem arms', () => {
it('user bubbles expose clock / copy / branch / edit; copy writes the text', () => {
@@ -28,7 +37,7 @@ describe('MessageItem arms', () => {
const now = new Date()
const time = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 14, 24).getTime()
render(
<MessageItem node={{
<MessageItem t={t} node={{
kind: 'user', seq: 1, time,
content: [{ type: 'text', text: 'hello bubble' }] as never,
source: null,
@@ -54,7 +63,7 @@ describe('MessageItem arms', () => {
value: exec,
})
render(
<MessageItem node={{
<MessageItem t={t} node={{
kind: 'user', seq: 1, time: 1_000,
content: [{ type: 'text', text: 'fallback body' }] as never,
source: null,
@@ -77,7 +86,7 @@ describe('MessageItem arms', () => {
},
})
render(
<MessageItem node={{
<MessageItem t={t} node={{
kind: 'user', seq: 1, time: 1_000,
content: [{ type: 'text', text: 'quiet' }] as never,
source: null,
@@ -95,7 +104,7 @@ describe('MessageItem arms', () => {
it('steering bubbles carry the interjection badge and non-text rest blocks, without user actions', () => {
const view = render(
<MessageItem node={{
<MessageItem t={t} node={{
kind: 'steering', seq: 2, turn: 1, source: null,
content: [{ type: 'text', text: 'steer!' }, { type: 'image', data: 'x' }] as never,
} as never}
@@ -109,7 +118,7 @@ describe('MessageItem arms', () => {
it('context uses the Tool calls disclosure chrome and keeps its JSON collapsed by default', () => {
const ctxView = render(
<MessageItem node={{
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'x\n"y":,[{}]' }],
@@ -135,7 +144,7 @@ describe('MessageItem arms', () => {
it('context preserves the bounded JSON truncation contract', () => {
const view = render(
<MessageItem node={{
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'x'.repeat(21_000) }],
@@ -150,25 +159,176 @@ describe('MessageItem arms', () => {
it('unknown nodes retain the generic JSON row', () => {
const unknownView = render(
<MessageItem node={{ kind: 'unknown', seq: 4, type: 'surface/next', data: { x: 1 } } as never} />,
<MessageItem t={t} node={{ kind: 'unknown', seq: 4, type: 'surface/next', data: { x: 1 } } as never} />,
)
expect(unknownView.getByText(/未知 surface 事件surface\/next/)).toBeTruthy()
})
it('collapses retry details behind the durable model retry status', () => {
vi.useFakeTimers()
vi.setSystemTime(10_000)
const view = render(
<MessageItem
t={t}
retryActive
node={{
kind: 'model-retry',
seq: 5,
time: 10_000,
retryState: 'scheduled',
turn: 1,
step: 0,
provider: 'mock',
mode: 'normal',
policyKey: 'mock-normal',
retry: 1,
maxRetries: 2,
delayMs: 2_500.4,
failure: { code: 'TRANSPORT', message: '连接被重置' },
}}
/>,
)
const details = view.container.querySelector('details')
const summary = view.container.querySelector('summary')
expect(details?.open).toBe(false)
expect(details?.dataset.active).toBe('true')
expect(view.getByRole('status').textContent).toBe('正在重试模型请求1/2 · 3s')
expect(view.getByText('重试延迟:').parentElement?.textContent).toBe('重试延迟2500ms')
expect(view.getByText('失败原因:').parentElement?.textContent).toBe('失败原因:连接被重置')
act(() => { vi.advanceTimersByTime(1_100) })
expect(view.getByRole('status').textContent).toBe('正在重试模型请求1/2 · 2s')
act(() => { vi.advanceTimersByTime(1_000) })
expect(view.getByRole('status').textContent).toBe('正在重试模型请求1/2 · 1s')
view.rerender(
<MessageItem
t={t}
retryActive
node={{
kind: 'model-retry',
seq: 6,
time: 12_100,
retryState: 'scheduled',
turn: 2,
step: 0,
provider: 'mock',
mode: 'normal',
policyKey: 'mock-normal',
retry: 2,
maxRetries: 2,
delayMs: 3_500.4,
failure: { code: 'TRANSPORT', message: '再次断开' },
}}
/>,
)
expect(view.getByRole('status').textContent).toBe('正在重试模型请求2/2 · 4s')
if (summary === null) throw new Error('retry summary missing')
fireEvent.click(summary)
expect(details?.open).toBe(true)
view.rerender(
<MessageItem t={t} node={{
kind: 'model-retry',
seq: 6,
time: 12_100,
retryState: 'started',
turn: 2,
step: 0,
provider: 'mock',
mode: 'normal',
policyKey: 'mock-normal',
retry: 2,
maxRetries: 2,
delayMs: 3_500.4,
failure: { code: 'TRANSPORT', message: '再次断开' },
}}
/>,
)
expect(details?.dataset.active).toBeUndefined()
expect(view.getByRole('status').textContent).toBe('已重试模型请求2/2 · 4s')
view.rerender(
<MessageItem t={t} node={{
kind: 'model-retry',
seq: 7,
time: 12_100,
retryState: 'started',
turn: 3,
step: 0,
provider: 'mock',
mode: 'always',
policyKey: 'mock-always',
retry: 3,
delayMs: 3_500.4,
failure: { code: 'TRANSPORT', message: '继续重试' },
}}
/>,
)
expect(view.getByRole('status').textContent).toBe('已重试模型请求3/∞) · 4s')
view.rerender(
<MessageItem t={t} node={{
kind: 'model-retry',
seq: 8,
time: 12_100,
retryState: 'cancelled',
turn: 4,
step: 0,
provider: 'mock',
mode: 'normal',
policyKey: 'mock-normal',
retry: 1,
maxRetries: 2,
delayMs: 3_500.4,
failure: { code: 'TRANSPORT', message: '用户取消' },
}}
/>,
)
expect(view.getByRole('status').textContent).toBe('模型请求重试已取消1/2 · 4s')
})
it('synchronizes the countdown when an inactive retry becomes active at the one-second floor', () => {
vi.useFakeTimers()
vi.setSystemTime(10_000)
const node = {
kind: 'model-retry',
seq: 5,
time: 10_000,
retryState: 'scheduled',
turn: 1,
step: 0,
provider: 'mock',
mode: 'normal',
policyKey: 'mock-normal',
retry: 1,
maxRetries: 2,
delayMs: 5_000,
failure: { code: 'TRANSPORT', message: '连接被重置' },
} as const
const view = render(<MessageItem t={t} node={node} />)
expect(view.getByRole('status').textContent).toBe('等待重试模型请求1/2 · 5s')
act(() => { vi.advanceTimersByTime(4_200) })
view.rerender(<MessageItem t={t} node={node} retryActive />)
expect(view.getByRole('status').textContent).toBe('正在重试模型请求1/2 · 1s')
})
})
describe('formatMessageClock', () => {
const now = new Date(2026, 6, 29, 10, 0).getTime()
it('keeps HH:mm on the same calendar day', () => {
expect(formatMessageClock(new Date(2026, 6, 29, 14, 24).getTime(), now)).toBe('14:24')
expect(formatMessageClock(new Date(2026, 6, 29, 14, 24).getTime(), t, now)).toBe('14:24')
})
it('prefixes month and day across days in the same year', () => {
expect(formatMessageClock(new Date(2026, 0, 1, 14, 24).getTime(), now)).toBe('1月1日 14:24')
expect(formatMessageClock(new Date(2026, 0, 1, 14, 24).getTime(), t, now)).toBe('1月1日 14:24')
})
it('prefixes year, month, and day across years', () => {
expect(formatMessageClock(new Date(2025, 11, 31, 9, 5).getTime(), now)).toBe('2025年12月31日 09:05')
expect(formatMessageClock(new Date(2025, 11, 31, 9, 5).getTime(), t, now)).toBe('2025年12月31日 09:05')
})
it('arms the next local midnight from an in-day instant', () => {
@@ -191,7 +351,7 @@ describe('useCalendarDay boundary refresh', () => {
vi.setSystemTime(dayStart)
const time = new Date(2026, 6, 29, 14, 24).getTime()
render(
<MessageItem node={{
<MessageItem t={t} node={{
kind: 'user', seq: 1, time,
content: [{ type: 'text', text: 'night bubble' }] as never,
source: null,
@@ -209,7 +369,7 @@ describe('useCalendarDay boundary refresh', () => {
describe('small branch tails', () => {
it('AssistantMarkdown single-line reasoning summary skips the newline cut', () => {
const view = render(
<AssistantMarkdown blocks={[{ kind: 'reasoning', text: 'one-liner' }]} streaming={false} />,
<AssistantMarkdown t={t} blocks={[{ kind: 'reasoning', text: 'one-liner' }]} streaming={false} />,
)
expect(view.getByText('one-liner')).toBeTruthy()
})
@@ -224,6 +384,7 @@ describe('small branch tails', () => {
const time = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 14, 24).getTime()
const settled = render(
<AssistantMarkdown
t={t}
blocks={[{ kind: 'text', text: 'answer body' }, { kind: 'reasoning', text: 'hidden' }]}
streaming={false}
time={time}
@@ -238,6 +399,7 @@ describe('small branch tails', () => {
const thinkOnly = render(
<AssistantMarkdown
t={t}
blocks={[{ kind: 'reasoning', text: 'only thinking' }]}
streaming={false}
time={time}
@@ -248,7 +410,7 @@ describe('small branch tails', () => {
thinkOnly.unmount()
const streaming = render(
<AssistantMarkdown blocks={[{ kind: 'text', text: 'partial' }]} streaming time={time} />,
<AssistantMarkdown t={t} blocks={[{ kind: 'text', text: 'partial' }]} streaming time={time} />,
)
expect(streaming.queryByRole('button', { name: '复制' })).toBeNull()
expect(streaming.queryByText('14:24')).toBeNull()

View File

@@ -137,7 +137,9 @@ async function bench(snapshot: ConversationSnapshot) {
}
ctx.provide('workspaces', workspaces)
ctx.provide('layout', layout)
ctx.provide('locale', new LocaleService(ctx))
const locale = new LocaleService(ctx)
ctx.provide('locale', locale)
slots.installLocale(locale)
slots.install(createSlotRenderer())
slots.register({
@@ -203,7 +205,7 @@ describe('run_code sub-calls through the real chat machinery', () => {
expect(nest.querySelector('[data-tool="cordis_unmount"]')?.textContent)
.toContain('Unmount temporary Plugindyn-2')
fireEvent.click(mounted!.querySelector('button[aria-expanded]')!)
fireEvent.click(mounted!.querySelector('[data-expandable]')!)
expect(mounted!.querySelector('pre.shiki')?.textContent).toBe(code)
})
@@ -211,8 +213,8 @@ describe('run_code sub-calls through the real chat machinery', () => {
const parent = 'call-64'
const b = await bench(snapshotWith([codeResult(10, parent)], new Map()))
const view = mountApp(b.slots)
// The code row is expandable via its leading control (body = the program).
const toggle = view.container.querySelector('[data-variant="code"] button[aria-expanded]')
// The code row is expandable via the whole summary row (body = the program).
const toggle = view.container.querySelector('[data-variant="code"] [data-expandable]')
expect(toggle).not.toBeNull()
fireEvent.click(toggle!)
// Shiki splits the program into token spans inside one <pre class="shiki">:

View File

@@ -11,9 +11,16 @@ import type {
} from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { StatsLine, deriveStats, formatDuration, formatTokens, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
import { BashRow } from '../src/client/toolviews/bash-sample.tsx'
import { zh } from '../src/client/locales.ts'
type BashRowProps = Parameters<typeof BashRow>[0]
// Mirrors the real lookup chain (conversation namespace, then common).
const t: BashRowProps['t'] = makeTranslate(zh, commonZh)
afterEach(cleanup)
@@ -168,12 +175,13 @@ describe('bash sample row', () => {
const rowProps = (sessionId: SessionId, over?: {
store?: ReturnType<typeof listStore>
}): ToolRowProps => ({
}): BashRowProps => ({
callId: 'c1', toolName: 'bash', block: result('c1'),
openFile: vi.fn(),
sessionId,
useSessions: bindSnapshotSelector(over?.store ?? listStore()),
} as unknown as ToolRowProps)
t,
} as unknown as BashRowProps)
it('differential rendering: the scoped variant in sub-sessions, global at roots', () => {
const scoped = render(<BashRow {...rowProps(CHILD)} />)

View File

@@ -12,7 +12,7 @@ beforeEach(() => {
describe('createChatStore', () => {
it('init shape: empty selection/draft/view', () => {
const store = createChatStore().create()
expect(store.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null })
expect(store.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null, inspect: null })
})
it('actions cover the declared write set', () => {
@@ -30,6 +30,11 @@ describe('createChatStore', () => {
store.actions.setView('chat')
expect(store.store.getSnapshot().view).toBe('chat')
store.actions.setInspect({ callId: 'c1' })
expect(store.store.getSnapshot().inspect).toEqual({ callId: 'c1' })
store.actions.setInspect(null)
expect(store.store.getSnapshot().inspect).toBeNull()
})
it('restoreDraft only fills an empty draft (optimistic-send rollback contract)', () => {

View File

@@ -4,11 +4,16 @@ import { cleanup, fireEvent, render } from '@testing-library/react'
afterEach(cleanup)
import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import { classifyTool, resolveToolPath, toolRowModel } from '../src/client/contract/tool-call-model.ts'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { classifyTool, resolveToolPath, resultText, toolRowModel } from '../src/client/contract/tool-call-model.ts'
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
import { ToolRow } from '../src/client/chat/ToolRow.tsx'
import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
import type { ToolRowOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { GenericToolCard, type GenericToolCardProps } from '../src/client/chat/GenericToolCard.tsx'
import { zh } from '../src/client/locales.ts'
// Mirrors the real lookup chain (conversation namespace, then common).
const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh)
const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({
callId: 'c1', name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}',
@@ -102,6 +107,29 @@ describe('tool-call-model', () => {
.toBe('{\n "code": ""\n}')
})
it('resultText flattens text blocks verbatim, other shapes as JSON, empty error content to name: code', () => {
expect(resultText(result({ content: [{ type: 'text', text: 'a\nb' }] }))).toBe('a\nb')
expect(resultText(result({ content: [{ type: 'text', text: 'a' }, { type: 'image', data: 'x' } as never] })))
.toBe(`a\n${JSON.stringify({ type: 'image', data: 'x' }, null, 2)}`)
expect(resultText(result({ content: [], isError: true, error: { name: 'ToolError', code: 'denied' } })))
.toBe('ToolError: denied')
expect(resultText(result({ content: [] }))).toBe('')
})
it('derives output from the settled result and null while running or blank', () => {
expect(toolRowModel('bash', result({ content: [{ type: 'text', text: 'out' }] })).output).toBe('out')
expect(toolRowModel('bash', running()).output).toBeNull()
expect(toolRowModel('bash', result({ content: [] })).output).toBeNull()
})
it('derives errorSummary as the first output line on error rows only', () => {
const failed = result({ content: [{ type: 'text', text: 'boom\ndetail' }], isError: true })
expect(toolRowModel('bash', failed).errorSummary).toBe('boom')
expect(toolRowModel('bash', result({ content: [{ type: 'text', text: 'boom' }] })).errorSummary).toBeNull()
expect(toolRowModel('bash', result({ content: [], isError: true })).errorSummary).toBeNull()
expect(toolRowModel('bash', running()).errorSummary).toBeNull()
})
it('gives Cordis lifecycle tools action titles over their generic variants', () => {
expect(toolRowModel('cordis_inspect', running({
name: 'cordis_inspect',
@@ -132,6 +160,7 @@ describe('tool-call-model', () => {
describe('ToolRow', () => {
const rowProps = {
t,
variant: 'bash' as const, icon: <i data-testid="tool-icon" />, title: 'Bash',
summary: 'List files', body: '{\n "a": 1\n}', state: 'ok' as const,
}
@@ -144,14 +173,15 @@ describe('ToolRow', () => {
expect(view.container.querySelector('[aria-expanded]')?.getAttribute('aria-expanded')).toBe('false')
})
it('expanding swaps the leading slot to a chevron, hides summary, shows body', () => {
it('row click expands: chevron leading, summary kept inline, body in the scrolling card', () => {
const view = render(<ToolRow {...rowProps} />)
fireEvent.click(view.container.querySelector('button')!)
fireEvent.click(view.getByRole('button'))
expect(view.queryByTestId('tool-icon')).toBeNull()
expect(view.container.querySelector('svg')).not.toBeNull()
expect(view.queryByText('List files')).toBeNull()
expect(view.getByText('List files')).toBeTruthy()
expect(view.getByText(/"a": 1/)).toBeTruthy()
fireEvent.click(view.container.querySelector('button')!)
expect(view.container.querySelector('[class*="ioCard"]')).not.toBeNull()
fireEvent.click(view.getByRole('button'))
expect(view.queryByTestId('tool-icon')).not.toBeNull()
expect(view.getByText('List files')).toBeTruthy()
})
@@ -162,16 +192,20 @@ describe('ToolRow', () => {
expect(runningView.container.querySelector('[data-state="running"]')).not.toBeNull()
const errorView = render(<ToolRow {...rowProps} state="error" />)
expect(errorView.container.querySelector('[data-testid="tool-icon"]')).toBeNull()
// The dot rides the idle slot, so an expandable error row keeps the
// icon→chevron hover preview instead of losing it with the icon.
expect(errorView.container.querySelector('[class*="chevronHover"]')).not.toBeNull()
})
it('non-expandable rows render a passive leading slot', () => {
it('non-expandable rows render a passive leading slot and no row button', () => {
const view = render(<ToolRow {...rowProps} body={null} />)
expect(view.container.querySelector('button')).toBeNull()
expect(view.queryByRole('button')).toBeNull()
expect(view.container.querySelector('[aria-expanded]')).toBeNull()
expect(view.queryByTestId('tool-icon')).not.toBeNull()
})
it('an expandOnRowClick row toggles from Enter and Space, ignoring other keys', () => {
const view = render(<ToolRow {...rowProps} expandOnRowClick />)
it('the row toggles from Enter and Space, ignoring other keys', () => {
const view = render(<ToolRow {...rowProps} />)
const row = view.getByRole('button')
fireEvent.keyDown(row, { key: 'Tab' })
expect(row.getAttribute('aria-expanded')).toBe('false')
@@ -181,32 +215,31 @@ describe('ToolRow', () => {
expect(row.getAttribute('aria-expanded')).toBe('false')
})
it('a non-expandable expandOnRowClick row exposes no row button', () => {
const view = render(<ToolRow {...rowProps} body={null} expandOnRowClick />)
expect(view.queryByRole('button')).toBeNull()
})
it('file-path summary opens through onOpenFile; the leading slot is not an expand control', () => {
it('file rows expand from the row while the path link opens without toggling', () => {
const open = vi.fn()
const view = render(
<ToolRow {...rowProps} variant="read" title="Read" summary="src/a.ts" filePath="src/a.ts" onOpenFile={open} />,
)
const row = view.getByRole('button', { name: /Read/ })
// Path click opens the file and leaves the row collapsed.
fireEvent.click(view.getByText('src/a.ts'))
expect(open).toHaveBeenCalledWith('src/a.ts')
// Only the path link is a button — no args-expand affordance on file rows.
expect(view.container.querySelectorAll('button')).toHaveLength(1)
expect(view.container.querySelector('[aria-expanded]')).toBeNull()
expect(view.queryByText(/"a": 1/)).toBeNull()
expect(row.getAttribute('aria-expanded')).toBe('false')
// Row click (outside the link) expands the args body.
fireEvent.click(row)
expect(row.getAttribute('aria-expanded')).toBe('true')
expect(view.getByText(/"a": 1/)).toBeTruthy()
})
it('a single-file path disables expand even when onOpenFile is absent', () => {
it('a file path without onOpenFile renders a plain summary on an expandable row', () => {
const view = render(
<ToolRow {...rowProps} variant="write" title="Write" summary="作文.md" filePath="作文.md" />,
)
expect(view.container.querySelector('button')).toBeNull()
expect(view.container.querySelector('[aria-expanded]')).toBeNull()
fireEvent.click(view.getByText('作文.md'))
expect(view.queryByText(/"a": 1/)).toBeNull()
const row = view.getByRole('button')
fireEvent.click(row)
expect(row.getAttribute('aria-expanded')).toBe('true')
expect(view.getByText(/"a": 1/)).toBeTruthy()
})
it('non-file rows do not open anything when the summary is clicked', () => {
@@ -215,12 +248,82 @@ describe('ToolRow', () => {
fireEvent.click(view.getByText('List files'))
expect(open).not.toHaveBeenCalled()
})
it('an error row shows the failure first line in the collapsed summary and the full text expanded', () => {
const view = render(
<ToolRow {...rowProps} state="error" errorSummary="boom" output={'boom\ndetail'} />,
)
expect(view.getByText('boom')).toBeTruthy()
expect(view.queryByText('List files')).toBeNull()
fireEvent.click(view.getByRole('button'))
expect(view.getByText(/detail/)).toBeTruthy()
expect(view.container.querySelector('[data-error]')).not.toBeNull()
})
it('an error row without an error summary keeps the args summary', () => {
const view = render(<ToolRow {...rowProps} state="error" errorSummary={null} />)
expect(view.getByText('List files')).toBeTruthy()
})
it('an error file row drops the open-file link (the summary is failure prose, not the path)', () => {
const open = vi.fn()
const view = render(
<ToolRow
{...rowProps}
variant="write" title="Write" state="error" errorSummary="cannot overwrite"
filePath="src/a.ts" onOpenFile={open}
/>,
)
fireEvent.click(view.getByText('cannot overwrite'))
expect(open).not.toHaveBeenCalled()
// The failure line renders as plain text, not the underlined link button.
expect(view.container.querySelector('[class*="fileLink"]')).toBeNull()
})
it('the expanded body carries a hover Inspect pill that fires the callback', () => {
const inspect = vi.fn()
const view = render(<ToolRow {...rowProps} inspect={inspect} />)
// Collapsed: no pill.
expect(view.queryByText('Inspect')).toBeNull()
fireEvent.click(view.getByRole('button', { name: /Bash/ }))
const pill = view.getByText('Inspect')
fireEvent.click(pill)
expect(inspect).toHaveBeenCalledTimes(1)
// The pill click must not collapse the row (body is a .row sibling).
expect(view.getByRole('button', { name: /Bash/ }).getAttribute('aria-expanded')).toBe('true')
})
it('no inspect callback, no pill', () => {
const view = render(<ToolRow {...rowProps} />)
fireEvent.click(view.getByRole('button'))
expect(view.queryByText('Inspect')).toBeNull()
})
it('the expanded card gutter-labels each section it carries (IN / OUT)', () => {
const both = render(<ToolRow {...rowProps} output="result text" />)
fireEvent.click(both.getByRole('button'))
expect(both.getByText('IN')).toBeTruthy()
expect(both.getByText('OUT')).toBeTruthy()
expect(both.getByText('result text')).toBeTruthy()
cleanup()
const inputOnly = render(<ToolRow {...rowProps} />)
fireEvent.click(inputOnly.getByRole('button'))
expect(inputOnly.getByText('IN')).toBeTruthy()
expect(inputOnly.queryByText('OUT')).toBeNull()
cleanup()
const outputOnly = render(<ToolRow {...rowProps} body={null} output="only out" />)
fireEvent.click(outputOnly.getByRole('button'))
expect(outputOnly.queryByText('IN')).toBeNull()
expect(outputOnly.getByText('OUT')).toBeTruthy()
expect(outputOnly.getByText('only out')).toBeTruthy()
})
})
describe('ThinkRow', () => {
it('expands from either Think or the reasoning summary', () => {
const view = render(
<AssistantMarkdown
t={t}
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nCheck persistence' }]}
streaming={false}
/>,
@@ -234,11 +337,27 @@ describe('ThinkRow', () => {
fireEvent.click(view.getByText('Think'))
expect(row.getAttribute('aria-expanded')).toBe('false')
})
it('expanded Think drops the inline summary and renders plain prose, no IN card', () => {
const view = render(
<AssistantMarkdown
t={t}
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nCheck persistence' }]}
streaming={false}
/>,
)
fireEvent.click(view.getByText('Think'))
// The summary (first line) is gone from the row; only the body carries it.
expect(view.getAllByText(/Inspect the session/)).toHaveLength(1)
expect(view.queryByText('IN')).toBeNull()
expect(view.container.querySelector('[class*="ioCard"]')).toBeNull()
expect(view.container.querySelector('[class*="thinkBody"]')).not.toBeNull()
})
})
describe('GenericToolCard', () => {
const props = (toolName: string, block: RunningToolCall | ToolResultNode): ToolRowOwnerProps => ({
callId: 'c1', toolName, block, openFile: vi.fn(),
const props = (toolName: string, block: RunningToolCall | ToolResultNode): GenericToolCardProps => ({
callId: 'c1', toolName, block, openFile: vi.fn(), t,
})
it('renders the classified variant row from the frozen slice', () => {
@@ -283,6 +402,14 @@ describe('GenericToolCard', () => {
expect(view.container.querySelector('svg')).not.toBeNull()
})
it('passes the owner inspect callback through to the expanded row pill', () => {
const inspect = vi.fn()
const view = render(<GenericToolCard {...props('bash', result())} inspect={inspect} />)
fireEvent.click(view.getByRole('button', { name: /Bash/ }))
fireEvent.click(view.getByText('Inspect'))
expect(inspect).toHaveBeenCalledTimes(1)
})
it('file-path summary click reaches openFile; bash summary does not', () => {
const file = props('read', running({ name: 'read', argsRaw: '{"path":"src/x.ts"}' }))
const fileView = render(<GenericToolCard {...file} />)

View File

@@ -65,7 +65,9 @@ async function bench(nodes: ToolResultNode[]) {
const runtime = await SlotTestRuntime.create()
const layout = { openDetails: vi.fn(), closeDetails: vi.fn() }
runtime.provide('layout', layout)
runtime.provide('locale', new LocaleService(runtime.ctx))
const locale = new LocaleService(runtime.ctx)
runtime.provide('locale', locale)
runtime.slots.installLocale(locale)
await runtime.sessions.add({
id: SID,
summary: { title: 'S', displayTitle: 'S' },
@@ -112,7 +114,7 @@ describe('keyed toolview hole through the real machinery', () => {
expect(view.container.querySelector('[data-tool="cordis_unmount"]')?.textContent)
.toContain('Unmount temporary Plugindyn-2')
fireEvent.click(mounted!.querySelector('button[aria-expanded]')!)
fireEvent.click(mounted!.querySelector('[data-expandable]')!)
expect(mounted!.querySelector('pre.shiki')?.textContent).toBe(code)
await b.runtime.dispose()
})
@@ -193,7 +195,9 @@ describe('registrant load-order seam', () => {
it("suspends a registrant on inject: ['slots', 'conversation'] until the service (and the hole) exists", async () => {
const runtime = await SlotTestRuntime.create()
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
runtime.provide('locale', new LocaleService(runtime.ctx))
const locale = new LocaleService(runtime.ctx)
runtime.provide('locale', locale)
runtime.slots.installLocale(locale)
await runtime.root.declare(LAYOUT_CHILDREN, AppRoot)
// Third-party posture, mounted BEFORE ui-conversation: real fiber inject

View File

@@ -7,16 +7,20 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Profiler } from 'react'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import type {
AssistantMessageNode, CommandNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId,
SessionListState, ToolResultNode, UserMessageNode, WorkspaceListState,
AssistantMessageNode, CommandNode, ConversationNode, ConversationSnapshot,
ModelRetryNode, RunningToolCall, SessionId, SessionListState, ToolResultNode,
UserMessageNode, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
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 { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { createChatStore } from '../src/client/stores.ts'
import { ChatView } from '../src/client/chat/ChatView.tsx'
import { deriveChatFlow, flowKeys } from '../src/client/chat/chat-flow.ts'
import { zh } from '../src/client/locales.ts'
import { assistantActionsSeqs, deriveChatFlow, flowKeys } from '../src/client/chat/chat-flow.ts'
afterEach(cleanup)
// Keyless create() persists under the bare declared key; clear between cases
@@ -61,8 +65,15 @@ const user = (seq: number, text: string): UserMessageNode => ({
content: [{ type: 'text', text }] as never,
source: null,
})
const assistant = (seq: number, text: string): AssistantMessageNode => ({
kind: 'assistant', seq, time: seq * 1_000, turn: 1, step: 1, blocks: [{ kind: 'text', text }],
const assistant = (seq: number, text: string, turn = 1): AssistantMessageNode => ({
kind: 'assistant', seq, time: seq * 1_000, turn, step: 1, blocks: [{ kind: 'text', text }],
})
const retry = (seq: number): ModelRetryNode => ({
kind: 'model-retry', seq, time: seq * 1_000, turn: 1, step: 0,
retryState: 'scheduled',
provider: 'mock', mode: 'normal', policyKey: 'mock-normal',
retry: 1, maxRetries: 2, delayMs: 450,
failure: { code: 'TRANSPORT', message: '连接被重置' },
})
const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode => ({
kind: 'tool-result', seq, time: seq * 1_000, callId,
@@ -94,6 +105,13 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
const openDetails = vi.fn<(t: SelectionTarget) => void>()
const openFile = vi.fn<(path: string) => void>()
const loadOlder = vi.fn()
const inspectCall = vi.fn<(callId: string) => void>()
// In-memory scroll memory matching the apply.ts per-session map contract.
let savedScrollTop: number | null = null
const chatScroll = {
save: (top: number | null) => { savedScrollTop = top },
read: () => savedScrollTop,
}
const forkAt = vi.fn()
// Selection rides the REAL chat store (same construction path as
// production; the view reads it through the PropsStore useStore share).
@@ -121,10 +139,14 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
openDetails,
openFile,
loadOlder,
inspectCall,
chatScroll,
forkAt,
// Mirrors the real lookup chain (conversation namespace, then common).
t: makeTranslate(zh, commonZh),
}
const setSelection = (next: SelectionTarget | null): void => { chat.actions.select(next) }
return { set, ChatView, props, openDetails, openFile, loadOlder, forkAt, setSelection }
return { set, ChatView, props, openDetails, openFile, loadOlder, inspectCall, chatScroll, forkAt, setSelection }
}
describe('chat-flow derivation', () => {
@@ -141,6 +163,17 @@ describe('chat-flow derivation', () => {
expect(flowKeys(deriveChatFlow([...nodes, toolResult(7, 'd')]))).toBe('n1|n2|g3|n5|g6')
})
it('reuses one stable row for consecutive retry turns', () => {
const first = retry(2)
const second = { ...retry(3), turn: 2, retry: 2 }
const initial = deriveChatFlow([user(1, 'try'), first])
const updated = deriveChatFlow([user(1, 'try'), first, second])
expect(flowKeys(initial)).toBe('n1|n2')
expect(flowKeys(updated)).toBe('n1|n2')
expect(updated).toHaveLength(2)
expect(updated[1]?.kind === 'node' && updated[1].node).toBe(second)
})
it('skips render-nothing assistant nodes so tool runs stay one group', () => {
// A tool-call-only step message (and blank text/reasoning) renders nothing:
// it must not split the run into two groups with an empty line between.
@@ -156,6 +189,23 @@ describe('chat-flow derivation', () => {
expect(flowKeys(deriveChatFlow([toolResult(3, 'a'), { ...headsOnly, interrupted: true }, toolResult(5, 'b')]))).toBe('g3|n4|g5')
expect(flowKeys(deriveChatFlow([toolResult(3, 'a'), assistant(4, 'found'), toolResult(5, 'b')]))).toBe('g3|n4|g5')
})
it('assistantActionsSeqs keeps only the last content assistant per turn', () => {
const thinkOnly: AssistantMessageNode = {
kind: 'assistant', seq: 3, time: 3_000, turn: 1, step: 2,
blocks: [{ kind: 'reasoning', text: 'planning' }],
}
const seqs = assistantActionsSeqs([
user(1, 'hi'),
assistant(2, 'looking', 1),
thinkOnly,
toolResult(4, 'a'),
assistant(5, 'done', 1),
user(6, 'again'),
assistant(7, 'second turn', 2),
])
expect([...seqs].sort((a, b) => a - b)).toEqual([5, 7])
})
})
describe('ChatView', () => {
@@ -196,6 +246,74 @@ describe('ChatView', () => {
expect(view.getByText('run a')).toBeTruthy()
})
it('animates only the latest unresolved model retry', () => {
const retryNode = retry(2)
const nextRetry = { ...retry(3), turn: 2, retry: 2 }
const context = {
kind: 'context', seq: 4, time: 4_000, content: [], source: null,
} as const satisfies ConversationNode
const h = makeHarness({ nodes: [user(1, 'try'), retryNode], running: true })
const view = render(<h.ChatView {...h.props} />)
const disclosure = view.container.querySelector('details')
expect(disclosure?.dataset.active).toBe('true')
expect(view.getByRole('status').textContent).toBe('正在重试模型请求1/2 · 1s')
act(() => {
h.set({ nodes: [user(1, 'try'), retryNode, nextRetry] })
})
expect(view.getAllByRole('status')).toHaveLength(1)
expect(view.container.querySelector('details')).toBe(disclosure)
expect(view.getByRole('status').textContent).toBe('正在重试模型请求2/2 · 1s')
act(() => {
h.set({
nodes: [
user(1, 'try'),
retryNode,
{ ...nextRetry, retryState: 'started' },
context,
assistant(5, 'done'),
],
running: false,
})
})
expect(disclosure?.dataset.active).toBeUndefined()
expect(view.getByRole('status').textContent).toBe('已重试模型请求2/2 · 1s')
act(() => {
h.set({ nodes: [user(1, 'try'), { ...retry(6), retryState: 'cancelled' }], running: true })
})
expect(disclosure?.dataset.active).toBeUndefined()
expect(view.getByRole('status').textContent).toContain('重试已取消')
})
it('the expanded row Inspect pill hands the call id to inspectCall', () => {
const h = makeHarness({
nodes: [toolResult(3, 'a')],
})
const view = render(<h.ChatView {...h.props} />)
fireEvent.click(view.getByRole('button', { name: /Bash/ }))
fireEvent.click(view.getByText('Inspect'))
expect(h.inspectCall).toHaveBeenCalledWith('a')
})
it('shows assistant IconActions only on the last content message of each turn', () => {
const h = makeHarness({
nodes: [
user(1, 'hi'),
assistant(2, 'mid-turn text'),
toolResult(3, 'a'),
assistant(4, 'final answer'),
user(5, 'next'),
assistant(6, 'second turn', 2),
],
})
const view = render(<h.ChatView {...h.props} />)
// 2 user + 2 turn-tail assistants; mid-turn text at seq 2 stays chrome-free.
expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(4)
expect(view.getAllByRole('button', { name: '在新对话中分支' })).toHaveLength(4)
})
it('forks from both user and finalized assistant message actions at their event seq', () => {
const h = makeHarness({ nodes: [user(1, 'question'), assistant(2, 'answer')] })
const view = render(<h.ChatView {...h.props} />)
@@ -292,11 +410,11 @@ describe('ChatView', () => {
expect(rowRenders).toBe(afterMount)
})
it('tool row expands to the args body via the leading slot toggle', () => {
it('tool row expands to the args body via the whole-row toggle', () => {
const h = makeHarness({ nodes: [toolResult(3, 'a')] })
const view = render(<h.ChatView {...h.props} />)
expect(view.queryByText(/"command": "cmd-a"/)).toBeNull()
fireEvent.click(view.container.querySelector('button[aria-expanded]')!)
fireEvent.click(view.container.querySelector('[data-expandable]')!)
expect(view.getByText(/"command": "cmd-a"/)).toBeTruthy()
})
@@ -419,6 +537,55 @@ describe('ChatView', () => {
}
})
it('a remount restores the saved scroll position instead of re-jumping to the bottom', () => {
const host = document.createElement('div')
host.setAttribute('data-conversation-scroll', '')
Object.defineProperty(host, 'scrollHeight', { value: 2000, writable: true, configurable: true })
Object.defineProperty(host, 'clientHeight', { value: 500, writable: true, configurable: true })
Object.defineProperty(host, 'scrollTop', { value: 0, writable: true, configurable: true })
document.body.appendChild(host)
try {
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
// Fresh open (nothing saved): the bottom jump stands.
const view = render(<h.ChatView {...h.props} />, { container: host })
expect(host.scrollTop).toBe(2000)
// Reader scrolls up; the position is recorded continuously.
host.scrollTop = 100
fireEvent.scroll(host)
// View-tab switch away and back: the view unmounts, then remounts.
view.rerender(<div />)
host.scrollTop = 0
view.rerender(<h.ChatView {...h.props} />)
expect(host.scrollTop).toBe(100)
// The restored position is above the floor: follow stays disarmed.
expect(view.getByLabelText('回到底部')).toBeTruthy()
} finally {
host.remove()
}
})
it('a remount while pinned to the bottom keeps the bottom jump', () => {
const host = document.createElement('div')
host.setAttribute('data-conversation-scroll', '')
Object.defineProperty(host, 'scrollHeight', { value: 2000, writable: true, configurable: true })
Object.defineProperty(host, 'clientHeight', { value: 500, writable: true, configurable: true })
Object.defineProperty(host, 'scrollTop', { value: 0, writable: true, configurable: true })
document.body.appendChild(host)
try {
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
const view = render(<h.ChatView {...h.props} />, { container: host })
// At the bottom: the scroll event records the pinned state (null).
fireEvent.scroll(host)
expect(h.chatScroll.read()).toBeNull()
view.rerender(<div />)
host.scrollTop = 0
view.rerender(<h.ChatView {...h.props} />)
expect(host.scrollTop).toBe(2000)
} finally {
host.remove()
}
})
it('paging button loads older and shows its busy label', () => {
const h = makeHarness({ nodes: [user(5, 'later')], hasMore: true })
const view = render(<h.ChatView {...h.props} />)

View File

@@ -8,12 +8,19 @@ 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 { RunningToolCall, SessionId, SessionListState, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { ToolRowOwnerProps, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { apply as nodeApply } from '../src/index.ts'
import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
import { GenericToolCard, type GenericToolCardProps } from '../src/client/chat/GenericToolCard.tsx'
import { ToolRow } from '../src/client/chat/ToolRow.tsx'
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
import { BashRow } from '../src/client/toolviews/bash-sample.tsx'
import { zh } from '../src/client/locales.ts'
type BashRowProps = Parameters<typeof BashRow>[0]
// Mirrors the real lookup chain (conversation namespace, then common).
const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh)
afterEach(cleanup)
@@ -24,7 +31,7 @@ describe('tails', () => {
it('ToolRow stopped state renders the warning dot in the leading slot', () => {
const view = render(
<ToolRow variant="bash" icon={<i data-testid="icon" />} title="Bash" summary="s" body={null} state="stopped" />,
<ToolRow t={t} variant="bash" icon={<i data-testid="icon" />} title="Bash" summary="s" body={null} state="stopped" />,
)
expect(view.queryByTestId('icon')).toBeNull()
expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull()
@@ -33,6 +40,7 @@ describe('tails', () => {
it('AssistantMarkdown renders reasoning as a Think row and unknown blocks as JSON fallback', () => {
const view = render(
<AssistantMarkdown
t={t}
blocks={[
{ kind: 'reasoning', text: 'thinking hard\nsecond line' },
{ kind: 'tool-call', callId: 'c', name: 'bash', argsRaw: '{}' },
@@ -45,7 +53,7 @@ describe('tails', () => {
expect(view.getByText('thinking hard')).toBeTruthy()
expect(view.getByText(/未知内容块/)).toBeTruthy()
const stopped = render(
<AssistantMarkdown blocks={[{ kind: 'text', text: 'partial words' }]} streaming={false} interrupted />,
<AssistantMarkdown t={t} blocks={[{ kind: 'text', text: 'partial words' }]} streaming={false} interrupted />,
)
expect(stopped.getByText('已停止')).toBeTruthy()
})
@@ -55,12 +63,13 @@ describe('tails', () => {
// groups is layout noise (no text, no pulse, no interrupted marker).
const empty = render(
<AssistantMarkdown
t={t}
blocks={[{ kind: 'tool-call', callId: 'c', name: 'todo_write', argsRaw: '{}' }]}
streaming={false}
/>,
)
expect(empty.container.firstChild).toBeNull()
const blank = render(<AssistantMarkdown blocks={[]} streaming={false} />)
const blank = render(<AssistantMarkdown t={t} blocks={[]} streaming={false} />)
expect(blank.container.firstChild).toBeNull()
})
@@ -71,8 +80,8 @@ describe('tails', () => {
callTime: 1_000,
content: [], isError: false, callView: null, resultView: null,
}
const props: ToolRowOwnerProps = {
callId: 'c5', toolName: 'todo_write', block: settled, openFile: vi.fn(),
const props: GenericToolCardProps = {
callId: 'c5', toolName: 'todo_write', block: settled, openFile: vi.fn(), t,
}
const view = render(<GenericToolCard {...props} />)
// Settled ok state keeps the variant icon (sparkle) instead of a StateDot.
@@ -91,7 +100,8 @@ describe('tails', () => {
const props = (block: RunningToolCall | ToolResultNode) => ({
callId: 'c1', toolName: 'bash', block, openFile: vi.fn(),
sessionId: sid, useSessions: bindSnapshotSelector(list),
} as unknown as ToolRowProps)
t,
} as unknown as BashRowProps)
const running: RunningToolCall = {
callId: 'c1', name: 'bash', argsRaw: '{"command":"ls","description":"List"}',

View File

@@ -7,10 +7,16 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import type { ConversationSnapshot, SessionId, SessionListState, WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client'
import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { createChatStore } from '../src/client/stores.ts'
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx'
import { StatsLine } from '../src/client/chat/StatsLine.tsx'
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
import { zh } from '../src/client/locales.ts'
// Mirrors the real lookup chain (conversation namespace, then common).
const t: AssistantMarkdownProps['t'] = makeTranslate(zh, commonZh)
afterEach(cleanup)
@@ -28,6 +34,7 @@ describe('render branch tails', () => {
it('AssistantMarkdown reasoning row is ok-state when not the streaming tail', () => {
const view = render(
<AssistantMarkdown
t={t}
blocks={[{ kind: 'reasoning', text: 'done thinking' }, { kind: 'text', text: 'answer' }]}
streaming
/>,
@@ -54,7 +61,7 @@ describe('render branch tails', () => {
it('AssistantMarkdown reasoning as the streaming tail renders the running ring', () => {
const view = render(
<AssistantMarkdown blocks={[{ kind: 'reasoning', text: 'still thinking' }]} streaming />,
<AssistantMarkdown t={t} blocks={[{ kind: 'reasoning', text: 'still thinking' }]} streaming />,
)
expect(view.container.querySelector('[data-state="running"]')).not.toBeNull()
})
@@ -82,6 +89,7 @@ describe('render branch tails', () => {
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
closeDetails={vi.fn()}
t={t}
/>,
)
expect(view.getByText('详情')).toBeTruthy()
@@ -118,6 +126,7 @@ describe('render branch tails', () => {
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
closeDetails={vi.fn()}
t={t}
/>,
)
// Sub-call material: the sub-tool name titles the panel, args pretty-print,

View File

@@ -8,10 +8,13 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import type { ClientContext, ConversationSnapshot, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { SessionInputShell } from '../src/client/input/facade.ts'
import { InputBar } from '../src/client/skeleton/InputBar.tsx'
import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
import { zh } from '../src/client/locales.ts'
afterEach(cleanup)
@@ -42,11 +45,13 @@ interface BenchOptions {
promptError?: ConversationSnapshot['promptError']
variant?: 'hero' | 'composer'
placeholder?: string
translateHint?: (key: string) => string
t?: InputBarProps['t']
accessory?: React.ReactNode
overlay?: React.ReactNode
leftItems?: React.ReactNode
rightItems?: React.ReactNode
commandMenuOpen?: boolean
toggleCommandMenu?: (selection: { start: number; end: number }) => void
}
/** Real machine behind the bar entry: sink spy, no slash pipeline (plain text goes straight to the sink). */
@@ -74,6 +79,7 @@ function bench(over?: BenchOptions) {
promptError: over?.promptError ?? null,
}))
const stop = vi.fn()
const menuLauncher = createSnapshotStore<string | null>(over?.commandMenuOpen === true ? 'command' : null)
const slotCalls: { key: string; owner: unknown }[] = []
const renderSlot = ((key: string, owner: object) => {
slotCalls.push({ key, owner })
@@ -97,15 +103,14 @@ function bench(over?: BenchOptions) {
useInput: bindSnapshotSelector(shell.state),
inputActions: shell.actions,
keyboard: shell,
toggleCommandMenu: over?.toggleCommandMenu ?? vi.fn(),
useNotices: bindSnapshotSelector(shell.notices),
useLexicon: bindSnapshotSelector(shell.lexicon),
useMenuLauncher: bindSnapshotSelector(menuLauncher),
stop,
command: () => Promise.resolve(true),
// Mirrors the en 'command.hint' locale entries the production apply wires in.
translateHint: over?.translateHint ?? ((key: string) => ({
'placeholder.default': 'Message the agent',
'placeholder.plan': 'describe your task to generate plan',
} as Record<string, string>)[key] ?? key),
// Mirrors the real lookup chain (conversation namespace, then common).
t: over?.t ?? makeTranslate(zh, commonZh),
renderSlot,
variant: over?.variant ?? 'composer',
...(over?.placeholder !== undefined ? { placeholder: over.placeholder } : {}),
@@ -118,9 +123,9 @@ function bench(over?: BenchOptions) {
const textarea = view.container.querySelector('textarea')!
// aria-label (not role name): title carries the same label and would double-match.
const button = view.container.querySelector<HTMLButtonElement>(
`button[aria-label="${over?.running === true ? 'Stop generating' : 'Send message'}"]`,
`button[aria-label="${over?.running === true ? '停止生成' : '发送消息'}"]`,
)!
return { view, textarea, button, props, sink, shell, wiring: shell, session, stop, slotCalls }
return { view, textarea, button, props, sink, shell, wiring: shell, session, stop, slotCalls, menuLauncher }
}
describe('Enter semantics', () => {
@@ -196,7 +201,7 @@ describe('running and lock semantics (queue cut 1)', () => {
fireEvent.change(textarea, { target: { value: '排队消息2' } })
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(sink).toHaveBeenCalledWith('排队消息2', 'queue')
expect(button.getAttribute('aria-label')).toBe('Stop generating')
expect(button.getAttribute('aria-label')).toBe('停止生成')
fireEvent.click(button)
expect(stop).toHaveBeenCalledTimes(1)
})
@@ -204,8 +209,8 @@ describe('running and lock semantics (queue cut 1)', () => {
it('disabled (session removed) locks the textarea and chrome', () => {
const { textarea, view } = bench({ disabled: true })
expect(textarea.disabled).toBe(true)
expect(textarea.placeholder).toBe('Session unavailable')
expect((view.getByLabelText('Add attachment') as HTMLButtonElement).disabled).toBe(true)
expect(textarea.placeholder).toBe('会话不可用')
expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true)
})
it('idle primary sends and disables on empty draft', () => {
@@ -222,7 +227,7 @@ describe('running and lock semantics (queue cut 1)', () => {
const textarea = first.view.container.querySelector('textarea')!
expect(document.activeElement).toBe(textarea)
textarea.blur()
fireEvent.mouseDown(first.view.container.querySelector('button[aria-label="Send message"]')!)
fireEvent.mouseDown(first.view.container.querySelector('button[aria-label="发送消息"]')!)
expect(document.activeElement).toBe(textarea)
})
@@ -285,22 +290,22 @@ describe('running and lock semantics (queue cut 1)', () => {
it('disabled state shows the unavailable placeholder; custom placeholder wins', () => {
const { textarea } = bench({ disabled: true })
expect(textarea.placeholder).toBe('Session unavailable')
expect(textarea.placeholder).toBe('会话不可用')
const live = bench()
expect(live.textarea.placeholder).toBe('Message the agent')
expect(live.textarea.placeholder).toBe('给智能体发消息')
const custom = bench({ placeholder: 'Custom placeholder' })
expect(custom.textarea.placeholder).toBe('Custom placeholder')
})
it('the plan projection swaps the placeholder while its effective target is plan mode', () => {
const active = bench({ plan: { active: true, pending: false } })
expect(active.textarea.placeholder).toBe('describe your task to generate plan')
expect(active.textarea.placeholder).toBe('描述你的任务以生成计划')
// /plan just ran: pending entry already reads as the plan target.
const entering = bench({ plan: { active: false, pending: true } })
expect(entering.textarea.placeholder).toBe('describe your task to generate plan')
expect(entering.textarea.placeholder).toBe('描述你的任务以生成计划')
// Pending exit: target is default again.
const leaving = bench({ plan: { active: true, pending: true } })
expect(leaving.textarea.placeholder).toBe('Message the agent')
expect(leaving.textarea.placeholder).toBe('给智能体发消息')
// Owner placeholder outranks the plan swap.
const custom = bench({ plan: { active: true, pending: false }, placeholder: 'Custom placeholder' })
expect(custom.textarea.placeholder).toBe('Custom placeholder')
@@ -325,13 +330,14 @@ describe('machine pending lock', () => {
expect(shell.snapshot.phase).toBe('submitting')
const textarea = view.container.querySelector('textarea')!
expect(textarea.readOnly).toBe(true)
expect(view.container.querySelector<HTMLButtonElement>('button[aria-label="Send message"]')!.disabled).toBe(true)
expect(view.container.querySelector<HTMLButtonElement>('button[aria-label="发送消息"]')!.disabled).toBe(true)
})
})
describe('decorations', () => {
it('claimed token renders the mirror highlight and the blank-args hint', () => {
const { view, shell } = bench()
// Dictionary-less stub: an unmatched hint key keeps the machine's raw hint.
const { view, shell } = bench({ t: makeTranslate({}) })
act(() => {
shell.setDraft('/goal ')
shell.beginCommand(
@@ -349,8 +355,7 @@ describe('decorations', () => {
})
it('a locale entry for the claimed command overrides the raw claim hint (trailing-space token)', () => {
const dict: Record<string, string> = { goal: '输入目标,智能体将持续执行' }
const { view, shell } = bench({ translateHint: key => dict[key] ?? key })
const { view, shell } = bench()
act(() => {
shell.setDraft('/goal ')
shell.beginCommand(
@@ -438,18 +443,30 @@ describe('strips and variants', () => {
})
})
describe('placeholder chrome and control seats', () => {
it('renders attach; the Access chip is absent without the permissions projection; plan/model seats render EMPTY without entries (B ruling)', () => {
describe('command launcher chrome and control seats', () => {
it('renders the command launcher; the Access chip is absent without the permissions projection; plan/model seats render EMPTY without entries (B ruling)', () => {
const { view, slotCalls } = bench()
expect(view.getByLabelText('Add attachment')).toBeTruthy()
expect(view.getByLabelText('命令')).toBeTruthy()
// Capability absent (no projection value): the chip renders nothing.
expect(view.queryByLabelText(/^Access mode/)).toBeNull()
expect(view.queryByLabelText(/^访问模式/)).toBeNull()
// Both seats dispatched, nothing rendered.
expect(slotCalls.map(c => c.key)).toEqual(['conversation.input.plan', 'conversation.input.model'])
expect(view.queryByLabelText('Plan mode')).toBeNull()
expect(view.queryByLabelText('Model')).toBeNull()
})
it('passes the textarea selection to the command menu launcher and reflects its expanded state', () => {
const toggleCommandMenu = vi.fn()
const { view, textarea, menuLauncher } = bench({ draft: 'draft text', toggleCommandMenu })
textarea.setSelectionRange(2, 7)
const launcher = view.getByLabelText('命令')
expect(launcher.getAttribute('aria-expanded')).toBe('false')
fireEvent.click(launcher)
expect(toggleCommandMenu).toHaveBeenCalledExactlyOnceWith({ start: 2, end: 7 })
act(() => { menuLauncher.set('command') })
expect(launcher.getAttribute('aria-expanded')).toBe('true')
})
it('the Access chip renders the projection value and submits /permission on pick', async () => {
const permissions = {
options: [
@@ -459,7 +476,7 @@ describe('placeholder chrome and control seats', () => {
currentValue: 'workspace-write',
}
const { view } = bench({ permissions })
const trigger = view.getByLabelText(/^Access mode/) as HTMLButtonElement
const trigger = view.getByLabelText(/^访问模式/) as HTMLButtonElement
// Title-case display is presentation only; the menu ids stay machine names.
expect(trigger.textContent).toBe('Workspace Write')
fireEvent.click(trigger)
@@ -467,11 +484,11 @@ describe('placeholder chrome and control seats', () => {
expect(items.map(o => o.textContent)).toEqual(['Workspace Write', 'Danger Full Access'])
fireEvent.click(items[1]!)
// Optimistic pick + disable until admission resolves (command stub resolves true).
const busy = view.getByLabelText(/^Access mode/) as HTMLButtonElement
const busy = view.getByLabelText(/^访问模式/) as HTMLButtonElement
expect(busy.textContent).toBe('Danger Full Access')
expect(busy.disabled).toBe(true)
await act(async () => {})
expect((view.getByLabelText(/^Access mode/) as HTMLButtonElement).disabled).toBe(false)
expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).disabled).toBe(false)
})
it('a registered entry fills its seat and receives the locked owner prop', () => {
@@ -489,13 +506,13 @@ describe('placeholder chrome and control seats', () => {
expect(live.slotCalls.every(c => !(c.owner as { locked: boolean }).locked)).toBe(true)
})
it('disabled locks the Access chip and attach control (running does not)', () => {
it('disabled locks the Access chip and command launcher (running does not)', () => {
const permissions = { options: [{ value: 'workspace-write', name: 'workspace-write' }], currentValue: 'workspace-write' }
const { view } = bench({ disabled: true, permissions })
expect((view.getByLabelText('Add attachment') as HTMLButtonElement).disabled).toBe(true)
expect((view.getByLabelText(/^Access mode/) as HTMLButtonElement).disabled).toBe(true)
expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true)
expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).disabled).toBe(true)
cleanup()
const live = bench({ running: true, permissions })
expect((live.view.getByLabelText(/^Access mode/) as HTMLButtonElement).disabled).toBe(false)
expect((live.view.getByLabelText(/^访问模式/) as HTMLButtonElement).disabled).toBe(false)
})
})

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