feat(web): add dedicated skill tool row
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user