feat(web): add dedicated skill tool row
This commit is contained in:
@@ -750,6 +750,20 @@ export const IconSparkle16 = ({ size = 16, className }: IconProps) => (
|
||||
</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">
|
||||
<path
|
||||
d="M12.5113 15.4067C12.4395 15.6249 12.1308 15.6249 12.059 15.4067L11.643 14.1416C11.454 13.567 11.0033 13.1164 10.4288 12.9274L9.16369 12.5113C8.94544 12.4395 8.94544 12.1308 9.16369 12.059L10.4288 11.643C11.0033 11.454 11.454 11.0033 11.643 10.4288L12.059 9.16369C12.1308 8.94544 12.4395 8.94544 12.5113 9.16369L12.9274 10.4288C13.1164 11.0033 13.567 11.454 14.1416 11.643L15.4067 12.059C15.6249 12.1308 15.6249 12.4395 15.4067 12.5113L14.1416 12.9274C13.567 13.1164 13.1164 13.567 12.9274 14.1416L12.5113 15.4067Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M9.02246 0.546878C9.9822 0.546878 10.7564 0.545403 11.374 0.612307C12.0042 0.680586 12.5515 0.826244 13.0273 1.17188C13.3052 1.37376 13.5501 1.61868 13.752 1.89649C14.0975 2.37225 14.2432 2.91984 14.3115 3.54981C14.3784 4.16727 14.377 4.94206 14.377 5.90137V8.51367C13.9611 8.29533 13.5071 8.13985 13.0273 8.06055V5.90137C13.0273 4.9121 13.0259 4.22322 12.9688 3.69532C12.9129 3.18044 12.8098 2.89782 12.6592 2.69043C12.5406 2.52724 12.3966 2.38326 12.2334 2.26465C12.026 2.11404 11.7437 2.0109 11.2285 1.95508C10.7005 1.89789 10.0122 1.89649 9.02246 1.89649H6.55371C5.56395 1.89649 4.87569 1.89787 4.34766 1.95508C3.83242 2.01092 3.55022 2.11398 3.34278 2.26465C3.17953 2.38329 3.03564 2.52719 2.91699 2.69043C2.76642 2.89782 2.66325 3.18042 2.60742 3.69532C2.55027 4.22322 2.54883 4.9121 2.54883 5.90137V10.0986C2.54883 11.0878 2.55031 11.7768 2.60742 12.3047C2.66326 12.8196 2.76642 13.1032 2.91699 13.3105C3.03558 13.4736 3.17966 13.6178 3.34278 13.7363C3.5502 13.8869 3.83265 13.9901 4.34766 14.0459C4.87568 14.1031 5.56398 14.1035 6.55371 14.1035H8.08399C8.27443 14.6025 8.55077 15.0585 8.89551 15.4541H6.55371C5.59402 15.4541 4.81976 15.4546 4.20215 15.3877C3.57204 15.3194 3.02468 15.1738 2.54883 14.8281C2.27111 14.6263 2.02606 14.3813 1.82422 14.1035C1.47883 13.6278 1.33293 13.08 1.26465 12.4502C1.19783 11.8327 1.19922 11.0579 1.19922 10.0986V5.90137C1.19922 4.94206 1.1978 4.16727 1.26465 3.54981C1.33295 2.91984 1.47867 2.37225 1.82422 1.89649C2.02613 1.61864 2.27098 1.37379 2.54883 1.17188C3.02472 0.826181 3.57197 0.6806 4.20215 0.612307C4.81976 0.545393 5.594 0.546877 6.55371 0.546878H9.02246ZM9.19629 9.14649H4.5459V7.84571H9.19629V9.14649ZM11.0303 6.10645H4.5459V4.80567H11.0303V6.10645Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** ic_ds_question_outline_14 (figma extract): ring + question glyph. */
|
||||
export const IconQuestionOutline14 = ({ size = 14, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
|
||||
@@ -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 + the hand-authored sparkle)', () => {
|
||||
expect(iconNames.length).toBe(64)
|
||||
it('exports the full P-I set (46 deepsuite + 17 figma extracts + two hand-authored product glyphs)', () => {
|
||||
expect(iconNames.length).toBe(65)
|
||||
})
|
||||
|
||||
it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', (name) => {
|
||||
|
||||
@@ -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: fc83ae47dc83e72d60f382892aa678989902d217
|
||||
README.zh.md: e103db812d2a21f7f211bc843ec0cd31d1dc2c1e
|
||||
README.md: 2280c9302dbc46cff723752f88c47940f98417d5
|
||||
README.zh.md: 0e9344ff63139f77461b02b48e18b0e94e54c223
|
||||
|
||||
@@ -8,6 +8,10 @@ A failed `skill.list` throws from `candidates`, which the slash shell logs and f
|
||||
|
||||
The `/client` export surface is the plugin body (`apply`/`inject`) only; the source object is internal to the registration effect.
|
||||
|
||||
## 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.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Skill reference text in the user prompt
|
||||
|
||||
@@ -8,6 +8,10 @@ skill(技能)引用 source 的浏览器端:把 `/` 触发的 `skill` sourc
|
||||
|
||||
`/client` 导出表层只有插件主体(`apply`/`inject`);source 对象是注册 effect 的内部实现。
|
||||
|
||||
## skill 工具行
|
||||
|
||||
浏览器插件还会把一个 key 为 `skill` 的 toolview 注册进 `conversation.chat.toolview`。收起的行以与 Bash 行相同的中性色层级显示 16 像素的 skill 文档与闪光组合图标、`Skill` 标题、分隔符和请求加载的 skill 名称;运行中的调用带有 transcript(文本记录)的扫光效果,失败时用错误首行替换名称,中断调用则使用警告状态。已结算的行以整行作为展开入口,展开后显示一个尺寸受限的 `Instructions` 卡片,其中原样呈现持久化的工具输出;可用时还会提供标准执行轨迹的 `Inspect` 入口。该行的名称、生命周期和正文只派生自已记录的调用/结果片段,绝不读取当前 skill 目录,因此即使已安装的 skill 或其描述发生变化,冷回放仍保持稳定。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 用户提示词中的 skill 引用文本
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-skill",
|
||||
"description": "Skill reference source: '/' menu candidates from skill.list, inserts <skill>name</skill> references",
|
||||
"description": "Web skill references and the dedicated skill tool row",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -25,6 +25,8 @@
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-conversation",
|
||||
"@deepseek-ai/dsh-client-ui-slash"
|
||||
],
|
||||
"platform": "web"
|
||||
@@ -36,19 +38,31 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"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",
|
||||
"@deepseek-ai/dsh-client-ui-slash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"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:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@types/react": "~18.3.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
|
||||
212
packages/client/ui-skill/src/client/SkillRow.module.css
Normal file
212
packages/client/ui-skill/src/client/SkillRow.module.css
Normal file
@@ -0,0 +1,212 @@
|
||||
/* Skill toolview: Bash-matched summary row plus a bounded instructions disclosure. */
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.row {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.row[data-expandable] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card[data-state='running'] .row::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0 auto 0 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-skill-row-sweep 2.6s ease-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes dsh-skill-row-sweep {
|
||||
0% { left: -300px; }
|
||||
90%, 100% { left: 100%; }
|
||||
}
|
||||
|
||||
.leading {
|
||||
position: relative;
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 6px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.chevron {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.iconIdle {
|
||||
display: inline-flex;
|
||||
opacity: 1;
|
||||
transition: opacity 100ms ease;
|
||||
}
|
||||
|
||||
.chevronHover {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
opacity: 0;
|
||||
transition: opacity 100ms ease;
|
||||
}
|
||||
|
||||
.row:hover .iconIdle {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.row:hover .chevronHover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: none;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.separator {
|
||||
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);
|
||||
}
|
||||
|
||||
.errorSummary {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.bodyWrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.instructionsCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 260px;
|
||||
margin: 4px 0 4px 4px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--dsw-alias-border-l1);
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-alias-markdown-code-block);
|
||||
}
|
||||
|
||||
.instructionsHeader {
|
||||
flex: none;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
background: var(--dsw-alias-markdown-code-block-banner);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 16px;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.instructions {
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
padding: 10px 12px 12px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
font: var(--dsw-font-markdown-code-block-small);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.instructions[data-error] {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.instructions::-webkit-scrollbar-thumb {
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.instructions::-webkit-scrollbar-track {
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
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;
|
||||
}
|
||||
|
||||
.inspectButton:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover-solid);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.visuallyHidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.card[data-state='running'] .row::after {
|
||||
animation: none;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.iconIdle,
|
||||
.chevronHover,
|
||||
.inspectButton {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
174
packages/client/ui-skill/src/client/SkillRow.tsx
Normal file
174
packages/client/ui-skill/src/client/SkillRow.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
// Skill toolview registrant: a domain-owned row over the keyed toolview hole.
|
||||
// The compact accent row keeps loaded instructions scannable in the transcript;
|
||||
// the exact durable tool output remains available in a bounded disclosure card.
|
||||
|
||||
import { useState, type KeyboardEvent, type ReactNode } from 'react'
|
||||
import {
|
||||
IconChevronDownOutline14, 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'
|
||||
import css from './SkillRow.module.css'
|
||||
|
||||
/** Skill row lifecycle derived solely from the durable call slice. */
|
||||
type SkillRowState = 'running' | 'ok' | 'error' | 'stopped'
|
||||
|
||||
/** Full row props: the toolview runtime share plus this package's locale seat. */
|
||||
type SkillRowProps = ToolRowProps & PropsLocale<'skill'>
|
||||
|
||||
/** Compact, replay-stable view model for the dedicated row. */
|
||||
interface SkillRowModel {
|
||||
readonly name: string
|
||||
readonly output: string | null
|
||||
readonly errorSummary: string | null
|
||||
readonly state: SkillRowState
|
||||
}
|
||||
|
||||
/** First physical line for the collapsed error summary and malformed-args fallback. */
|
||||
function firstLine(text: string): string {
|
||||
const newline = text.indexOf('\n')
|
||||
return newline === -1 ? text : text.slice(0, newline)
|
||||
}
|
||||
|
||||
/** Skill names are the only call argument the compact row presents. */
|
||||
function skillName(argsRaw: string, callId: string): string {
|
||||
try {
|
||||
const parsed = JSON.parse(argsRaw) as unknown
|
||||
if (typeof parsed === 'object' && parsed !== null) {
|
||||
const name = (parsed as Record<string, unknown>).name
|
||||
if (typeof name === 'string' && name !== '') return firstLine(name)
|
||||
}
|
||||
} catch {
|
||||
// Streaming can expose a truncated JSON prefix; its first line is still
|
||||
// more useful than replacing the call with an unrelated catalog lookup.
|
||||
}
|
||||
return argsRaw === '' ? callId : firstLine(argsRaw)
|
||||
}
|
||||
|
||||
/** Flatten the durable result exactly like the generic row's text fallback. */
|
||||
function resultText(block: ToolRowProps['block']): string | null {
|
||||
if (!('kind' in block)) return null
|
||||
const parts: string[] = []
|
||||
for (const item of block.content) {
|
||||
parts.push(item.type === 'text' ? item.text : JSON.stringify(item, null, 2))
|
||||
}
|
||||
if (parts.length === 0 && block.error !== undefined) {
|
||||
parts.push(`${block.error.name}: ${block.error.code}`)
|
||||
}
|
||||
return parts.join('\n') || null
|
||||
}
|
||||
|
||||
/** Derive display state without consulting the live skill catalog. */
|
||||
function skillRowModel(block: ToolRowProps['block']): SkillRowModel {
|
||||
const settled = 'kind' in block
|
||||
const argsRaw = (settled ? block.call?.argsRaw : block.argsRaw) ?? ''
|
||||
const state: SkillRowState = !settled
|
||||
? 'running'
|
||||
: block.error?.code === 'interrupted'
|
||||
? 'stopped'
|
||||
: block.isError ? 'error' : 'ok'
|
||||
const output = resultText(block)
|
||||
return {
|
||||
name: skillName(argsRaw, block.callId),
|
||||
output,
|
||||
errorSummary: state === 'error' && output !== null ? firstLine(output) : null,
|
||||
state,
|
||||
}
|
||||
}
|
||||
|
||||
/** State substitution for the collapsed leading slot. */
|
||||
function leadingFor(state: SkillRowState): ReactNode {
|
||||
switch (state) {
|
||||
case 'error': return <StateDot state="error" />
|
||||
case 'stopped': return <StateDot state="warning" />
|
||||
default: return <IconSkillOutline16 />
|
||||
}
|
||||
}
|
||||
|
||||
/** Visually hidden state copy for the colour-only lifecycle cues. */
|
||||
function stateStatus(state: SkillRowState, t: SkillRowProps['t']): string | null {
|
||||
switch (state) {
|
||||
case 'running': return t('row.running')
|
||||
case 'error': return t('row.failed')
|
||||
case 'stopped': return t('row.stopped')
|
||||
default: return 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.
|
||||
* @returns the dedicated skill row.
|
||||
*/
|
||||
export function SkillRow({ block, inspect, t }: SkillRowProps) {
|
||||
const model = skillRowModel(block)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const expandable = model.output !== null
|
||||
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)
|
||||
}
|
||||
const toggleFromKeyboard = (event: KeyboardEvent<HTMLDivElement>): void => {
|
||||
if (!expandable || (event.key !== 'Enter' && event.key !== ' ')) return
|
||||
event.preventDefault()
|
||||
toggleExpand()
|
||||
}
|
||||
const leading = open
|
||||
? <IconChevronDownOutline14 className={css.chevron} />
|
||||
: expandable
|
||||
? (
|
||||
<>
|
||||
<span className={css.iconIdle}>{leadingFor(model.state)}</span>
|
||||
<IconChevronDownOutline14 className={`${css.chevron} ${css.chevronHover}`} />
|
||||
</>
|
||||
)
|
||||
: leadingFor(model.state)
|
||||
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}
|
||||
>
|
||||
<span className={css.leading}>{leading}</span>
|
||||
{status !== null ? <span className={css.visuallyHidden}>{status}</span> : null}
|
||||
<span className={css.title}>Skill</span>
|
||||
<span className={css.separator} aria-hidden />
|
||||
<span className={model.errorSummary === null ? css.summary : `${css.summary} ${css.errorSummary}`}>
|
||||
{summary}
|
||||
</span>
|
||||
</div>
|
||||
{open ? (
|
||||
<div className={css.bodyWrap}>
|
||||
<section className={css.instructionsCard} aria-label={t('row.instructions')}>
|
||||
<div className={css.instructionsHeader}>{t('row.instructions')}</div>
|
||||
<pre className={css.instructions} data-error={model.state === 'error' || undefined}>{model.output}</pre>
|
||||
</section>
|
||||
{inspect !== undefined ? (
|
||||
<button type="button" className={css.inspectButton} onClick={inspect}>
|
||||
<IconInspect />
|
||||
Inspect
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -19,10 +19,24 @@
|
||||
* not kill the prewarm other consumers will hit, so it carries its own
|
||||
* abort (fired only on invalidation/teardown) while a candidates caller
|
||||
* with an aborted signal just returns early.
|
||||
*
|
||||
* This browser half also owns the `skill` keyed toolview: a replay-stable
|
||||
* accent row derived only from each logged call/result slice.
|
||||
*/
|
||||
import type { ConnectionHandle, SessionId, SkillEntry } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ClientContext, ISessions } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SlashServiceContract, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { SkillRow } from './SkillRow.tsx'
|
||||
import { en, NS, zh, type SkillKey } from './locales.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap {
|
||||
/** The dedicated skill tool row's copy. */
|
||||
skill: SkillKey
|
||||
}
|
||||
}
|
||||
|
||||
/** One session's catalog fetch: the shared promise plus its own abort handle. */
|
||||
interface CatalogFetch {
|
||||
@@ -32,14 +46,20 @@ interface CatalogFetch {
|
||||
settled?: readonly SkillEntry[]
|
||||
}
|
||||
|
||||
/** Required services: slash registry, routed sessions, and the wire face. */
|
||||
export const inject = ['slash', 'connection', 'sessions']
|
||||
/** Required services: reference source faces plus the tool-row and locale registries. */
|
||||
export const inject = ['slash', 'connection', 'sessions', 'slots', 'locale']
|
||||
|
||||
/**
|
||||
* Client plugin body: register the '/' skill source over the root wire face.
|
||||
* Client plugin body: register the '/' source, dictionaries, and keyed tool row.
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-skill: dictionaries')
|
||||
ctx.slots.inject('conversation.chat.toolview', () => ctx.slots.register(
|
||||
{ name: 'conversation.chat.toolview', key: 'skill', locale: NS },
|
||||
SkillRow,
|
||||
))
|
||||
|
||||
const skills = (ctx.get('connection') as ConnectionHandle).api.skills
|
||||
const sessions = ctx.get('sessions') as ISessions
|
||||
// Session-keyed catalog cache; single-flight per key. Plugin-closure state:
|
||||
|
||||
23
packages/client/ui-skill/src/client/locales.ts
Normal file
23
packages/client/ui-skill/src/client/locales.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/** `skill` namespace dictionaries for the dedicated tool row. */
|
||||
|
||||
/** Dictionary namespace owned by this plugin. */
|
||||
export const NS = 'skill'
|
||||
|
||||
/** Simplified Chinese dictionary (the key-set source of truth). */
|
||||
export const zh = {
|
||||
'row.running': '正在加载 skill',
|
||||
'row.failed': 'skill 加载失败',
|
||||
'row.stopped': 'skill 加载已中止',
|
||||
'row.instructions': '说明',
|
||||
} satisfies Record<string, string>
|
||||
|
||||
/** The skill namespace key union. */
|
||||
export type SkillKey = keyof typeof zh
|
||||
|
||||
/** English dictionary, checked complete against the zh key set. */
|
||||
export const en = {
|
||||
'row.running': 'Loading skill',
|
||||
'row.failed': 'Skill load failed',
|
||||
'row.stopped': 'Skill load stopped',
|
||||
'row.instructions': 'Instructions',
|
||||
} satisfies Record<SkillKey, string>
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* ui-skill browser half: source registration (duplicate-name proof) +
|
||||
* ui-skill browser half: source and keyed toolview registration +
|
||||
* locale dictionaries + source duplicate-name proof +
|
||||
* fiber-teardown removal (HMR safety) against the real SlashService, then
|
||||
* the source behavior contract driven directly on the captured source with
|
||||
* real ClientSessionContext projections — sessionId addressing, the
|
||||
@@ -16,6 +17,7 @@ import type { SessionId } 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'
|
||||
import { SkillRow as SkillToolRow } from '../src/client/SkillRow.tsx'
|
||||
|
||||
type SkillRow = { name: string; description: string; whenToUse?: string }
|
||||
type ListResult =
|
||||
@@ -23,6 +25,38 @@ 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
|
||||
dictionaries: Array<{ namespace: string; dictionaries: unknown }>
|
||||
}
|
||||
|
||||
/** Provide the presentation registries and capture the plugin's registrations. */
|
||||
function providePresentation(ctx: Context): PresentationCapture {
|
||||
const capture: PresentationCapture = { dictionaries: [] }
|
||||
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
|
||||
}
|
||||
|
||||
/** Boot the plugin over fake slash/connection faces; returns the captured source and its ctx. */
|
||||
async function bench(list: ListFn, addressed?: SessionId) {
|
||||
const ctx = new Context()
|
||||
@@ -34,6 +68,7 @@ async function bench(list: ListFn, addressed?: SessionId) {
|
||||
? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const }
|
||||
: undefined,
|
||||
})
|
||||
providePresentation(ctx)
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
return { ctx, source: captured! }
|
||||
}
|
||||
@@ -65,7 +100,36 @@ const req = (query: string, signal?: AbortSignal) =>
|
||||
|
||||
describe('apply', () => {
|
||||
it('declares the services it binds', () => {
|
||||
expect(inject).toEqual(['slash', 'connection', 'sessions'])
|
||||
expect(inject).toEqual(['slash', 'connection', 'sessions', 'slots', 'locale'])
|
||||
})
|
||||
|
||||
it('registers the dedicated skill row and its locale dictionaries', async () => {
|
||||
const ctx = new Context()
|
||||
ctx.provide('slash', { registerSource: () => () => {} })
|
||||
ctx.provide('connection', { api: { skills: { list: listOk(CATALOG) } } })
|
||||
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)
|
||||
expect(presentation.dictionaries).toEqual([{
|
||||
namespace: 'skill', dictionaries: {
|
||||
zh: {
|
||||
'row.running': '正在加载 skill',
|
||||
'row.failed': 'skill 加载失败',
|
||||
'row.stopped': 'skill 加载已中止',
|
||||
'row.instructions': '说明',
|
||||
},
|
||||
en: {
|
||||
'row.running': 'Loading skill',
|
||||
'row.failed': 'Skill load failed',
|
||||
'row.stopped': 'Skill load stopped',
|
||||
'row.instructions': 'Instructions',
|
||||
},
|
||||
},
|
||||
}])
|
||||
})
|
||||
|
||||
it('registers the "/" skill source; disposal frees the name (HMR safety)', async () => {
|
||||
@@ -74,6 +138,7 @@ describe('apply', () => {
|
||||
ctx.provide('sessions', {})
|
||||
await ctx.plugin(SlashService).await()
|
||||
ctx.provide('connection', { api: { skills: { list: listOk(CATALOG) } } })
|
||||
providePresentation(ctx)
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
const slash = ctx.get('slash') as SlashService
|
||||
|
||||
152
packages/client/ui-skill/tests/skill-row.spec.tsx
Normal file
152
packages/client/ui-skill/tests/skill-row.spec.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
// @vitest-environment jsdom
|
||||
// Dedicated skill tool row: replay-stable naming, lifecycle states, disclosure,
|
||||
// keyboard operation, exact output, and the trajectory Inspect handoff.
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RunningToolCall, ToolResultNode } 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 { SkillRow } from '../src/client/SkillRow.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
|
||||
type SkillRowProps = Parameters<typeof SkillRow>[0]
|
||||
|
||||
const t: SkillRowProps['t'] = makeTranslate(zh, commonZh)
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
function settled(over: Partial<ToolResultNode> = {}): ToolResultNode {
|
||||
return {
|
||||
kind: 'tool-result',
|
||||
seq: 3,
|
||||
time: 3_000,
|
||||
callId: 'call-skill',
|
||||
call: { name: 'skill', argsRaw: '{"name":"dsh-manage-issues"}' },
|
||||
callTime: 2_000,
|
||||
content: [{ type: 'text', text: 'Follow the issue workflow.\nKeep project fields in sync.' }],
|
||||
isError: false,
|
||||
callView: null,
|
||||
resultView: null,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
function running(argsRaw = '{"name":"dsh-manage-issues"}'): RunningToolCall {
|
||||
return {
|
||||
callId: 'call-skill', name: 'skill', argsRaw, turn: 1, step: 1, time: 2_000, callView: null,
|
||||
}
|
||||
}
|
||||
|
||||
function props(block: SkillRowProps['block'], inspect?: () => void): SkillRowProps {
|
||||
return {
|
||||
callId: block.callId,
|
||||
toolName: 'skill',
|
||||
block,
|
||||
openFile: vi.fn(),
|
||||
inspect,
|
||||
t,
|
||||
} as unknown as SkillRowProps
|
||||
}
|
||||
|
||||
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' })
|
||||
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')
|
||||
expect(screen.queryByLabelText('说明')).toBeNull()
|
||||
|
||||
fireEvent.click(row)
|
||||
expect(row.getAttribute('aria-expanded')).toBe('true')
|
||||
const card = screen.getByLabelText('说明')
|
||||
expect(card.textContent).toBe('说明Follow the issue workflow.\nKeep project fields in sync.')
|
||||
expect(view.container.textContent).not.toContain('{"name":"dsh-manage-issues"}')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Inspect' }))
|
||||
expect(inspect).toHaveBeenCalledTimes(1)
|
||||
|
||||
fireEvent.click(row)
|
||||
expect(row.getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('supports Enter and Space while ignoring unrelated keys', () => {
|
||||
render(<SkillRow {...props(settled())} />)
|
||||
const row = screen.getByRole('button')
|
||||
fireEvent.keyDown(row, { key: 'Escape' })
|
||||
expect(row.getAttribute('aria-expanded')).toBe('false')
|
||||
fireEvent.keyDown(row, { key: 'Enter' })
|
||||
expect(row.getAttribute('aria-expanded')).toBe('true')
|
||||
fireEvent.keyDown(row, { key: ' ' })
|
||||
expect(row.getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('keeps a running call compact and announces its state', () => {
|
||||
const view = render(<SkillRow {...props(running())} />)
|
||||
const row = view.container.querySelector('[data-tool="skill"] > div')!
|
||||
expect(row.getAttribute('role')).toBeNull()
|
||||
expect(view.container.textContent).toContain('正在加载 skill')
|
||||
expect(view.container.textContent).toContain('dsh-manage-issues')
|
||||
expect(view.container.querySelector('svg [fill="currentColor"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('uses the first failure line in the summary and exposes the full error', () => {
|
||||
const view = render(<SkillRow {...props(settled({
|
||||
content: [{ type: 'text', text: 'SkillError: missing resource\nCheck SKILL.md.' }],
|
||||
isError: true,
|
||||
error: { name: 'SkillError', code: 'missing' },
|
||||
}))} />)
|
||||
const row = screen.getByRole('button', { name: 'skill 加载失败 Skill SkillError: 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)
|
||||
const output = view.container.querySelector('pre')!
|
||||
expect(output.textContent).toBe('SkillError: missing resource\nCheck SKILL.md.')
|
||||
expect(output.getAttribute('data-error')).toBe('true')
|
||||
})
|
||||
|
||||
it('renders stopped, structured, and structured-error durable outcomes', () => {
|
||||
const stoppedView = render(<SkillRow {...props(settled({
|
||||
error: { name: 'InterruptedError', code: 'interrupted' },
|
||||
}))} />)
|
||||
expect(stoppedView.container.textContent).toContain('skill 加载已中止')
|
||||
expect(stoppedView.container.querySelector('[data-state="warning"]')).not.toBeNull()
|
||||
cleanup()
|
||||
|
||||
const structuredView = render(<SkillRow {...props(settled({
|
||||
content: [{ type: 'reasoning', text: 'structured instruction note' }],
|
||||
}))} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(structuredView.container.textContent).toContain('"type": "reasoning"')
|
||||
cleanup()
|
||||
|
||||
render(<SkillRow {...props(settled({
|
||||
content: [],
|
||||
isError: true,
|
||||
error: { name: 'SkillError', code: 'missing' },
|
||||
}))} />)
|
||||
const errorRow = screen.getByRole('button', { name: 'skill 加载失败 Skill SkillError: missing' })
|
||||
fireEvent.click(errorRow)
|
||||
expect(screen.getAllByText('SkillError: missing')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('falls back to durable args or call id when the skill name is unavailable', () => {
|
||||
const invalid = render(<SkillRow {...props(running('{"name":\n'))} />)
|
||||
expect(invalid.container.textContent).toContain('{"name":')
|
||||
cleanup()
|
||||
|
||||
const scalar = render(<SkillRow {...props(running('"raw-name"'))} />)
|
||||
expect(scalar.container.textContent).toContain('"raw-name"')
|
||||
cleanup()
|
||||
|
||||
const emptyName = render(<SkillRow {...props(running('{"name":""}'))} />)
|
||||
expect(emptyName.container.textContent).toContain('{"name":""}')
|
||||
cleanup()
|
||||
|
||||
const blank = render(<SkillRow {...props(settled({ call: null, content: [] }))} />)
|
||||
expect(blank.container.textContent).toContain('call-skill')
|
||||
expect(blank.container.querySelector('[role="button"]')).toBeNull()
|
||||
expect(blank.container.textContent).not.toContain('正在加载 skill')
|
||||
})
|
||||
})
|
||||
@@ -14,9 +14,18 @@
|
||||
{
|
||||
"path": "../connection"
|
||||
},
|
||||
{
|
||||
"path": "../locale"
|
||||
},
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
{
|
||||
"path": "../ui-conversation"
|
||||
},
|
||||
{
|
||||
"path": "../ui-primitives"
|
||||
},
|
||||
{
|
||||
"path": "../ui-slash"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user