fix(web): address skill row review feedback

This commit is contained in:
Yichen Jiang
2026-08-06 17:21:13 +08:00
parent 690f7ae035
commit a667d2cd64
39 changed files with 326 additions and 142 deletions

View File

@@ -7,7 +7,7 @@
export type {
ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, HistoryToolCall, ToolEventView,
DirectoryEntry, DirectoryListing,
WorkspaceApi, WorkspaceId, WorkspaceView,
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,

View File

@@ -29,7 +29,7 @@ import type {
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import { deriveEventMessage, foldSurface } from '@deepseek-ai/dsh-session/surface'
import type {
ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt,
ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HistoryToolCall, HostFrame, MuxFrame, RpcReceipt,
ModelProviderGroup, ModelTarget, RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary,
ToolCallView, ToolEventView, ToolResultView, WorkspaceId, WorkspaceView,
} from './api.ts'
@@ -661,25 +661,33 @@ function presentResult(name: string, argsRaw: string, resultText: string): ToolR
}
}
/** Host-side viewFor mirror: tool/call presents from its own args; tool/result back-scans the log for the paired call. */
/** Full-log tool/result pair used by the fixture history envelope and presenter mirror. */
function pairedHistoryCall(event: SessionEvent, log: readonly SessionEvent[]): HistoryToolCall | undefined {
if (event.type !== 'tool/result') return undefined
const callId = String(event.data.message.source.callId)
for (let i = log.length - 1; i >= 0; i--) {
const candidate = log[i]
/* v8 ignore next -- dense-array guard: i stays within [0, log.length),
so the undefined arm needs a sparse log no code path builds. */
if (candidate !== undefined && candidate.type === 'tool/call' && String(candidate.data.callId) === callId) {
return { name: candidate.data.name, arguments: candidate.data.arguments, time: candidate.time }
}
}
return undefined
}
/** Host-side viewFor mirror: tool/call presents from its own args; tool/result uses its full-log pair. */
function viewFor(event: SessionEvent, log: readonly SessionEvent[]): ToolEventView | undefined {
if (event.type === 'tool/call') {
const view = presentCall(event.data.name, event.data.arguments)
return view === undefined ? undefined : { for: 'call', view }
}
if (event.type === 'tool/result') {
const callId = String(event.data.message.source.callId)
for (let i = log.length - 1; i >= 0; i--) {
const candidate = log[i]
/* v8 ignore next -- dense-array guard: i stays within [0, log.length),
so the undefined arm needs a sparse log no code path builds. */
if (candidate !== undefined && candidate.type === 'tool/call' && String(candidate.data.callId) === callId) {
const resultText = event.data.message.content[0].content.map(b => (b.type === 'text' ? b.text : '')).join('')
const view = presentResult(candidate.data.name, candidate.data.arguments, resultText)
return view === undefined ? undefined : { for: 'result', view }
}
}
return undefined // cross-page unpaired: documented default
const call = pairedHistoryCall(event, log)
if (call === undefined) return undefined
const resultText = event.data.message.content[0].content.map(b => (b.type === 'text' ? b.text : '')).join('')
const view = presentResult(call.name, call.arguments, resultText)
return view === undefined ? undefined : { for: 'result', view }
}
return undefined
}
@@ -1044,7 +1052,12 @@ function pageOf(
}
const events = log.slice(start, end).map((event): HistoryEntry => {
const view = viewFor(event, log)
return view === undefined ? { event } : { event, view }
const call = pairedHistoryCall(event, log)
return {
event,
...view === undefined ? {} : { view },
...call === undefined ? {} : { call },
}
})
return { events, hasMore: start > 0 }
}

View File

