Merge pull request #1754 from deepseek-harness/worktree/web-skill-tool-row

feat(web): add dedicated skill tool row
This commit is contained in:
Yichen Jiang
2026-08-07 15:45:24 +08:00
committed by GitHub
25 changed files with 939 additions and 36 deletions
@@ -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: 6583062f38b0e9cff059fa4477313ff6a5bdd2aa
2026-08-06-web-skill-tool-row.zh.md: 3d5c4b712896c2cf41df3ec913c597f7f791486c
@@ -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 a paired call/result slice in the current runtime window. 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. If pagination leaves the call outside the window, the result has no tool identity and remains on the generic fallback rather than extending the history wire contract. 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 when its call is in the runtime window, 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. A result-only history page intentionally uses the generic fallback; keeping this edge case generic preserves the existing history protocol and confines the feature to client presentation. 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` 入口仍保留在卡片下方。
该行的所有可见值均派生自当前 runtime 窗口中已配对的调用/结果片段。skill 名称来自已记录的 `name` 参数,指令来自持久化的结果内容;该行绝不关联当前 skill 目录来读取描述或提供方元数据。如果分页将调用留在窗口外,结果便没有工具身份,并继续使用通用后备路径,而不是扩展 history 协议契约。现有的 ACPAgent Client Protocol`skill-load` 记录经由真实的 Web 持久化与组合路径写入,用于无需密钥的交互和无障碍快照。
## 考虑过的替代方案
- 保留通用工具行,只添加一个 `skill` 颜色选择器,并将其放在 `ui-conversation` 中。该方案仍会保留多余的输入外层结构和通用展开体,也会让 conversation 包拥有特定领域的视觉规则。
- 在宿主工具渲染意图联合类型中添加新的 `skill` 值。键控客户端 slot 在调用位于 runtime 窗口内时已经能够识别该工具,因此新的跨边界呈现值只会增加协议和快照表层,却不会支持其他消费方。
- 导出 conversation 包的私有 `ToolRow` 组件供复用。客户端包刻意对外暴露契约而非跨包组件;导出该组件会使独立功能包耦合到 conversation 的实现细节。
## 后果
除了引用 source 的依赖外,`ui-skill` 现在还依赖公开的 conversation toolview 契约、locale 包、原语包和 React。它自行保留了一小份折叠展开行 chrome,因此未来的全局交互变更必须与 Bash 示例和 conversation 行同步更新这个注册方。
即使已安装的 skill 目录发生变化,冷回放仍保持确定性;在用户显式展开指令前,transcript 保持紧凑。仅含结果的 history 页有意使用通用后备路径;让这个边缘情况保持通用呈现,可以保留现有 history 协议,并将该功能限定在客户端呈现层。专用卡片有意显示工具完整封装的输出,而不是只提取 `<skill_instructions>`,从而原样保留模型实际收到的内容,也避免为 skill 结果格式再引入一个解析器。
+80
View File
@@ -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,45 @@
- 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 "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
+1
View File
@@ -63,6 +63,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/turn-tail-actions.e2e.ts",
"tests/goal-multi-turn-actions.e2e.ts",
"tests/chat-scroll-fixture.ts",
+9 -6
View File
@@ -467,11 +467,6 @@ flowchart TD
pkg_client_ui_layout --> pkg_client_ui_slots
pkg_client_ui_layout --> pkg_client_ui_theme
pkg_client_ui_layout --> pkg_invariants
pkg_client_ui_skill --> pkg_client_connection
pkg_client_ui_skill --> pkg_client_runtime
pkg_client_ui_skill --> pkg_client_ui_slash
pkg_client_ui_skill --> pkg_client_ui_slots
pkg_client_ui_skill --> pkg_invariants
pkg_code_runtime_worker --> pkg_code_runtime
pkg_code_runtime_worker --> pkg_invariants
pkg_code_runtime_worker --> pkg_session
@@ -858,6 +853,14 @@ flowchart TD
pkg_client_ui_goal --> pkg_client_ui_slots
pkg_client_ui_goal --> pkg_goal
pkg_client_ui_goal --> pkg_invariants
pkg_client_ui_skill --> pkg_client_connection
pkg_client_ui_skill --> pkg_client_locale
pkg_client_ui_skill --> pkg_client_runtime
pkg_client_ui_skill --> pkg_client_ui_conversation
pkg_client_ui_skill --> pkg_client_ui_primitives
pkg_client_ui_skill --> pkg_client_ui_slash
pkg_client_ui_skill --> pkg_client_ui_slots
pkg_client_ui_skill --> pkg_invariants
pkg_session_reference --> pkg_agent
pkg_session_reference --> pkg_compact
pkg_session_reference --> pkg_invariants
@@ -1191,7 +1194,6 @@ flowchart TD
| [`llm-replay`](../packages/support/llm-replay) | `support` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`app-boot`](../packages/ui/app-boot) | `ui` | [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`system-prompt`](../packages/core/system-prompt) |
| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) |
| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) |
| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) |
@@ -1268,6 +1270,7 @@ flowchart TD
| [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
| [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) |
| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) |
| [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
| [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) |
@@ -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 -2
View File
@@ -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
+5
View File
@@ -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).
+5
View File
@@ -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 是台账事项)。
+17 -3
View File
@@ -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,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>
)
}
+23 -3
View File
@@ -19,10 +19,24 @@
* not kill the prewarm other consumers will hit, so it carries its own
* abort (fired only on invalidation/teardown) while a candidates caller
* with an aborted signal just returns early.
*
* This browser half also owns the `skill` keyed toolview: a replay-stable
* accent row derived only from each logged call/result slice.
*/
import type { ConnectionHandle, SessionId, SkillEntry } from '@deepseek-ai/dsh-client-connection/client'
import type { ClientContext, ISessions } from '@deepseek-ai/dsh-client-runtime/client'
import type { SlashServiceContract, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
import { SkillRow } from './SkillRow.tsx'
import { en, NS, zh, type SkillKey } from './locales.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
/** The dedicated skill tool row's copy. */
skill: SkillKey
}
}
/** One session's catalog fetch: the shared promise plus its own abort handle. */
interface CatalogFetch {
@@ -32,14 +46,20 @@ interface CatalogFetch {
settled?: readonly SkillEntry[]
}
/** Required services: slash registry, routed sessions, and the wire face. */
export const inject = ['slash', 'connection', 'sessions']
/** Required services: reference source faces plus the tool-row and locale registries. */
export const inject = ['slash', 'connection', 'sessions', 'slots', 'locale']
/**
* Client plugin body: register the '/' skill source over the root wire face.
* Client plugin body: register the '/' source, dictionaries, and keyed tool row.
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-skill: dictionaries')
ctx.slots.inject('conversation.chat.toolview', () => ctx.slots.register(
{ name: 'conversation.chat.toolview', key: 'skill', locale: NS },
SkillRow,
))
const skills = (ctx.get('connection') as ConnectionHandle).api.skills
const sessions = ctx.get('sessions') as ISessions
// Session-keyed catalog cache; single-flight per key. Plugin-closure state:
@@ -0,0 +1,23 @@
/** `skill` namespace dictionaries for the dedicated tool row. */
/** Dictionary namespace owned by this plugin. */
export const NS = 'skill'
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'row.running': '正在加载 skill',
'row.failed': 'skill 加载失败',
'row.stopped': 'skill 加载已中止',
'row.instructions': '说明',
} satisfies Record<string, string>
/** The skill namespace key union. */
export type SkillKey = keyof typeof zh
/** English dictionary, checked complete against the zh key set. */
export const en = {
'row.running': 'Loading skill',
'row.failed': 'Skill load failed',
'row.stopped': 'Skill load stopped',
'row.instructions': 'Instructions',
} satisfies Record<SkillKey, string>
+4 -3
View File
@@ -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)
})
})
@@ -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')
})
})
+9
View File
@@ -14,9 +14,18 @@
{
"path": "../connection"
},
{
"path": "../locale"
},
{
"path": "../runtime"
},
{
"path": "../ui-conversation"
},
{
"path": "../ui-primitives"
},
{
"path": "../ui-slash"
},
+24
View File
@@ -2038,9 +2038,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
@@ -2050,9 +2062,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:
+1
View File
@@ -50,6 +50,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/turn-tail-actions.e2e.ts",
"apps/web/tests/goal-multi-turn-actions.e2e.ts",
"apps/web/tests/chat-scroll-fixture.ts",