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
@@ -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;
}
}
@@ -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>
)
}
+23 -3
View File
@@ -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:
@@ -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>