feat(web): add dedicated skill tool row

This commit is contained in:
Yichen Jiang
2026-08-06 13:59:05 +08:00
parent 16ad5f3f86
commit bb920b32e0
21 changed files with 921 additions and 12 deletions

View File

@@ -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

View 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')
})
})