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 <skill_content>
collapsed behind a disclosure. A record with an unreadable name degrades
to the injected-context row.
This commit is contained in:
Yichen Jiang
2026-08-08 01:05:08 +08:00
parent 56e9e61749
commit 011e3e4e63
8 changed files with 163 additions and 4 deletions
@@ -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;
}
@@ -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 `<skill_content>` 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 (
<div className={css.userRow} data-skill-invocation data-time-hover-root>
<div className={css.bubble}>
<span className={css.refChip} data-ref-chip="skill">{`/${node.name}`}</span>
{node.args !== undefined && <MessageText text={` ${node.args}`} />}
<details className={css.skillInvocationDetails}>
<summary className={css.skillInvocationSummary}>{t('message.skillInvocation.expand')}</summary>
<pre className={css.skillInvocationBody}>{text}</pre>
</details>
</div>
<MessageIconActions
text={text}
time={node.time}
clock="start"
className={css.actions}
t={t}
/>
</div>
)
}
/**
* 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 <SkillInvocationRow node={node} t={t} />
case 'compaction':
return <CompactionItem node={node} t={t} />
case 'model-retry':
@@ -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',
@@ -864,6 +864,42 @@ describe('MessageItem arms', () => {
view.rerender(<MessageItem t={t} node={node} retryActive />)
expect(view.getByRole('status').textContent).toBe('正在重试模型请求(1/2 · 1s')
})
it('skill-invocation renders the /name chip, args, and a collapsed injected body', () => {
const body = '<skill_content name="hidden-demo">instructions</skill_content>\n\ncheck the fixture'
const view = render(
<MessageItem t={t} node={{
kind: 'skill-invocation', seq: 4, time: 1_000,
name: 'hidden-demo', args: 'check the fixture',
content: [{ type: 'text', text: body }] as never,
source: null,
}}
/>,
)
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(
<MessageItem t={t} node={{
kind: 'skill-invocation', seq: 5, time: 1_000,
name: 'bare-skill',
content: [{ type: 'text', text: '<skill_content name="bare-skill">x</skill_content>' }] 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', () => {