@@ -13,7 +13,7 @@ import { isLoopbackHostname } from '../loopback-hostname.ts'
// ---- Contract re-exports (browser-safe apiproxy channels + core types) ----
export type {
ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, HistoryToolCall, ToolEventView,
DirectoryEntry, DirectoryListing,
ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,

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: 8ac29a4258bbd7456b20c61e547d48c570e84d27
README.zh.md: 0e065e43ecc571e68d3976d2100eb43959cb2e3d
README.md: 3d981392ce0314f41fe84bc1adb2b9484a6a5989
README.zh.md: c05bdb6ebb33c0ffa47e2b54fb1b3d9d25f2fa6d

View File

@@ -34,7 +34,7 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
## The human transcript
`ConversationSnapshot.nodes` is the human transcript, not the model surface. `TranscriptAdapter` projects the raw window in log order — every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint — and never consults surface order. `SteeringHistory` replays the durable `agent/inbox/spliced` records in that window: a user-origin message claimed from `next-step` becomes a `SteeringMessageNode` when its matching `user/message` lands, a `next-turn` claim stays a user node, and non-user next-step input stays context. `ConversationSnapshot.turnEnds` maps each completed turn in that window to its `turn/end` seq, retaining turn completion independently from the transcript so presentation can require a real boundary before enabling an action. A landed compaction therefore keeps the conversation it shadowed on the model side: the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary. A checkpoint is a `user/message` carrying the compaction seam's plugin source that **replaced** a surface range; an appending plugin-sourced `user/message` is injected context, not a compaction. Each context node also carries a `provenance` view: `contextProvenance()` reads the durable source alone to decide whether the row is an `inject` or a cross-session `recall`, and to name its producer from the instruction paths, referenced session titles, or plugin id that source already records. The client holds no table of plugin ids, so a renamed or newly mounted producer stays identifiable without a client release and a resumed or foreign log projects exactly like a live one; a source with no readable kind degrades to an unnamed injection. Beside it, `contextForm()` reads the producer-declared `ContextForm` — the second, independent axis: `kind` says who produced the context, `form` says what shape of information it is, so several producers may share one form. A form this UI version does not present projects as null and renders opaque. The adapter's plugin literal is pinned to the seam's own declaration by a type-only import of the cordis-free [`dsh-compact/checkpoint`](../../compact/compact/README.md) leaf, so renaming it there fails `tsc` here; a **value** import of the package would fail the client purity gate, and the package **root** is unreachable even as a type (it reaches `dsh-session`'s root, whose `Context` merge collides the host `sessions` with this program's).
`ConversationSnapshot.nodes` is the human transcript, not the model surface. `TranscriptAdapter` projects the raw window in log order — every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint — and never consults surface order. A paged `tool/result` first pairs against an in-window `tool/call`, then against the Host-carried complete-log call annotation; `ToolResultNode.call` is null only for a truly orphaned durable result, so a page boundary cannot change keyed toolview dispatch, argument-derived labels, or duration. The call-side render intent remains null when its event is outside the window, while the result intent is already computed by the Host from the complete pair. `SteeringHistory` replays the durable `agent/inbox/spliced` records in that window: a user-origin message claimed from `next-step` becomes a `SteeringMessageNode` when its matching `user/message` lands, a `next-turn` claim stays a user node, and non-user next-step input stays context. `ConversationSnapshot.turnEnds` maps each completed turn in that window to its `turn/end` seq, retaining turn completion independently from the transcript so presentation can require a real boundary before enabling an action. A landed compaction therefore keeps the conversation it shadowed on the model side: the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary. A checkpoint is a `user/message` carrying the compaction seam's plugin source that **replaced** a surface range; an appending plugin-sourced `user/message` is injected context, not a compaction. Each context node also carries a `provenance` view: `contextProvenance()` reads the durable source alone to decide whether the row is an `inject` or a cross-session `recall`, and to name its producer from the instruction paths, referenced session titles, or plugin id that source already records. The client holds no table of plugin ids, so a renamed or newly mounted producer stays identifiable without a client release and a resumed or foreign log projects exactly like a live one; a source with no readable kind degrades to an unnamed injection. Beside it, `contextForm()` reads the producer-declared `ContextForm` — the second, independent axis: `kind` says who produced the context, `form` says what shape of information it is, so several producers may share one form. A form this UI version does not present projects as null and renders opaque. The adapter's plugin literal is pinned to the seam's own declaration by a type-only import of the cordis-free [`dsh-compact/checkpoint`](../../compact/compact/README.md) leaf, so renaming it there fails `tsc` here; a **value** import of the package would fail the client purity gate, and the package **root** is unreachable even as a type (it reaches `dsh-session`'s root, whose `Context` merge collides the host `sessions` with this program's).
Because the projection is log-ordered, the node array is seq-monotonic by construction: log-only `command/run` / `command/done` nodes splice in by seq, `Session` merges interrupted frozen nodes by their fractional seqs, and a window whose checkpoint cites a shadowed range outside it renders the marker with nothing logged. The marker's summary text comes from the checkpoint's `compact/summary` provenance; a window cut that left the provenance outside makes the row non-expandable rather than empty, and a later page that supplies it resolves the text. Performance contract: one append materializes at most one node and copies the projection only when it adds that node; an event that changes no node keeps the previous array reference (a chunk storm costs nothing), and unchanged nodes keep their object identity.

View File

@@ -34,7 +34,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
## 面向人的 transcript文本记录
`ConversationSnapshot.nodes` 是面向人的 transcript不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口。每个 append 来源的 surface 事件(`isAppendSurfaceEvent`落在它自己的日志位置上每次落地的压缩compaction检查点还会贡献一个 `CompactionSummaryNode` 标记;适配器从不查询 surface 顺序。`SteeringHistory` 会重放该窗口中的持久 `agent/inbox/spliced` 记录:用户来源的消息从 `next-step` 被领取,并以相同身份落成 `user/message` 时,会投影为 `SteeringMessageNode`;从 `next-turn` 领取的消息仍是用户节点,非用户来源的 next-step 输入仍是上下文。`ConversationSnapshot.turnEnds` 把该窗口中的每个已完成轮次映射到其 `turn/end` seq它独立于 transcript 保留轮次完成状态,使呈现层能够在启用操作前要求存在真实边界。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。每个上下文节点还携带一份 `provenance` 视图:`contextProvenance()` 只读取持久来源,据此判定该行是 `inject`(注入)还是跨会话的 `recall`(召回),并用该来源已经记录的指令文件路径、被引用会话标题或插件 id 命名其生产者。客户端不保存任何插件 id 表,因此重命名或新挂载的生产者无需客户端发版即可保持可辨识,恢复的会话日志与外部日志的投影结果和实时会话完全一致;没有可读 kind 的来源则降级为无名注入。与之并列的 `contextForm()` 读取生产方声明的 `ContextForm`,这是相互独立的第二根轴:`kind` 说明上下文由谁产生,`form` 说明它是何种形态的信息,因此多个生产方可以共用一种形态。本 UI 版本不呈现的形态投影为 null按 opaque 渲染。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在压缩 seam 自己的声明上:在那里改名会让此处 `tsc` 失败而对该包package做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。
`ConversationSnapshot.nodes` 是面向人的 transcript不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口。每个 append 来源的 surface 事件(`isAppendSurfaceEvent`落在它自己的日志位置上每次落地的压缩compaction检查点还会贡献一个 `CompactionSummaryNode` 标记;适配器从不查询 surface 顺序。分页得到的 `tool/result` 会先与窗口内的 `tool/call` 配对,再与 Host 携带的完整日志调用注解配对;只有持久结果确实没有配对调用时,`ToolResultNode.call` 才为 null因此分页边界无法改变键控 toolview 分派、由参数派生的标签或耗时。调用事件位于窗口外时,调用侧渲染意图仍为 null而结果侧渲染意图已经由 Host 基于完整配对计算完成。`SteeringHistory` 会重放该窗口中的持久 `agent/inbox/spliced` 记录:用户来源的消息从 `next-step` 被领取,并以相同身份落成 `user/message` 时,会投影为 `SteeringMessageNode`;从 `next-turn` 领取的消息仍是用户节点,非用户来源的 next-step 输入仍是上下文。`ConversationSnapshot.turnEnds` 把该窗口中的每个已完成轮次映射到其 `turn/end` seq它独立于 transcript 保留轮次完成状态,使呈现层能够在启用操作前要求存在真实边界。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。每个上下文节点还携带一份 `provenance` 视图:`contextProvenance()` 只读取持久来源,据此判定该行是 `inject`(注入)还是跨会话的 `recall`(召回),并用该来源已经记录的指令文件路径、被引用会话标题或插件 id 命名其生产者。客户端不保存任何插件 id 表,因此重命名或新挂载的生产者无需客户端发版即可保持可辨识,恢复的会话日志与外部日志的投影结果和实时会话完全一致;没有可读 kind 的来源则降级为无名注入。与之并列的 `contextForm()` 读取生产方声明的 `ContextForm`,这是相互独立的第二根轴:`kind` 说明上下文由谁产生,`form` 说明它是何种形态的信息,因此多个生产方可以共用一种形态。本 UI 版本不呈现的形态投影为 null按 opaque 渲染。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在压缩 seam 自己的声明上:在那里改名会让此处 `tsc` 失败而对该包package做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。
由于投影按日志顺序,节点数组天然按 seq 单调:仅日志的 `command/run` / `command/done` 节点按 seq 插入,`Session` 按分数 seq 归并被打断的冻结节点,而检查点所引范围落在窗口之外的窗口会渲染出标记且不打印任何日志。标记的摘要文本来自检查点的 `compact/summary` 溯源;窗口切分把溯源留在窗口外时该行不可展开而非空白,后续补上溯源的分页会解析出文本。性能契约:一次追加最多物化一个节点,并且仅在加入该节点时复制投影;不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识。

View File

@@ -362,7 +362,8 @@ export function projectConversationHistory(
let contextGeneration = 0
for (const [index, event] of events.entries()) {
const view = entries[index]?.view
const entry = entries[index]
const view = entry?.view
if (event.type === 'tool/call') {
callIndex.set(String(event.data.callId), {
name: event.data.name,
@@ -370,8 +371,17 @@ export function projectConversationHistory(
time: event.time,
callView: view?.for === 'call' ? view.view : null,
})
} else if (event.type === 'tool/result' && view?.for === 'result') {
resultViews.set(event.seq, view.view)
} else if (event.type === 'tool/result') {
const callId = String(event.data.message.source.callId)
if (!callIndex.has(callId) && entry?.call !== undefined) {
callIndex.set(callId, {
name: entry.call.name,
argsRaw: entry.call.arguments,
time: entry.call.time,
callView: null,
})
}
if (view?.for === 'result') resultViews.set(event.seq, view.view)
}
if (isSurfaceEvent(event) && event.surfaceOp !== 'append') {
contextGeneration++

View File

@@ -155,16 +155,16 @@ export interface TurnErrorNode {
code?: string
}
/** A tool result paired (when in-window) with its call head. */
/** A tool result paired with its durable call head when the Host can resolve it. */
export interface ToolResultNode {
kind: 'tool-result'
seq: number
/** Unix epoch ms from the tool/result session event. */
time: number
callId: string
/** Call head backfilled from the in-window tool/call; null when window truncation left the call outside (card head shows callId). */
/** Call head from the window or history envelope; null only when the durable log has no pair (card head shows callId). */
call: { name: string; argsRaw: string } | null
/** Unix epoch ms of the paired tool/call when the call is still in-window; used for call-row duration. */
/** Unix epoch ms of the paired tool/call; null when the durable log has no pair. */
callTime: number | null
content: readonly ContentBlock[]
isError: boolean

View File

@@ -5,7 +5,7 @@ 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, MessageId, MuxFrame, QueueAction, RpcError,
HistoryEntry, HistoryToolCall, IApiClient, MessageId, MuxFrame, QueueAction, RpcError,
RpcId, RpcResponse, RpcResult, SessionId, SubagentAddress, ToolEventView,
} from '@deepseek-ai/dsh-client-connection/client'
// Value import from the inline-safe wire layer (not the connection plugin):
@@ -85,6 +85,8 @@ export class Session implements SessionFace {
/** Wire views aligned with `events` by index (envelope-level annotations; undefined = no view).
* Kept parallel rather than merged so `events` stays the raw log slice (model-visible ⟺ logged). */
private views: (ToolEventView | undefined)[] = []
/** Host-carried call metadata aligned with result entries when the call event is outside the page. */
private historyCalls: (HistoryToolCall | undefined)[] = []
private baseSeq = 0
private hasMore = false
private openState: OpenState = 'cold'
@@ -381,10 +383,11 @@ export class Session implements SessionFace {
}
this.events = [...older.map(e => e.event), ...this.events]
this.views = [...older.map(e => e.view), ...this.views]
this.historyCalls = [...older.map(e => e.call), ...this.historyCalls]
/* v8 ignore next -- the ?? arm needs older[0] undefined, but the empty-page branch above already returned. */
this.baseSeq = older[0]?.event.seq ?? this.baseSeq
this.hasMore = result.value.hasMore
this.transcript.reset(this.events, this.views) // prepend forces a rebuild (the window grew at the head)
this.transcript.reset(this.events, this.views, this.historyCalls) // prepend forces a rebuild (the window grew at the head)
this.rebuildDerivedFromWindow()
} catch (error) {
console.error('[web-runtime] loadOlder failed:', error)
@@ -411,6 +414,7 @@ export class Session implements SessionFace {
this.openError = null
this.events = []
this.views = []
this.historyCalls = []
this.baseSeq = 0
// Superseded, not settled: the baseline replay re-sends still-pending requested frames verbatim
// (same rpcId), re-minting fresh waits; a stale reference's respond() still reaches the host.
@@ -644,9 +648,10 @@ export class Session implements SessionFace {
private installWindow(entries: HistoryEntry[], hasMore: boolean, projections?: ProjectionsBaseline): void {
this.events = entries.map(e => e.event)
this.views = entries.map(e => e.view)
this.historyCalls = entries.map(e => e.call)
this.baseSeq = this.events[0]?.seq ?? 0
this.hasMore = hasMore
this.transcript.reset(this.events, this.views)
this.transcript.reset(this.events, this.views, this.historyCalls)
this.rebuildDerivedFromWindow()
if (projections !== undefined) this.projections.seed(projections)
const buffered = this.liveBuffer
@@ -661,6 +666,7 @@ export class Session implements SessionFace {
if (tailSeq !== null && event.seq <= tailSeq) return // replay overlap, drop
this.events.push(event)
this.views.push(view)
this.historyCalls.push(undefined)
this.transcript.append(event, view)
this.handoffPendingSteering(event)
this.applyEventSideEffects(event, view)

View File

@@ -19,7 +19,9 @@ import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
// `sessions: ISessions` (TS2717, the one-program-per-side rule in
// docs/development.md).
import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpoint'
import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
import type {
HistoryToolCall, ToolCallView, ToolEventView, ToolResultView,
} from '@deepseek-ai/dsh-client-connection/client'
import type { CommandNode, CompactionSummaryNode, ConversationNode } from './conversation.ts'
import { toAssistantBlocks } from './conversation.ts'
import { contextForm, contextProvenance } from './context-provenance.ts'
@@ -213,8 +215,13 @@ export class TranscriptAdapter {
* and re-project the transcript.
* @param events - the new window contents (seq-ascending).
* @param views - per-event wire views aligned with `events` by index (undefined slots for view-less events).
* @param calls - host-carried result pairs aligned with `events` by index.
*/
reset(events: readonly SessionEvent[], views?: readonly (ToolEventView | undefined)[]): void {
reset(
events: readonly SessionEvent[],
views?: readonly (ToolEventView | undefined)[],
calls?: readonly (HistoryToolCall | undefined)[],
): void {
this.rev++
this.eventIndex = new Map()
this.callIdx = new Map()
@@ -228,7 +235,7 @@ export class TranscriptAdapter {
/* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */
if (event === undefined) continue
this.eventIndex.set(event.seq, event)
this.indexCall(event, views?.[i])
this.indexCall(event, views?.[i], calls?.[i])
this.indexCommand(event)
if (this.steeringHistory.apply(event)) steeringSeqs.add(event.seq)
indexAssistantStepTiming(this.stepTimings, event)
@@ -338,9 +345,20 @@ export class TranscriptAdapter {
return true
}
private indexCall(event: SessionEvent, view?: ToolEventView): void {
private indexCall(event: SessionEvent, view?: ToolEventView, pairedCall?: HistoryToolCall): void {
if (event.type === 'tool/result') {
if (view?.for === 'result') this.resultViews.set(event.seq, view.view)
const callId = String(event.data.message.source.callId)
if (!this.callIdx.has(callId) && pairedCall !== undefined) {
this.callIdx.set(callId, {
name: pairedCall.name,
argsRaw: pairedCall.arguments,
turn: event.data.turn,
step: event.data.step,
time: pairedCall.time,
callView: null,
})
}
return
}
if (event.type !== 'tool/call') return

View File

@@ -3,7 +3,7 @@
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type {
ClientResponse, CommandDescriptor, HostFrame, IApiClient, ModelTarget, MuxFrame,
ClientResponse, CommandDescriptor, HistoryEntry, HostFrame, IApiClient, ModelTarget, MuxFrame,
RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry,
WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-connection/client'
@@ -68,7 +68,7 @@ export class FakeApiClient implements IApiClient {
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 }))
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean }>> =
=> Promise<RpcResponse<{ events: HistoryEntry[]; hasMore: boolean }>> =
() => Promise.resolve(ok({ events: [], hasMore: false }))
onModels: (payload: unknown) => Promise<RpcResponse<SessionModels>> = () => Promise.resolve(ok({

View File

@@ -53,6 +53,20 @@ describe('projectConversationHistory', () => {
}])
})
it('projects a paged tool result from its host-carried call pair', () => {
const result = ev.toolResult(50, 3, 'outside-call', '已加载 skill')
const projection = projectConversationHistory([{
event: result,
call: { name: 'skill', arguments: '{"name":"dsh-code-review"}', time: 40 },
}])
expect(projection.eventNodes).toMatchObject([{
kind: 'tool-result',
call: { name: 'skill', argsRaw: '{"name":"dsh-code-review"}' },
callTime: 40,
callView: null,
}])
})
it('projects a high-sequence history window without synthesizing its unloaded prefix', () => {
const baseSeq = 400_000
const events = [

View File

@@ -53,6 +53,23 @@ describe('open', () => {
expect(snapshot.turnEnds.get(3)).toBe(15)
})
it('installs host-carried call metadata for a result-only tail page', async () => {
const { api, session } = makeSession()
api.onHistory = () => Promise.resolve(ok({
events: [{
event: ev.toolResult(50, 3, 'outside-call', '已加载 skill'),
call: { name: 'skill', arguments: '{"name":"dsh-code-review"}', time: 40 },
}],
hasMore: true,
}))
await session.open()
expect(session.getSnapshot().nodes).toMatchObject([{
kind: 'tool-result',
call: { name: 'skill', argsRaw: '{"name":"dsh-code-review"}' },
callTime: 40,
}])
})
it('is idempotent: concurrent opens share one history call, reopening when open is a no-op', async () => {
const { api, session } = makeSession()
await Promise.all([session.open(), session.open()])

View File

@@ -365,6 +365,22 @@ describe('TranscriptAdapter', () => {
expect(adapter.nodes()[0]).toMatchObject({ kind: 'tool-result', callId: 'outside-call', call: null })
})
it('materializes a paged tool-result from its host-carried call pair', () => {
const adapter = new TranscriptAdapter()
adapter.reset(
[ev.toolResult(50, 3, 'outside-call', '已加载 skill')],
[undefined],
[{ name: 'skill', arguments: '{"name":"dsh-code-review"}', time: 40 }],
)
expect(adapter.nodes()[0]).toMatchObject({
kind: 'tool-result',
callId: 'outside-call',
call: { name: 'skill', argsRaw: '{"name":"dsh-code-review"}' },
callTime: 40,
callView: null,
})
})
it('materializes a tool-result error field when present', () => {
const adapter = new TranscriptAdapter()
adapter.reset([

View File

@@ -23,7 +23,7 @@
import { useEffect, useRef, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
import clsx from 'clsx'
import {
CodeBlock, DiffBlock, ReadBlock, SearchBlock, StateDot, TerminalBlock, WebBlock,
CodeBlock, DiffBlock, IconInspectOutline12, ReadBlock, SearchBlock, StateDot, TerminalBlock, WebBlock,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { WebBlockProps } from '@deepseek-ai/dsh-client-ui-primitives'
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
@@ -99,15 +99,6 @@ export interface ToolRowProps {
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
* semantic (error = red, interrupted = amber halo). Running keeps the icon —
* the row sweep (CSS on data-state) carries the in-flight signal. */
@@ -319,7 +310,7 @@ export function ToolRow({
className={css.inspectButton}
onClick={inspect}
>
<IconInspect />
<IconInspectOutline12 />
Inspect
</button>
)}

View File

@@ -168,11 +168,12 @@ function collapse(body: string, rooted: boolean, separator = '/'): string {
* returns a generic fenced card for an execution error or a background
* start, whose text and error styling the generic path preserves.
*
* Window truncation can drop the call head from a settled result (see
* `ToolResultNode.call`/`callView` in dsh-client-runtime), leaving a terminal
* result with no call side. That still renders: the command falls back to the
* result view's replacement title, then to an empty command (the prompt line
* draws bare), and the prompt shows no cwd.
* Window truncation can drop the call event and its call-side view from a
* settled result (see `ToolResultNode.callView` in dsh-client-runtime), leaving
* a terminal result with no presentation call side even though the history
* envelope preserves its name and arguments. That still renders: the command
* falls back to the result view's replacement title, then to an empty command
* (the prompt line draws bare), and the prompt shows no cwd.
* @param block - RunningToolCall or ToolResultNode off the snapshot caches.
* @param sessionCwd - the session workspace root, which resolves an omitted or
* relative view cwd (see {@link resolveTerminalCwd}); absent leaves both unresolved.

View File

@@ -17,7 +17,7 @@ import { useState, type KeyboardEvent } from 'react'
import type { Context } from 'cordis'
import clsx from 'clsx'
import {
IconApiOutline14, IconChevronDownOutline14, StateDot, TerminalBlock,
IconApiOutline14, IconChevronDownOutline14, IconInspectOutline12, 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'
@@ -153,9 +153,7 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }:
)}
{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>
<IconInspectOutline12 />
Inspect
</button>
)}

View File

@@ -282,12 +282,12 @@ describe('chat-flow derivation', () => {
})
describe('ChatView', () => {
it('a windowless tool result (call head truncated) renders with an empty tool name', () => {
it('an orphan tool result renders through the generic fallback', () => {
const h = makeHarness({
nodes: [{ ...toolResult(3, 'w1'), call: null }],
})
const view = render(<h.ChatView {...h.props} />)
// classifyTool('') → others; the summary slot falls back to the callId.
// No durable call exists for this id, so the summary falls back to callId.
expect(view.container.querySelector('[data-variant="others"]')).not.toBeNull()
expect(view.getByText('w1')).toBeTruthy()
})

View File

@@ -750,6 +750,13 @@ export const IconSparkle16 = ({ size = 16, className }: IconProps) => (
</svg>
)
/** inspect_outline_12 (shared tool-row trajectory affordance glyph) */
export const IconInspectOutline12 = ({ size = 12, className }: IconProps) => (
<svg width={size} height={size} className={className} 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>
)
/** skill_outline_16 (skill tool-row glyph; document instructions + sparkle) */
export const IconSkillOutline16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">

View File

@@ -16,8 +16,8 @@ const icons = Object.fromEntries(
const iconNames = Object.keys(icons)
describe('ic_ds_ icon set', () => {
it('exports the full P-I set (46 deepsuite + 17 figma extracts + two hand-authored product glyphs)', () => {
expect(iconNames.length).toBe(65)
it('exports the full P-I set (46 deepsuite + 17 figma extracts + three product glyphs outside those sets)', () => {
expect(iconNames.length).toBe(66)
})
it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', (name) => {

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-skill/README.md
README.md: 2280c9302dbc46cff723752f88c47940f98417d5
README.zh.md: 0e9344ff63139f77461b02b48e18b0e94e54c223
README.md: ba9f1faae0f70a0f7bed4641e02703cc26bcb692
README.zh.md: f8210a885d201cbdc89d7a34704a819e80463d2c

View File

@@ -10,7 +10,7 @@ The `/client` export surface is the plugin body (`apply`/`inject`) only; the sou
## Skill tool row
The browser plugin also registers a keyed `skill` toolview in `conversation.chat.toolview`. A collapsed row renders the 16-pixel skill document-and-sparkle glyph, `Skill` title, separator, and requested skill name with the same neutral hierarchy as the Bash row; running calls carry the transcript shimmer, failures replace the name with the first error line, and interrupted calls use the warning state. A settled row expands as one whole-row disclosure into a bounded `Instructions` card containing the exact durable tool output, with the standard trajectory `Inspect` affordance when available. The row derives its name, lifecycle, and body only from the logged call/result slice, never from the current catalog, so cold replay remains stable even when installed skills or their descriptions change.
The browser plugin also registers a keyed `skill` toolview in `conversation.chat.toolview`. A collapsed row renders the 16-pixel skill document-and-sparkle glyph, `Skill` title, separator, and requested skill name with the same neutral hierarchy as the Bash row; running calls carry the transcript shimmer, failures replace the name with the first error line, and interrupted calls use the warning state. A settled row expands as one whole-row disclosure into a bounded `Instructions` card containing the exact durable tool output, with the standard trajectory `Inspect` affordance when available. The row derives its name, lifecycle, and body only from the logged call/result slice, using the history envelope's host-carried durable pair when pagination left the call event outside the window; it never reads the current catalog, so cold replay remains stable across page cuts and when installed skills or their descriptions change.
## Model Experience

View File

@@ -10,7 +10,7 @@ skill技能引用 source 的浏览器端:把 `/` 触发的 `skill` sourc
## skill 工具行
浏览器插件还会把一个 key 为 `skill` 的 toolview 注册进 `conversation.chat.toolview`。收起的行以与 Bash 行相同的中性色层级显示 16 像素的 skill 文档与闪光组合图标、`Skill` 标题、分隔符和请求加载的 skill 名称;运行中的调用带有 transcript文本记录的扫光效果失败时用错误首行替换名称中断调用则使用警告状态。已结算的行以整行作为展开入口展开后显示一个尺寸受限的 `Instructions` 卡片,其中原样呈现持久化的工具输出;可用时还会提供标准执行轨迹的 `Inspect` 入口。该行的名称、生命周期和正文只派生自已记录的调用/结果片段绝不读取当前 skill 目录,因此即使已安装的 skill 或其描述发生变化,冷回放仍保持稳定。
浏览器插件还会把一个 key 为 `skill` 的 toolview 注册进 `conversation.chat.toolview`。收起的行以与 Bash 行相同的中性色层级显示 16 像素的 skill 文档与闪光组合图标、`Skill` 标题、分隔符和请求加载的 skill 名称;运行中的调用带有 transcript文本记录的扫光效果失败时用错误首行替换名称中断调用则使用警告状态。已结算的行以整行作为展开入口展开后显示一个尺寸受限的 `Instructions` 卡片,其中原样呈现持久化的工具输出;可用时还会提供标准执行轨迹的 `Inspect` 入口。该行的名称、生命周期和正文只派生自已记录的调用/结果片段;分页将调用事件留在窗口外时,则使用 history envelope 中由 Host 携带的持久配对。该行绝不读取当前 skill 目录,因此冷回放在跨分页时,以及已安装的 skill 或其描述发生变化时均保持稳定。
## 模型体验

View File

@@ -4,7 +4,7 @@
import { useState, type KeyboardEvent, type ReactNode } from 'react'
import {
IconChevronDownOutline14, IconSkillOutline16, StateDot,
IconChevronDownOutline14, IconInspectOutline12, IconSkillOutline16, StateDot,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
@@ -45,7 +45,8 @@ function skillName(argsRaw: string, callId: string): string {
return argsRaw === '' ? callId : firstLine(argsRaw)
}
/** Flatten the durable result exactly like the generic row's text fallback. */
/** Flatten durable result blocks under the generic tool-row text contract.
* Keep aligned with ui-conversation's contract/tool-call-model.ts `resultText`. */
function resultText(block: ToolRowProps['block']): string | null {
if (!('kind' in block)) return null
const parts: string[] = []
@@ -108,15 +109,6 @@ function stateStatus(state: SkillRowState, t: SkillRowProps['t']): string | null
}
}
/** Inspect affordance glyph shared with the transcript's other tool rows. */
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>
)
}
/**
* Render one `skill` tool call as an accent summary and instructions disclosure.
* @param props - keyed toolview payload plus the skill locale seat.
@@ -129,7 +121,6 @@ export function SkillRow({ block, inspect, t }: SkillRowProps) {
const open = expanded && expandable
const status = stateStatus(model.state, t)
const summary = model.errorSummary ?? model.name
const ariaLabel = status === null ? `Skill ${summary}` : `${status} Skill ${summary}`
const toggleExpand = (): void => {
setExpanded(value => !value)
}
@@ -138,18 +129,20 @@ export function SkillRow({ block, inspect, t }: SkillRowProps) {
event.preventDefault()
toggleExpand()
}
const disclosureProps = expandable ? {
role: 'button' as const,
tabIndex: 0,
'aria-expanded': open,
onClick: toggleExpand,
onKeyDown: toggleFromKeyboard,
} : {}
const leading = disclosureLeading(model.state, open, expandable)
return (
<div className={css.card} data-tool="skill" data-state={model.state}>
<div
className={css.row}
data-expandable={expandable || undefined}
role={expandable ? 'button' : undefined}
tabIndex={expandable ? 0 : undefined}
aria-expanded={expandable ? open : undefined}
aria-label={expandable ? ariaLabel : undefined}
onClick={expandable ? toggleExpand : undefined}
onKeyDown={expandable ? toggleFromKeyboard : undefined}
{...disclosureProps}
>
<span className={css.leading}>{leading}</span>
{status !== null ? <span className={css.visuallyHidden}>{status}</span> : null}
@@ -167,7 +160,7 @@ export function SkillRow({ block, inspect, t }: SkillRowProps) {
</section>
{inspect !== undefined ? (
<button type="button" className={css.inspectButton} onClick={inspect}>
<IconInspect />
<IconInspectOutline12 />
Inspect
</button>
) : null}

View File

@@ -15,9 +15,10 @@ export const name = 'client-ui-skill-invariant'
export const inject = ['invariants']
/**
* No runtime invariant: a single slash-source registration whose disposal is
* proven by the HMR-safety spec — it emits no cordis events and owns no
* cross-plugin mutable state.
* No runtime invariant: the slash source, locale dictionaries, and keyed
* toolview are registry-owned registrations whose disposal is proven by the
* HMR-safety spec. They emit no cordis events and own no cross-plugin mutable
* state.
*/
const install: InvariantInstaller = () => {}

View File

@@ -14,6 +14,7 @@
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client'
import type { ClientSessionContext, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
import { apply, inject } from '../src/client/index.ts'
@@ -25,33 +26,28 @@ type ListResult =
| { ok: false; error: { code: string; message: string; details: object } }
type ListFn = (payload: object, signal?: AbortSignal) => Promise<{ result: ListResult }>
interface PresentationRegistration {
name: string
key?: string
locale?: string
}
interface PresentationCapture {
registration?: PresentationRegistration
component?: unknown
slots: SlotsService
dictionaries: Array<{ namespace: string; dictionaries: unknown }>
localeDisposed: boolean
}
/** Provide the presentation registries and capture the plugin's registrations. */
function providePresentation(ctx: Context): PresentationCapture {
const capture: PresentationCapture = { dictionaries: [] }
const slots = new SlotsService(ctx)
slots.register({
name: 'root',
children: { 'conversation.chat.toolview': { kind: 'keyed', scope: 'session' } },
} as never, () => null)
const capture: PresentationCapture = {
slots,
dictionaries: [],
localeDisposed: false,
}
ctx.provide('locale', {
register(namespace: string, dictionaries: unknown) {
capture.dictionaries.push({ namespace, dictionaries })
return () => {}
},
})
ctx.provide('slots', {
inject(_name: string, factory: () => unknown) { factory() },
register(registration: PresentationRegistration, component: unknown) {
capture.registration = registration
capture.component = component
return () => {}
return () => { capture.localeDisposed = true }
},
})
return capture
@@ -110,10 +106,10 @@ describe('apply', () => {
ctx.provide('sessions', { subagentAddress: () => undefined })
const presentation = providePresentation(ctx)
await ctx.plugin({ inject: [...inject], apply }).await()
expect(presentation.registration).toEqual({
name: 'conversation.chat.toolview', key: 'skill', locale: 'skill',
})
expect(presentation.component).toBe(SkillToolRow)
const entry = presentation.slots.entries('conversation.chat.toolview')[0]
expect(entry?.options).toMatchObject({ key: 'skill' })
expect(entry?.locale).toBe('skill')
expect(entry?.component).toBe(SkillToolRow)
expect(presentation.dictionaries).toEqual([{
namespace: 'skill', dictionaries: {
zh: {
@@ -138,7 +134,7 @@ describe('apply', () => {
ctx.provide('sessions', {})
await ctx.plugin(SlashService).await()
ctx.provide('connection', { api: { skills: { list: listOk(CATALOG) } } })
providePresentation(ctx)
const presentation = providePresentation(ctx)
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
const slash = ctx.get('slash') as SlashService
@@ -153,6 +149,8 @@ describe('apply', () => {
// …and fiber teardown releases it.
await fiber.dispose()
expect(() => slash.registerSource(rival)).not.toThrow()
expect(presentation.slots.entries('conversation.chat.toolview')).toHaveLength(0)
expect(presentation.localeDisposed).toBe(true)
})
})

View File

@@ -53,7 +53,7 @@ describe('SkillRow', () => {
it('renders a compact Bash-shaped summary and discloses the exact instructions', () => {
const inspect = vi.fn()
const view = render(<SkillRow {...props(settled(), inspect)} />)
const row = screen.getByRole('button', { name: 'Skill dsh-manage-issues' })
const row = screen.getByRole('button', { name: 'Skilldsh-manage-issues' })
expect(row.getAttribute('aria-expanded')).toBe('false')
expect(view.container.querySelector('[data-tool="skill"]')?.getAttribute('data-state')).toBe('ok')
expect(view.container.querySelector('[data-tool="skill"] svg')?.getAttribute('width')).toBe('16')
@@ -97,7 +97,7 @@ describe('SkillRow', () => {
isError: true,
error: { name: 'SkillError', code: 'missing' },
}))} />)
const row = screen.getByRole('button', { name: 'skill 加载失败 Skill SkillError: missing resource' })
const row = screen.getByRole('button', { name: 'skill 加载失败SkillSkillError: missing resource' })
expect(view.container.querySelector('[data-tool="skill"]')?.getAttribute('data-state')).toBe('error')
expect(row.textContent).not.toContain('Check SKILL.md.')
fireEvent.click(row)
@@ -126,7 +126,7 @@ describe('SkillRow', () => {
isError: true,
error: { name: 'SkillError', code: 'missing' },
}))} />)
const errorRow = screen.getByRole('button', { name: 'skill 加载失败 Skill SkillError: missing' })
const errorRow = screen.getByRole('button', { name: 'skill 加载失败SkillSkillError: missing' })
fireEvent.click(errorRow)
expect(screen.getAllByText('SkillError: missing')).toHaveLength(2)
})