|
|
@@ -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>
|
|
|
+ )
|
|
|
+}
|