feat(web): add dedicated skill tool row
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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 .agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.md
|
||||
2026-08-06-web-skill-tool-row.md: b1d76c411d7ccc839616ddcce9fee18716489bf5
|
||||
2026-08-06-web-skill-tool-row.zh.md: c16a9b84d75c641b0fdd8778ff56c331c2c81546
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note: Web skill tool row
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-06-web-skill-tool-row.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The Web transcript renders `skill` calls through the generic fallback row, so a loaded instruction set looks like an unknown tool call even though Skill is a first-class product concept. The generic row also exposes the JSON argument envelope beside the result, adding noise around the one identity users need: the loaded skill name.
|
||||
|
||||
## Decision
|
||||
|
||||
`ui-skill` registers a component under the existing `conversation.chat.toolview` keyed slot with key `skill`. The component owns its row chrome from the public `ToolRowProps` contract, matching the independent registrant posture used by the Bash sample instead of importing conversation-private components.
|
||||
|
||||
The collapsed row uses a 16-pixel document-and-sparkle glyph and the Bash row's neutral hierarchy: tertiary glyph, secondary `Skill` title, caption separator, and tertiary skill name. Running, failed, and interrupted calls retain the transcript's shimmer, error dot and first-line summary, and warning dot semantics. A settled call expands through the whole summary row into a 260-pixel bounded `Instructions` card containing the exact durable result text; the existing trajectory `Inspect` handoff remains available below the card.
|
||||
|
||||
The row derives every visible value from the logged call/result slice. It reads the skill name from the recorded `name` argument and the instructions from durable result content, and never joins the current skill catalog for descriptions or provider metadata. The existing ACP `skill-load` recording is seeded through the real Web persistence and composition path for a keyless interaction and accessibility snapshot.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- Keep the generic tool row and add only a `skill` color selector in `ui-conversation`. This leaves the redundant input envelope and generic expanded body in place, and makes the conversation package own a domain-specific visual rule.
|
||||
- Add a new `skill` value to the host tool render-intent union. The keyed client slot already identifies this tool without changing the wire contract, so a new cross-boundary presentation value adds protocol and snapshot surface without enabling another consumer.
|
||||
- Export the conversation package's private `ToolRow` component for reuse. Client packages intentionally expose contracts rather than cross-package components; exporting it would couple independent feature packages to conversation implementation details.
|
||||
|
||||
## Consequences
|
||||
|
||||
`ui-skill` now depends on the public conversation toolview contract, locale and primitive packages, and React in addition to its reference-source dependencies. It owns a small copy of the disclosure-row chrome, so future global interaction changes must update this registrant alongside the Bash sample and conversation rows.
|
||||
|
||||
Cold replay stays deterministic when the installed skill catalog changes, and the transcript remains compact until instructions are explicitly expanded. The dedicated card intentionally shows the tool's complete framed output rather than extracting only `<skill_instructions>`, preserving exactly what reached the model and avoiding a second parser for the skill result format.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note: Web skill 工具行
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-06-web-skill-tool-row.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
Web transcript(文本记录)通过通用后备行渲染 `skill` 调用,使已加载的指令集看起来像一次未知工具调用,尽管 Skill(技能)已是产品中的一等概念。通用行还会在结果旁暴露 JSON 参数的外层结构,围绕用户真正需要的唯一标识增加了噪声:已加载的 skill 名称。
|
||||
|
||||
## 决策
|
||||
|
||||
`ui-skill` 在现有的 `conversation.chat.toolview` 键控 slot 下注册 key 为 `skill` 的组件。该组件基于公开的 `ToolRowProps` 契约自行实现行 chrome,沿用 Bash 示例的独立注册方姿态,而不导入 conversation 私有组件。
|
||||
|
||||
收起的行使用 16 像素的文档与闪光组合图标,并沿用 Bash 行的中性色层级:图标采用三级色,`Skill` 标题采用二级色,分隔符采用 caption 色,skill 名称采用三级色。运行、失败和中断调用分别沿用 transcript 的扫光、错误状态点加首行摘要,以及警告状态点语义。已结算调用可以通过整个摘要行展开一个高度上限为 260 像素的 `Instructions` 卡片,其中原样呈现持久化结果文本;用于跳转至 trajectory 的现有 `Inspect` 入口仍保留在卡片下方。
|
||||
|
||||
该行的所有可见值均派生自已记录的调用/结果片段。skill 名称来自已记录的 `name` 参数,指令来自持久化的结果内容;该行绝不关联当前 skill 目录来读取描述或提供方元数据。现有的 ACP(Agent Client Protocol)`skill-load` 记录经由真实的 Web 持久化与组合路径写入,用于无需密钥的交互和无障碍快照。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
- 保留通用工具行,只添加一个 `skill` 颜色选择器,并将其放在 `ui-conversation` 中。该方案仍会保留多余的输入外层结构和通用展开体,也会让 conversation 包拥有特定领域的视觉规则。
|
||||
- 在宿主工具渲染意图联合类型中添加新的 `skill` 值。键控客户端 slot 无需更改协议契约即可识别该工具,因此新的跨边界呈现值只会增加协议与快照表层,却没有为其他消费方提供新能力。
|
||||
- 导出 conversation 包的私有 `ToolRow` 组件供复用。客户端包刻意对外暴露契约而非跨包组件;导出该组件会使独立功能包耦合到 conversation 的实现细节。
|
||||
|
||||
## 后果
|
||||
|
||||
除了引用 source 的依赖外,`ui-skill` 现在还依赖公开的 conversation toolview 契约、locale 包、原语包和 React。它自行保留了一小份折叠展开行 chrome,因此未来的全局交互变更必须与 Bash 示例和 conversation 行同步更新这个注册方。
|
||||
|
||||
即使已安装的 skill 目录发生变化,冷回放仍具有确定性;在用户显式展开指令前,transcript 保持紧凑。专用卡片有意显示工具完整封装的输出,而不是只提取 `<skill_instructions>`,从而原样保留模型实际收到的内容,也避免为 skill 结果格式再引入一个解析器。
|
||||
@@ -0,0 +1,80 @@
|
||||
// Web e2e scenario: the real skill-load recording, seeded cold through the
|
||||
// persistence seam, renders through ui-skill's keyed toolview without a model
|
||||
// call. The disclosure proves replay-stable naming and exact durable output.
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const FIXTURE = fileURLToPath(new URL('../../../examples/acp-agent/tests/snapshots/skill-load/session.jsonl', import.meta.url))
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/skill-tool-row', import.meta.url))
|
||||
const UI_EXPECTED = fileURLToPath(new URL('./snapshots/skill-tool-row/ui.expected.md', import.meta.url))
|
||||
const MODE = webSnapshotMode()
|
||||
const SEED_ID = 'skill-tool-row-web-e2e'
|
||||
const PROMPT = 'Load the snapshot-skill skill with the skill tool, then reply DONE.'
|
||||
|
||||
describe.skipIf(MODE === 'record')('web e2e: dedicated Skill tool row', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
const fixture = await readFile(FIXTURE, 'utf8')
|
||||
expect(fixtureUserPrompts(fixture)).toEqual([PROMPT])
|
||||
scaffold = await launchWebScaffold({})
|
||||
await seedSession(scaffold, fixture, SEED_ID)
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
|
||||
const groupRow = page.locator('[role="treeitem"]').first()
|
||||
await groupRow.waitFor({ timeout: 15_000 })
|
||||
await groupRow.click()
|
||||
const sessionRow = page.locator('[role="treeitem"]').nth(1)
|
||||
await sessionRow.waitFor({ timeout: 10_000 })
|
||||
await sessionRow.click()
|
||||
await page.locator('[data-tool="skill"]').waitFor({ timeout: 15_000 })
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('expands the loaded skill to its exact recorded instructions', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-skill-tool-row'))
|
||||
const call = page.locator('[data-tool="skill"]')
|
||||
const row = call.getByRole('button', { name: 'Skill snapshot-skill' })
|
||||
await expect.poll(() => row.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(await call.getByText('snapshot-skill', { exact: true }).count()).toBe(1)
|
||||
|
||||
await row.click()
|
||||
await expect.poll(() => row.getAttribute('aria-expanded')).toBe('true')
|
||||
await call.getByText('Instructions', { exact: true }).waitFor()
|
||||
const output = call.locator('pre')
|
||||
await output.waitFor()
|
||||
expect(await output.textContent()).toContain('<skill_content name="snapshot-skill">')
|
||||
expect(await output.textContent()).toContain('Follow these snapshot-only instructions.')
|
||||
expect(await output.evaluate(element => getComputedStyle(element.parentElement!).maxHeight)).toBe('260px')
|
||||
|
||||
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
|
||||
.replace(/\b\d{1,2}\/\d{1,2}(?= \{\{clock\}\})/g, '{{date}}')
|
||||
.split(SEED_ID).join('{{seededId}}')
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('keeps its snapshot inventory closed', async () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Load the snapshot-skill skill with" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- text: Load the snapshot-skill skill with the skill tool, then reply DONE. {{date}} {{clock}}
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- text: Available only on the last message of a completed turn
|
||||
- button "Context injection @deepseek-ai/dsh-system-prompt":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection @deepseek-ai/dsh-system-prompt
|
||||
- button "Context injection skill-catalog":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection skill-catalog
|
||||
- button "Think Load the requested skill.":
|
||||
- img
|
||||
- img
|
||||
- text: Think Load the requested skill.
|
||||
- button "Skill snapshot-skill" [expanded]:
|
||||
- img
|
||||
- text: Skill snapshot-skill
|
||||
- region "Instructions": "Instructions <skill_content name=\"snapshot-skill\"> <skill_resources> Base directory for this skill: {{cwd}}/.dsh/skills/snapshot-skill Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed. </skill_resources> <skill_instructions> Follow these snapshot-only instructions. Resolve referenced resources relative to this skill directory. </skill_instructions> </skill_content>"
|
||||
- button "Inspect"
|
||||
- button "Think The skill is loaded.":
|
||||
- img
|
||||
- img
|
||||
- text: Think The skill is loaded.
|
||||
- paragraph: DONE
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- text: {{date}} {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model":
|
||||
- text: Select model
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 280 tok · Output 30 tok
|
||||
@@ -62,6 +62,7 @@
|
||||
"tests/startup-auto-selection.e2e.ts",
|
||||
"tests/subagent-conversation.e2e.ts",
|
||||
"tests/bash-abort-row.e2e.ts",
|
||||
"tests/skill-tool-row.e2e.ts",
|
||||
"tests/chat-scroll-fixture.ts",
|
||||
"tests/chat-scroll-contract.e2e.ts",
|
||||
"tests/chat-long-interactions.e2e.ts",
|
||||
|
||||
@@ -750,6 +750,20 @@ export const IconSparkle16 = ({ size = 16, className }: IconProps) => (
|
||||
</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 + two hand-authored product glyphs)', () => {
|
||||
expect(iconNames.length).toBe(65)
|
||||
})
|
||||
|
||||
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: 2280c9302dbc46cff723752f88c47940f98417d5
|
||||
README.zh.md: 0e9344ff63139f77461b02b48e18b0e94e54c223
|
||||
@@ -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 the logged call/result slice, never from the current catalog, so cold replay remains stable even when installed skills or their descriptions change.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Skill reference text in the user prompt
|
||||
|
||||
@@ -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` 入口。该行的名称、生命周期和正文只派生自已记录的调用/结果片段,绝不读取当前 skill 目录,因此即使已安装的 skill 或其描述发生变化,冷回放仍保持稳定。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 用户提示词中的 skill 引用文本
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -14,9 +14,18 @@
|
||||
{
|
||||
"path": "../connection"
|
||||
},
|
||||
{
|
||||
"path": "../locale"
|
||||
},
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
{
|
||||
"path": "../ui-conversation"
|
||||
},
|
||||
{
|
||||
"path": "../ui-primitives"
|
||||
},
|
||||
{
|
||||
"path": "../ui-slash"
|
||||
},
|
||||
|
||||
Generated
+24
@@ -1942,9 +1942,21 @@ importers:
|
||||
'@deepseek-ai/dsh-client-connection':
|
||||
specifier: workspace:^
|
||||
version: link:../connection
|
||||
'@deepseek-ai/dsh-client-locale':
|
||||
specifier: workspace:^
|
||||
version: link:../locale
|
||||
'@deepseek-ai/dsh-client-runtime':
|
||||
specifier: workspace:^
|
||||
version: link:../runtime
|
||||
'@deepseek-ai/dsh-client-test-runtime':
|
||||
specifier: workspace:^
|
||||
version: link:../test-runtime
|
||||
'@deepseek-ai/dsh-client-ui-conversation':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-conversation
|
||||
'@deepseek-ai/dsh-client-ui-primitives':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-primitives
|
||||
'@deepseek-ai/dsh-client-ui-slash':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-slash
|
||||
@@ -1954,9 +1966,21 @@ importers:
|
||||
'@deepseek-ai/dsh-invariants':
|
||||
specifier: workspace:^
|
||||
version: link:../../support/invariants
|
||||
'@testing-library/react':
|
||||
specifier: ^16.1.0
|
||||
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@types/react':
|
||||
specifier: ~18.3.1
|
||||
version: 18.3.31
|
||||
cordis:
|
||||
specifier: ^4.0.0-rc.7
|
||||
version: link:../../../vendor/cordis
|
||||
react:
|
||||
specifier: ^18.2.0
|
||||
version: 18.3.1
|
||||
react-dom:
|
||||
specifier: ^18.2.0
|
||||
version: 18.3.1(react@18.3.1)
|
||||
|
||||
packages/client/ui-slash:
|
||||
dependencies:
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
"apps/web/tests/startup-auto-selection.e2e.ts",
|
||||
"apps/web/tests/subagent-conversation.e2e.ts",
|
||||
"apps/web/tests/bash-abort-row.e2e.ts",
|
||||
"apps/web/tests/skill-tool-row.e2e.ts",
|
||||
"apps/web/tests/chat-scroll-fixture.ts",
|
||||
"apps/web/tests/chat-scroll-contract.e2e.ts",
|
||||
"apps/web/tests/chat-long-interactions.e2e.ts",
|
||||
|
||||
Reference in New Issue
Block a user