Merge pull request #1754 from deepseek-harness/worktree/web-skill-tool-row
feat(web): add dedicated skill tool row
This commit is contained in:
@@ -23,7 +23,7 @@
|
||||
import { useEffect, useRef, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
CodeBlock, DiffBlock, ReadBlock, SearchBlock, StateDot, TerminalBlock, WebBlock,
|
||||
CodeBlock, DiffBlock, IconInspectOutline12, ReadBlock, SearchBlock, StateDot, TerminalBlock, WebBlock,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { WebBlockProps } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
@@ -107,15 +107,6 @@ export interface ToolRowProps {
|
||||
inspect?: (() => void) | undefined
|
||||
}
|
||||
|
||||
/** The Inspect pill's code glyph (user-supplied 16×16), fill follows text color. */
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
/** Leading-slot state substitution: the tool icon yields to the terminal state
|
||||
* semantic (error = red, interrupted = amber halo). Running keeps the icon —
|
||||
* the row sweep (CSS on data-state) carries the in-flight signal. */
|
||||
@@ -332,7 +323,7 @@ export function ToolRow({
|
||||
className={css.inspectButton}
|
||||
onClick={inspect}
|
||||
>
|
||||
<IconInspect />
|
||||
<IconInspectOutline12 />
|
||||
Inspect
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -17,7 +17,7 @@ import { useState, type KeyboardEvent } from 'react'
|
||||
import type { Context } from 'cordis'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
IconApiOutline14, IconChevronDownOutline14, StateDot, TerminalBlock,
|
||||
IconApiOutline14, IconChevronDownOutline14, IconInspectOutline12, StateDot, TerminalBlock,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ToolRowProps } from '../contract/slots.ts'
|
||||
@@ -153,9 +153,7 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }:
|
||||
)}
|
||||
{inspect !== undefined && (
|
||||
<button type="button" className={css.inspectButton} onClick={inspect}>
|
||||
<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>
|
||||
<IconInspectOutline12 />
|
||||
Inspect
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -750,6 +750,27 @@ export const IconSparkle16 = ({ size = 16, className }: IconProps) => (
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** inspect_outline_12 (shared tool-row trajectory affordance glyph) */
|
||||
export const IconInspectOutline12 = ({ size = 12, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} 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>
|
||||
)
|
||||
|
||||
/** 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 + three product glyphs outside those sets)', () => {
|
||||
expect(iconNames.length).toBe(66)
|
||||
})
|
||||
|
||||
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: a9506fe563b94fb4d1f9afd882216e023b0c2d13
|
||||
README.zh.md: 6af5d3eb8820dacc2ab569be8b830481dd45fb9a
|
||||
|
||||
@@ -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 a paired call/result slice in the current runtime window, never from the current catalog, so replay remains stable when installed skills or their descriptions change.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Skill reference text in the user prompt
|
||||
@@ -26,6 +30,7 @@ Append-only: the reference is part of a new user message appended after the reus
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Result-only history pages use the generic row** — keyed dispatch needs the paired call in the runtime window; pagination that leaves the call outside has no tool identity. This client presentation feature does not extend the history wire contract to recover it.
|
||||
- **Non-deterministic skill loading** — the reference is a collaboration cue, not a guarantee; the model may ignore it. The rework path when hit rate proves insufficient (a host-side `context/skill-reference` guidance package, or full-text injection) sits in the design ledger; the wire text shape would not change.
|
||||
- **First keystroke may race the prewarm** — the scope-birth warm launches the catalog fetch, but a menu opened before it settles shows no skill candidates for that keystroke. Accepted by design: skill references do not participate in enter adjudication, so nothing correctness-bearing waits on the catalog.
|
||||
- **Text is the truth** — the reference is plain draft text; a hand-typed identical token is the same reference. Chip visuals derive from the lexicon scan; no occurrence identity or position tracking (componentized chips are a ledger item).
|
||||
|
||||
@@ -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` 入口。该行的名称、生命周期和正文只派生自当前 runtime 窗口中已配对的调用/结果片段,绝不读取当前 skill 目录,因此即使已安装的 skill 或其描述发生变化,回放仍保持稳定。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 用户提示词中的 skill 引用文本
|
||||
@@ -26,6 +30,7 @@ skill(技能)引用 source 的浏览器端:把 `/` 触发的 `skill` sourc
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **仅含结果的 history 页使用通用行**:键控分派要求配对调用位于 runtime 窗口内;分页将调用留在窗口外时,结果没有工具身份。这项客户端呈现功能不会为了恢复该身份而扩展 history 协议契约。
|
||||
- **skill 加载具有非确定性**:引用是协作线索,不是保证;模型可能忽略它。针对命中率不足情况的返工路径(host 侧 `context/skill-reference` 引导包,或全文注入)记录在设计台账中;协议中的文本形态不会改变。
|
||||
- **首次击键可能与预热竞速**:scope 创建时的预热会启动目录拉取,但目录落定之前打开的菜单,在那次击键下不会显示 skill 候选。这是设计上接受的取舍:skill 引用不参与回车裁决,因此没有任何攸关正确性的环节等待目录。
|
||||
- **文本是唯一依据**:引用是普通的草稿文本;手动键入的相同 token 就是同一个引用。chip 视觉由 lexicon 扫描派生;没有 occurrence 身份或位置跟踪(组件化 chip 是台账事项)。
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
171
packages/client/ui-skill/src/client/SkillRow.tsx
Normal file
171
packages/client/ui-skill/src/client/SkillRow.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
// 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, IconInspectOutline12, 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 durable result blocks under the generic tool-row text contract.
|
||||
* Keep aligned with ui-conversation's contract/tool-call-model.ts `resultText`. */
|
||||
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 />
|
||||
}
|
||||
}
|
||||
|
||||
/** Leading disclosure slot: state icon at rest, chevron on hover or while open. */
|
||||
function disclosureLeading(state: SkillRowState, open: boolean, expandable: boolean): ReactNode {
|
||||
if (open) return <IconChevronDownOutline14 className={css.chevron} />
|
||||
const icon = leadingFor(state)
|
||||
if (!expandable) return icon
|
||||
return (
|
||||
<>
|
||||
<span className={css.iconIdle}>{icon}</span>
|
||||
<IconChevronDownOutline14 className={`${css.chevron} ${css.chevronHover}`} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 toggleExpand = (): void => {
|
||||
setExpanded(value => !value)
|
||||
}
|
||||
const toggleFromKeyboard = (event: KeyboardEvent<HTMLDivElement>): void => {
|
||||
if (!expandable || (event.key !== 'Enter' && event.key !== ' ')) return
|
||||
event.preventDefault()
|
||||
toggleExpand()
|
||||
}
|
||||
const disclosureProps = expandable ? {
|
||||
role: 'button' as const,
|
||||
tabIndex: 0,
|
||||
'aria-expanded': open,
|
||||
onClick: toggleExpand,
|
||||
onKeyDown: toggleFromKeyboard,
|
||||
} : {}
|
||||
const leading = disclosureLeading(model.state, open, expandable)
|
||||
return (
|
||||
<div className={css.card} data-tool="skill" data-state={model.state}>
|
||||
<div
|
||||
className={css.row}
|
||||
data-expandable={expandable || undefined}
|
||||
{...disclosureProps}
|
||||
>
|
||||
<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}>
|
||||
<IconInspectOutline12 />
|
||||
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>
|
||||
@@ -15,9 +15,10 @@ export const name = 'client-ui-skill-invariant'
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: a single slash-source registration whose disposal is
|
||||
* proven by the HMR-safety spec — it emits no cordis events and owns no
|
||||
* cross-plugin mutable state.
|
||||
* No runtime invariant: the slash source, locale dictionaries, and keyed
|
||||
* toolview are registry-owned registrations whose disposal is proven by the
|
||||
* HMR-safety spec. They emit no cordis events and own no cross-plugin mutable
|
||||
* state.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -13,9 +14,11 @@
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { SlotsService } 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 +26,33 @@ type ListResult =
|
||||
| { ok: false; error: { code: string; message: string; details: object } }
|
||||
type ListFn = (payload: object, signal?: AbortSignal) => Promise<{ result: ListResult }>
|
||||
|
||||
interface PresentationCapture {
|
||||
slots: SlotsService
|
||||
dictionaries: Array<{ namespace: string; dictionaries: unknown }>
|
||||
localeDisposed: boolean
|
||||
}
|
||||
|
||||
/** Provide the presentation registries and capture the plugin's registrations. */
|
||||
function providePresentation(ctx: Context): PresentationCapture {
|
||||
const slots = new SlotsService(ctx)
|
||||
slots.register({
|
||||
name: 'root',
|
||||
children: { 'conversation.chat.toolview': { kind: 'keyed', scope: 'session' } },
|
||||
} as never, () => null)
|
||||
const capture: PresentationCapture = {
|
||||
slots,
|
||||
dictionaries: [],
|
||||
localeDisposed: false,
|
||||
}
|
||||
ctx.provide('locale', {
|
||||
register(namespace: string, dictionaries: unknown) {
|
||||
capture.dictionaries.push({ namespace, dictionaries })
|
||||
return () => { capture.localeDisposed = true }
|
||||
},
|
||||
})
|
||||
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 +64,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 +96,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()
|
||||
const entry = presentation.slots.entries('conversation.chat.toolview')[0]
|
||||
expect(entry?.options).toMatchObject({ key: 'skill' })
|
||||
expect(entry?.locale).toBe('skill')
|
||||
expect(entry?.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 +134,7 @@ describe('apply', () => {
|
||||
ctx.provide('sessions', {})
|
||||
await ctx.plugin(SlashService).await()
|
||||
ctx.provide('connection', { api: { skills: { list: listOk(CATALOG) } } })
|
||||
const presentation = providePresentation(ctx)
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
const slash = ctx.get('slash') as SlashService
|
||||
@@ -88,6 +149,8 @@ describe('apply', () => {
|
||||
// …and fiber teardown releases it.
|
||||
await fiber.dispose()
|
||||
expect(() => slash.registerSource(rival)).not.toThrow()
|
||||
expect(presentation.slots.entries('conversation.chat.toolview')).toHaveLength(0)
|
||||
expect(presentation.localeDisposed).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
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: 'Skilldsh-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 加载失败SkillSkillError: 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 加载失败SkillSkillError: 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