From 011e3e4e63f4cae663bf2aa7a4523c92c9786f68 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 01:05:08 +0800 Subject: [PATCH] feat(client): render user skill invocations as dedicated transcript cards A user/message carrying the skill-invocation source materializes as its own conversation node (name/args lifted off the source metadata, never re-parsed from the body) and renders as a right-aligned bubble: the /name chip plus the user's trailing text, with the injected collapsed behind a disclosure. A record with an unreadable name degrades to the injected-context row. --- packages/client/runtime/src/client/index.ts | 2 +- .../src/client/sessions/conversation.ts | 20 ++++++++++ .../src/client/sessions/transcript-adapter.ts | 16 +++++++- .../runtime/tests/transcript-adapter.spec.ts | 25 ++++++++++++ .../src/client/chat/MessageItem.module.css | 27 +++++++++++++ .../src/client/chat/MessageItem.tsx | 39 ++++++++++++++++++- .../ui-conversation/src/client/locales.ts | 2 + .../tests/chat-branch-tails.spec.tsx | 36 +++++++++++++++++ 8 files changed, 163 insertions(+), 4 deletions(-) diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 5a1677df96..a0aa4df482 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -49,7 +49,7 @@ export type { AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig, AssistantTiming, CodeSubCall, CommandNode, CompactionSummaryNode, ComposerPhase, ContextMessageNode, ConversationNode, ConversationSnapshot, ModelRetryNode, QueuedMessage, - RunningToolCall, + RunningToolCall, SkillInvocationNode, SteeringMessageNode, TodoItem, ToolResultNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode, } from './sessions/conversation.ts' export type { diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index fb2c281331..d66faf5e95 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -129,6 +129,25 @@ export interface ContextMessageNode { form: KnownContextForm | null } +/** + * A user-explicit skill invocation: the host injected the rendered skill as a + * user message carrying the `skill-invocation` source, so the card presents + * `/name args` from source metadata and collapses the injected body. + */ +export interface SkillInvocationNode { + kind: 'skill-invocation' + seq: number + /** Unix epoch ms from the source session event. */ + time: number + /** Invoked skill name read off the message source. */ + name: string + /** Trailing user text read off the message source, when recorded. */ + args?: string + /** Full injected model-facing content (collapsed by default in the UI). */ + content: readonly ContentBlock[] + source: unknown +} + /** Durable notice that a closed failed step is waiting for a model-request retry. */ export type ModelRetryNode = LlmRetryEventData & { kind: 'model-retry' @@ -245,6 +264,7 @@ export type ConversationNode = | AssistantMessageNode | SteeringMessageNode | ContextMessageNode + | SkillInvocationNode | ModelRetryNode | TurnErrorNode | ToolResultNode diff --git a/packages/client/runtime/src/client/sessions/transcript-adapter.ts b/packages/client/runtime/src/client/sessions/transcript-adapter.ts index d970be596b..4a05afee06 100644 --- a/packages/client/runtime/src/client/sessions/transcript-adapter.ts +++ b/packages/client/runtime/src/client/sessions/transcript-adapter.ts @@ -57,7 +57,20 @@ function materializeNode( stepTimings: ReadonlyMap, ): ConversationNode { switch (event.type) { - case 'user/message': + case 'user/message': { + // A user-explicit skill invocation carries its name (and optional args) + // on the source; the dedicated node lets the card render `/name args` + // from metadata instead of re-parsing the injected body. A record whose + // name is unreadable degrades to injected context below. + const source = event.data.source as { kind?: unknown; name?: unknown; args?: unknown } + if (source.kind === 'skill-invocation' && typeof source.name === 'string') { + return { + kind: 'skill-invocation', seq: event.seq, time: event.time, + name: source.name, + ...typeof source.args === 'string' ? { args: source.args } : {}, + content: event.data.content, source: event.data.source, + } + } // Injected context (plugin/goal source) folds to a context node, not a // user message; only a direct human prompt is a user node. A compaction // checkpoint never reaches here (isCompactCheckpoint routes it away). @@ -80,6 +93,7 @@ function materializeNode( kind: 'user', seq: event.seq, time: event.time, content: event.data.content, source: event.data.source, } + } case 'assistant/message': return { kind: 'assistant', seq: event.seq, time: event.time, diff --git a/packages/client/runtime/tests/transcript-adapter.spec.ts b/packages/client/runtime/tests/transcript-adapter.spec.ts index e4ef3b0e1a..e847c2cec7 100644 --- a/packages/client/runtime/tests/transcript-adapter.spec.ts +++ b/packages/client/runtime/tests/transcript-adapter.spec.ts @@ -164,6 +164,31 @@ describe('TranscriptAdapter', () => { expect(adapter.nodes().map(node => node.kind)).toEqual(['user', 'user', 'context']) }) + it('materializes a skill-invocation source as its dedicated node', () => { + const adapter = new TranscriptAdapter() + adapter.reset([ + at(0, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({ + content: [{ type: 'text', text: 'body\n\ncheck the fixture' }], + source: { kind: 'skill-invocation', name: 'hidden-demo', args: 'check the fixture' } as never, + }) }), + at(1, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({ + content: [{ type: 'text', text: 'body' }], + source: { kind: 'skill-invocation', name: 'bare-skill' } as never, + }) }), + ]) + const nodes = adapter.nodes() + expect(nodes.map(node => node.kind)).toEqual(['skill-invocation', 'skill-invocation']) + expect(nodes[0]).toMatchObject({ name: 'hidden-demo', args: 'check the fixture' }) + expect(nodes[1]).toMatchObject({ name: 'bare-skill' }) + expect((nodes[1] as { args?: string }).args).toBeUndefined() + // A malformed record (no readable name) degrades to injected context, not a crash. + adapter.append(at(2, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({ + content: [{ type: 'text', text: 'odd' }], + source: { kind: 'skill-invocation' } as never, + }) })) + expect(adapter.nodes().at(-1)?.kind).toBe('context') + }) + it('skips events core does not call surface-eligible, marker or not', () => { // The transcript is the append-origin surface, so log-only events (a chunk, // a turn boundary, a compact/* provenance record) and a future type core diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css index 5c07ace71e..4330cde32c 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css @@ -256,3 +256,30 @@ white-space: nowrap; vertical-align: baseline; } + +/* User-explicit skill invocation: the injected body collapses behind a + disclosure inside the user bubble. */ +.skillInvocationDetails { + margin-top: 6px; +} + +.skillInvocationSummary { + cursor: pointer; + font-size: 0.8em; + color: var(--dsw-alias-label-secondary); + user-select: none; +} + +.skillInvocationBody { + margin: 6px 0 0; + padding: 8px; + max-height: 320px; + overflow: auto; + border-radius: 6px; + background: var(--dsw-alias-bg-secondary, rgba(0, 0, 0, 0.06)); + font-family: var(--dsw-font-mono, monospace); + font-size: 0.78em; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; +} diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index 5473c9f8a2..661dd0cda5 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -7,8 +7,8 @@ import { memo, useEffect, useMemo, useState } from 'react' import type { ReactNode } from 'react' import type { - CompactionSummaryNode, ContextMessageNode, ModelRetryNode, SteeringMessageNode, - TurnErrorNode, UnknownSurfaceNode, UserMessageNode, + CompactionSummaryNode, ContextMessageNode, ModelRetryNode, SkillInvocationNode, + SteeringMessageNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode, } from '@deepseek-ai/dsh-client-runtime/client' import { JsonBlock, MessageText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' @@ -22,6 +22,7 @@ export interface MessageItemProps { | UserMessageNode | SteeringMessageNode | ContextMessageNode + | SkillInvocationNode | CompactionSummaryNode | ModelRetryNode | TurnErrorNode @@ -193,6 +194,38 @@ function UserStyleBubble({ ) } +/** + * A user-explicit skill invocation: the right-aligned bubble presents the + * `/name args` gesture from source metadata (never re-parsed from the body), + * and the injected `` collapses behind a disclosure — the + * durable content is model-facing bulk, not conversation prose. + */ +function SkillInvocationRow({ node, t }: { + node: SkillInvocationNode + t: ChatViewSlotProps['t'] +}): ReactNode { + const { text } = contentText(node.content) + return ( +
+
+ {`/${node.name}`} + {node.args !== undefined && } +
+ {t('message.skillInvocation.expand')} +
{text}
+
+
+ +
+ ) +} + /** * Render one Host-authoritative pending steering item with the same visual * language as its eventual durable transcript node. @@ -254,6 +287,8 @@ export const MessageItem = memo(function MessageItem({ t={t} /> ) + case 'skill-invocation': + return case 'compaction': return case 'model-retry': diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index df107d2cd2..a340a2f634 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -79,6 +79,7 @@ export const zh = { 'message.context.recall.counts': '保留 {retained} 条 · 省略 {omitted} 条', 'message.context.recall.truncated': '已截断', 'message.steering': '插话', + 'message.skillInvocation.expand': '查看注入的 skill 内容', 'message.compaction': '上下文已压缩', 'message.compaction.expand': '点击查看压缩摘要', 'message.compaction.unavailable': '压缩摘要不可用', @@ -219,6 +220,7 @@ export const en = { 'message.context.recall.counts': '{retained} kept · {omitted} omitted', 'message.context.recall.truncated': 'truncated', 'message.steering': 'Interjection', + 'message.skillInvocation.expand': 'View injected skill content', 'message.compaction': 'Context compacted', 'message.compaction.expand': 'View compaction summary', 'message.compaction.unavailable': 'Compaction summary unavailable', diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index 3122b0fdc7..9471461cda 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -864,6 +864,42 @@ describe('MessageItem arms', () => { view.rerender() expect(view.getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 1s') }) + + it('skill-invocation renders the /name chip, args, and a collapsed injected body', () => { + const body = 'instructions\n\ncheck the fixture' + const view = render( + , + ) + const chip = view.container.querySelector('[data-ref-chip="skill"]') + expect(chip?.textContent).toBe('/hidden-demo') + const details = view.container.querySelector('details') + expect(details).toBeTruthy() + expect(details?.open).toBe(false) + expect(view.getByText('查看注入的 skill 内容')).toBeTruthy() + expect(view.container.querySelector('pre')?.textContent).toBe(body) + expect(view.container.querySelector('[data-skill-invocation]')).toBeTruthy() + }) + + it('skill-invocation without args renders only the chip line', () => { + const view = render( + x
' }] as never, + source: null, + }} + />, + ) + const bubble = view.container.querySelector('[data-skill-invocation]') + expect(bubble?.textContent).toContain('/bare-skill') + expect(bubble?.textContent).not.toContain('undefined') + }) }) describe('formatMessageClock', () => {