diff --git a/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.i18n.yaml new file mode 100644 index 0000000000..8186444a8d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.md b/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.md new file mode 100644 index 0000000000..b1d76c411d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.md @@ -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 ``, preserving exactly what reached the model and avoiding a second parser for the skill result format. diff --git a/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.zh.md b/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.zh.md new file mode 100644 index 0000000000..c16a9b84d7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.zh.md @@ -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 结果格式再引入一个解析器。 diff --git a/apps/web/tests/skill-tool-row.e2e.ts b/apps/web/tests/skill-tool-row.e2e.ts new file mode 100644 index 0000000000..af6c941bcd --- /dev/null +++ b/apps/web/tests/skill-tool-row.e2e.ts @@ -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 + + 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('') + 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']) + }) +}) diff --git a/apps/web/tests/snapshots/skill-tool-row/ui.expected.md b/apps/web/tests/snapshots/skill-tool-row/ui.expected.md new file mode 100644 index 0000000000..7a51aae904 --- /dev/null +++ b/apps/web/tests/snapshots/skill-tool-row/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 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. Follow these snapshot-only instructions. Resolve referenced resources relative to this skill directory. " +- 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 diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index dd5fe879e7..9e395e49ad 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -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", diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index 71e647a3e9..b0b76e164b 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -750,6 +750,20 @@ export const IconSparkle16 = ({ size = 16, className }: IconProps) => ( ) +/** skill_outline_16 (skill tool-row glyph; document instructions + sparkle) */ +export const IconSkillOutline16 = ({ size = 16, className }: IconProps) => ( + + + + +) + /** ic_ds_question_outline_14 (figma extract): ring + question glyph. */ export const IconQuestionOutline14 = ({ size = 14, className }: IconProps) => ( diff --git a/packages/client/ui-primitives/tests/icons.spec.tsx b/packages/client/ui-primitives/tests/icons.spec.tsx index 9877b7df1f..92f0d3cc37 100644 --- a/packages/client/ui-primitives/tests/icons.spec.tsx +++ b/packages/client/ui-primitives/tests/icons.spec.tsx @@ -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) => { diff --git a/packages/client/ui-skill/README.i18n.yaml b/packages/client/ui-skill/README.i18n.yaml index 059a5d8986..d23f68ee85 100644 --- a/packages/client/ui-skill/README.i18n.yaml +++ b/packages/client/ui-skill/README.i18n.yaml @@ -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 diff --git a/packages/client/ui-skill/README.md b/packages/client/ui-skill/README.md index fc83ae47dc..2280c9302d 100644 --- a/packages/client/ui-skill/README.md +++ b/packages/client/ui-skill/README.md @@ -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 diff --git a/packages/client/ui-skill/README.zh.md b/packages/client/ui-skill/README.zh.md index e103db812d..0e9344ff63 100644 --- a/packages/client/ui-skill/README.zh.md +++ b/packages/client/ui-skill/README.zh.md @@ -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 引用文本 diff --git a/packages/client/ui-skill/package.json b/packages/client/ui-skill/package.json index 20d61cdb53..c9d2dd4ed8 100644 --- a/packages/client/ui-skill/package.json +++ b/packages/client/ui-skill/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-skill", - "description": "Skill reference source: '/' menu candidates from skill.list, inserts name 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", diff --git a/packages/client/ui-skill/src/client/SkillRow.module.css b/packages/client/ui-skill/src/client/SkillRow.module.css new file mode 100644 index 0000000000..05c3388aa9 --- /dev/null +++ b/packages/client/ui-skill/src/client/SkillRow.module.css @@ -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; + } +} diff --git a/packages/client/ui-skill/src/client/SkillRow.tsx b/packages/client/ui-skill/src/client/SkillRow.tsx new file mode 100644 index 0000000000..c847678e4a --- /dev/null +++ b/packages/client/ui-skill/src/client/SkillRow.tsx @@ -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).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 + case 'stopped': return + default: return + } +} + +/** 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 ( + + + + ) +} + +/** + * 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): void => { + if (!expandable || (event.key !== 'Enter' && event.key !== ' ')) return + event.preventDefault() + toggleExpand() + } + const leading = open + ? + : expandable + ? ( + <> + {leadingFor(model.state)} + + + ) + : leadingFor(model.state) + return ( +
+
+ {leading} + {status !== null ? {status} : null} + Skill + + + {summary} + +
+ {open ? ( +
+
+
{t('row.instructions')}
+
{model.output}
+
+ {inspect !== undefined ? ( + + ) : null} +
+ ) : null} +
+ ) +} diff --git a/packages/client/ui-skill/src/client/index.ts b/packages/client/ui-skill/src/client/index.ts index c23f15b770..9631125801 100644 --- a/packages/client/ui-skill/src/client/index.ts +++ b/packages/client/ui-skill/src/client/index.ts @@ -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: diff --git a/packages/client/ui-skill/src/client/locales.ts b/packages/client/ui-skill/src/client/locales.ts new file mode 100644 index 0000000000..53746397bc --- /dev/null +++ b/packages/client/ui-skill/src/client/locales.ts @@ -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 + +/** 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 diff --git a/packages/client/ui-skill/tests/browser-plugin.spec.ts b/packages/client/ui-skill/tests/browser-plugin.spec.ts index 72a7d6f7a6..3febb36efb 100644 --- a/packages/client/ui-skill/tests/browser-plugin.spec.ts +++ b/packages/client/ui-skill/tests/browser-plugin.spec.ts @@ -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 diff --git a/packages/client/ui-skill/tests/skill-row.spec.tsx b/packages/client/ui-skill/tests/skill-row.spec.tsx new file mode 100644 index 0000000000..2dacf0a036 --- /dev/null +++ b/packages/client/ui-skill/tests/skill-row.spec.tsx @@ -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[0] + +const t: SkillRowProps['t'] = makeTranslate(zh, commonZh) + +afterEach(cleanup) + +function settled(over: Partial = {}): 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() + 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() + 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() + 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() + 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() + expect(stoppedView.container.textContent).toContain('skill 加载已中止') + expect(stoppedView.container.querySelector('[data-state="warning"]')).not.toBeNull() + cleanup() + + const structuredView = render() + fireEvent.click(screen.getByRole('button')) + expect(structuredView.container.textContent).toContain('"type": "reasoning"') + cleanup() + + render() + 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() + expect(invalid.container.textContent).toContain('{"name":') + cleanup() + + const scalar = render() + expect(scalar.container.textContent).toContain('"raw-name"') + cleanup() + + const emptyName = render() + expect(emptyName.container.textContent).toContain('{"name":""}') + cleanup() + + const blank = render() + expect(blank.container.textContent).toContain('call-skill') + expect(blank.container.querySelector('[role="button"]')).toBeNull() + expect(blank.container.textContent).not.toContain('正在加载 skill') + }) +}) diff --git a/packages/client/ui-skill/tsconfig.json b/packages/client/ui-skill/tsconfig.json index 318a44906a..f83486aa36 100644 --- a/packages/client/ui-skill/tsconfig.json +++ b/packages/client/ui-skill/tsconfig.json @@ -14,9 +14,18 @@ { "path": "../connection" }, + { + "path": "../locale" + }, { "path": "../runtime" }, + { + "path": "../ui-conversation" + }, + { + "path": "../ui-primitives" + }, { "path": "../ui-slash" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 34c8d60cf9..09d0384667 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/tsconfig.host.json b/tsconfig.host.json index 4fcf71b680..1a0f4698f7 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -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",