From 594305ce19765086472c6ef95c09c3f2a70ce37c Mon Sep 17 00:00:00 2001 From: gengruilin Date: Tue, 8 Sep 2026 23:33:23 +0800 Subject: [PATCH 01/20] fix(mcp): reject repeated tool discovery cursors Refs: https://github.com/deepseek-ai/deepseek-harness/discussions/3660 --- .../2026-07-07-mcp-client-plugin.i18n.yaml | 4 +- .../feature/2026-07-07-mcp-client-plugin.md | 4 +- .../2026-07-07-mcp-client-plugin.zh.md | 4 +- .../expected/mcp-pagination/stderr-cause.txt | 1 + .../tests/mcp-pagination.expected.e2e.ts | 30 ++++++++++++ packages/mcp/mcp-client/README.i18n.yaml | 4 +- packages/mcp/mcp-client/README.md | 1 + packages/mcp/mcp-client/README.zh.md | 1 + packages/mcp/mcp-client/src/tools.ts | 13 ++++- packages/mcp/mcp-client/tests/apply.spec.ts | 47 +++++++++++++++++++ .../tests/fixtures/repeated-cursor-server.ts | 18 +++++++ .../tests/fixtures/repeated-cursor.patch.yml | 17 +++++++ .../mcp/mcp-client/tests/mcp-client.spec.ts | 44 +++++++++++++++++ 13 files changed, 180 insertions(+), 8 deletions(-) create mode 100644 apps/cli/tests/profiles/headless/tests/expected/mcp-pagination/stderr-cause.txt create mode 100644 apps/cli/tests/profiles/headless/tests/mcp-pagination.expected.e2e.ts create mode 100644 packages/mcp/mcp-client/tests/fixtures/repeated-cursor-server.ts create mode 100644 packages/mcp/mcp-client/tests/fixtures/repeated-cursor.patch.yml diff --git a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml index 67227acc29..c5435129be 100644 --- a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.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 .agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md -2026-07-07-mcp-client-plugin.md: 1c451e14ea2a7c494454956ce9bb09d97d4e1e1d -2026-07-07-mcp-client-plugin.zh.md: 78a086d6c218a254e850777fe4024973e8848e17 +2026-07-07-mcp-client-plugin.md: 91c1bf428973eae7935e9c2a14dda7028a734313 +2026-07-07-mcp-client-plugin.zh.md: c0c2dbae5b854734240f8035edfc8d0b5a33408f diff --git a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md index 1c451e14ea..91c1bf4289 100644 --- a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md +++ b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md @@ -96,12 +96,14 @@ Every MCP tool has two names: This server-qualified shape is the de-facto standard among multi-server agent clients — every surveyed end-user product qualifies MCP tools by server ([Claude Code](https://code.claude.com/docs/en/agent-sdk/mcp#tool-naming-convention) `mcp__github__list_issues`, [Codex](https://openai.com/index/unrolling-the-codex-agent-loop/) `mcp__weather__get-forecast`, [Gemini CLI](https://geminicli.com/docs/tools/mcp-server/#3-tool-naming-and-namespaces), [VS Code](https://github.com/microsoft/vscode/blob/ab9ec62c6a61e429a9abd612ff220c3f4834c9ea/src/vs/workbench/contrib/mcp/common/mcpServer.ts#L217-L260), [Cline](https://github.com/cline/cline/blob/52fdbb1d72f7324a28142a7ba7678d4b53c902f4/sdk/packages/core/src/extensions/mcp/name-transform.ts#L20-L35), [Roo Code](https://github.com/RooCodeInc/Roo-Code/blob/b867ec9145750d0ae1ff7f02d35406e9bf2a0b16/src/utils/mcp-name.ts#L117-L140), [Goose](https://github.com/block/goose/blob/b3a012cbdde854b0fe14f95b1c48543bf6517c0a/crates/goose/src/agents/extension_manager.rs#L1391-L1441), [OpenCode](https://github.com/anomalyco/opencode/blob/d199b1bff90282a4f9cd6251b5fc7b16875a52f6/packages/opencode/src/mcp/catalog.ts#L117-L120)); the exact `mcp____` spelling follows Claude Code and Codex. The `mcp__` marker keeps MCP registrations out of the native tools' namespace and gives permission/telemetry rules a stable shape (`mcp__*`, `mcp__github__*`). -1. On connect: drain `client.listTools()` pagination, derive every tool's `publicName`, then register each as a raw `ToolDefinition` via `ctx.tools.register()`. The MCP JSON Schema and description pass through unchanged (no `defineTool` DSL conversion); only the model-facing `name` is replaced. +1. On connect: drain uncached `tools/list` pagination, derive every tool's `publicName`, then register each as a raw `ToolDefinition` via `ctx.tools.register()`. The MCP JSON Schema and description pass through unchanged (no `defineTool` DSL conversion); only the model-facing `name` is replaced. 2. Listen for `notifications/tools/list_changed` → re-run the same sync (dispose previous generation, register new). Deterministic names mean unchanged tools keep their names across re-syncs. 3. The executor closes over `rawName`; the public name is never sent to the server and never parsed to recover the raw name. 4. No `presentCall`/`presentResult` — UI consumers use the provider-neutral generic-card fallback. 5. Tools are transparent in the system prompt — no "[via MCP]" annotation beyond the name itself. +Each synchronization rejects a repeated non-empty continuation cursor before requesting another page, retaining the previous tool generation. Empty pages cannot establish progress through tool-name uniqueness, so cursor history also detects cycles spanning several pages ([reported failure](https://github.com/deepseek-ai/deepseek-harness/discussions/3660)). Cursor history belongs to one synchronization: a later update may reuse the same cursors. Focused bridge and lifecycle tests cover cycle rejection, retained callable tools, strict startup failure, and notification recovery. This detects repeated cursors; it does not bound a server that continually returns distinct cursors. + ### Public name normalization MCP allows tool names up to 128 characters including `.`; the DeepSeek function-name contract allows `[A-Za-z0-9_-]` and at most 64. Public names are normalized deterministically: invalid characters become `_`, and when replacement or truncation changed the name, a 12-hex-char SHA-256 hash of the `(serverName, rawName)` identity is appended so distinct MCP identities can never collapse into the same public name: diff --git a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md index 78a086d6c2..c0c2dbae5b 100644 --- a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md +++ b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md @@ -96,12 +96,14 @@ type Config = StdioConfig | StreamableHttpConfig 这种按服务器限定的形式是多服务器 agent 客户端事实上的标准——所有被调研的终端用户产品都按服务器限定 MCP 工具名([Claude Code](https://code.claude.com/docs/en/agent-sdk/mcp#tool-naming-convention) `mcp__github__list_issues`、[Codex](https://openai.com/index/unrolling-the-codex-agent-loop/) `mcp__weather__get-forecast`、[Gemini CLI](https://geminicli.com/docs/tools/mcp-server/#3-tool-naming-and-namespaces)、[VS Code](https://github.com/microsoft/vscode/blob/ab9ec62c6a61e429a9abd612ff220c3f4834c9ea/src/vs/workbench/contrib/mcp/common/mcpServer.ts#L217-L260)、[Cline](https://github.com/cline/cline/blob/52fdbb1d72f7324a28142a7ba7678d4b53c902f4/sdk/packages/core/src/extensions/mcp/name-transform.ts#L20-L35)、[Roo Code](https://github.com/RooCodeInc/Roo-Code/blob/b867ec9145750d0ae1ff7f02d35406e9bf2a0b16/src/utils/mcp-name.ts#L117-L140)、[Goose](https://github.com/block/goose/blob/b3a012cbdde854b0fe14f95b1c48543bf6517c0a/crates/goose/src/agents/extension_manager.rs#L1391-L1441)、[OpenCode](https://github.com/anomalyco/opencode/blob/d199b1bff90282a4f9cd6251b5fc7b16875a52f6/packages/opencode/src/mcp/catalog.ts#L117-L120));`mcp____` 的拼写方式与 Claude Code 和 Codex 一致。`mcp__` 前缀将 MCP 注册与原生工具的命名空间隔离,并为权限/遥测规则提供稳定的匹配模式(`mcp__*`、`mcp__github__*`)。 -1. 连接时:遍历 `client.listTools()` 的分页结果,推导每个工具的 `publicName`,然后通过 `ctx.tools.register()` 将其注册为原始 `ToolDefinition`。MCP 的 JSON Schema 和描述原样透传(不做 `defineTool` DSL 转换);仅替换模型可见的 `name`。 +1. 连接时:遍历未缓存的 `tools/list` 分页结果,推导每个工具的 `publicName`,然后通过 `ctx.tools.register()` 将其注册为原始 `ToolDefinition`。MCP 的 JSON Schema 和描述原样透传(不做 `defineTool` DSL 转换);仅替换模型可见的 `name`。 2. 监听 `notifications/tools/list_changed` → 重新执行同步(dispose 上一代、注册新一代)。确定性命名意味着未变化的工具在重新同步后保持原名。 3. 执行器闭包持有 `rawName`;公开名称永远不发送给服务器,也永远不被解析以还原原始名称。 4. 无 `presentCall`/`presentResult`——UI 消费方使用提供方无关的通用卡片兜底。 5. 工具在系统提示词中是透明的——除名称本身外不附加「[via MCP]」标注。 +每次同步在请求下一页前拒绝重复的非空续传游标,并保留上一代工具。空页无法通过工具名唯一性证明分页在前进,因此游标记录还会检测跨越多页的循环([问题报告](https://github.com/deepseek-ai/deepseek-harness/discussions/3660))。游标记录只属于一次同步:后续更新可以复用相同游标。定向桥接与生命周期测试覆盖循环拒绝、保留可调用工具、严格启动失败及通知恢复。此机制检测重复游标;它不限制持续返回不同游标的服务器。 + ### 公开名称规范化 MCP 允许工具名最长 128 字符且可包含 `.`;DeepSeek 的函数名约定允许 `[A-Za-z0-9_-]` 且最多 64 字符。公开名称按确定性规则规范化:非法字符替换为 `_`,当替换或截断改变了名称时,追加 `(serverName, rawName)` 标识的 12 位十六进制 SHA-256 hash,确保不同的 MCP 标识永远不会坍缩为同一个公开名称: diff --git a/apps/cli/tests/profiles/headless/tests/expected/mcp-pagination/stderr-cause.txt b/apps/cli/tests/profiles/headless/tests/expected/mcp-pagination/stderr-cause.txt new file mode 100644 index 0000000000..19a6f28bec --- /dev/null +++ b/apps/cli/tests/profiles/headless/tests/expected/mcp-pagination/stderr-cause.txt @@ -0,0 +1 @@ +Error: mcp-client(pagination-cycle): server repeated a tools/list continuation cursor — invalid tool list diff --git a/apps/cli/tests/profiles/headless/tests/mcp-pagination.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/mcp-pagination.expected.e2e.ts new file mode 100644 index 0000000000..e9e08bb302 --- /dev/null +++ b/apps/cli/tests/profiles/headless/tests/mcp-pagination.expected.e2e.ts @@ -0,0 +1,30 @@ +/** Startup diagnostics through the shipped headless profile and real MCP stdio transport. */ + +import { fileURLToPath } from 'node:url' +import { expect, it } from 'vitest' +import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' + +const fixtureRoot = new URL('../../../../../../packages/mcp/mcp-client/tests/fixtures/', import.meta.url) +const configPath = fileURLToPath(new URL('repeated-cursor.patch.yml', fixtureRoot)) +const expectedPath = fileURLToPath(new URL('./expected/mcp-pagination/stderr-cause.txt', import.meta.url)) + +it('reports a repeated MCP discovery cursor and exits before starting a turn', async () => { + const { stdout, stderr } = await runLoaderSmoke({ + label: 'MCP discovery pagination cycle', + tempDirPrefix: 'dsh-mcp-pagination-', + binScript: fileURLToPath(new URL('../../../../src/bin.ts', import.meta.url)), + libBinScript: fileURLToPath(new URL('../../../../lib/bin.js', import.meta.url)), + configPath, + binArgs: ['--profile', 'headless', '--patch', configPath, 'unreachable task'], + tsconfigPath: fileURLToPath(new URL('../../../../../../tsconfig.json', import.meta.url)), + expectedExitCode: 1, + env: { + DSH_MCP_PAGINATION_FIXTURE: fileURLToPath(new URL('repeated-cursor-server.ts', fixtureRoot)), + DSH_TELEMETRY_DISABLED: '1', + }, + }) + expect(stdout).toBe('') + expect(stderr).toContain('initial connection or tool synchronization failed') + const cause = stderr.split('\n').find(line => line.startsWith('Error: mcp-client(pagination-cycle):')) + await expect(`${cause}\n`).toMatchFileSnapshot(expectedPath) +}, LOADER_SMOKE_TEST_TIMEOUT_MS) diff --git a/packages/mcp/mcp-client/README.i18n.yaml b/packages/mcp/mcp-client/README.i18n.yaml index 370f6a1e7f..a3f68e19e5 100644 --- a/packages/mcp/mcp-client/README.i18n.yaml +++ b/packages/mcp/mcp-client/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/mcp/mcp-client/README.md -README.md: 112f477556d1b8ee2788c9b96251b6969c617f62 -README.zh.md: e642c7e130510cd717342a2e879c26396c77d55d +README.md: de646256888f1c271ad94a4c863338dfb02551e6 +README.zh.md: 2c71060ec309e823ff3456207881e27ab9aca553 diff --git a/packages/mcp/mcp-client/README.md b/packages/mcp/mcp-client/README.md index 112f477556..de64625688 100644 --- a/packages/mcp/mcp-client/README.md +++ b/packages/mcp/mcp-client/README.md @@ -76,6 +76,7 @@ The model sees each tool under a stable server-qualified name: `mcp____` namespace — the partial generation is @@ -149,6 +149,7 @@ export async function syncTools( ): Promise { // Phase 1: fetch and build the next generation without touching the registry. const definitions = new Map() + const seenCursors = new Set() let cursor: string | undefined do { const response = await listToolsUncached(client, cursor) @@ -172,6 +173,14 @@ export async function syncTools( )) } cursor = response.nextCursor + if (cursor) { + if (seenCursors.has(cursor)) { + throw new Error( + `mcp-client(${opts.serverName}): server repeated a tools/list continuation cursor — invalid tool list`, + ) + } + seenCursors.add(cursor) + } } while (cursor) // Phase 2: swap generations. diff --git a/packages/mcp/mcp-client/tests/apply.spec.ts b/packages/mcp/mcp-client/tests/apply.spec.ts index 1b1716e51b..5dee651d80 100644 --- a/packages/mcp/mcp-client/tests/apply.spec.ts +++ b/packages/mcp/mcp-client/tests/apply.spec.ts @@ -303,6 +303,28 @@ describe('apply (plugin lifecycle)', () => { expect(mockClose).toHaveBeenCalled() }) + it('rejects strict startup on a repeated discovery cursor and closes the client', async () => { + mockListTools + .mockResolvedValueOnce({ tools: [], nextCursor: 'cursor1' }) + .mockResolvedValueOnce({ tools: [], nextCursor: 'cursor1' }) + .mockRejectedValue(new Error('pagination continued after the repeated cursor')) + try { + await expect(apply(ctx, { + ...stdioConfig, + failOnStartupError: true, + reconnect: { enabled: false }, + })).rejects.toMatchObject({ + message: 'mcp-client(srv): initial connection or tool synchronization failed', + cause: new Error('mcp-client(srv): server repeated a tools/list continuation cursor — invalid tool list'), + }) + expect(mockListTools).toHaveBeenCalledTimes(2) + expect(mockClose).toHaveBeenCalledTimes(1) + expect(ctx.tools.schemas()).toEqual([]) + } finally { + await ctx.fiber.dispose() + } + }) + it('preserves strict startup registration when list_changed arrives before connect resolves', async () => { ctx.tools.register({ name: 'mcp__srv__remote', @@ -358,6 +380,31 @@ describe('apply (plugin lifecycle)', () => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() }) + it('continues notification synchronization after rejecting a pagination cycle', async () => { + try { + await apply(ctx, stdioConfig) + const handler = mockSetNotificationHandler.mock.calls[0]![1] as () => Promise + mockListTools + .mockResolvedValueOnce({ tools: [], nextCursor: 'cursor1' }) + .mockResolvedValueOnce({ tools: [], nextCursor: 'cursor1' }) + .mockRejectedValue(new Error('pagination continued after the repeated cursor')) + + await handler() + expect(mockListTools).toHaveBeenCalledTimes(3) + expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() + + mockListTools + .mockResolvedValueOnce({ tools: [], nextCursor: 'cursor1' }) + .mockResolvedValueOnce({ tools: [{ name: 'updated', inputSchema: { type: 'object' } }], nextCursor: undefined }) + await handler() + expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined() + expect(ctx.tools.get('mcp__srv__updated')).toBeDefined() + } finally { + await ctx.fiber.dispose() + } + expect(mockClose).toHaveBeenCalledTimes(1) + }) + it('effect disposer unregisters the CURRENT generation and closes client', async () => { // Load through ctx.plugin so ONLY the plugin's fiber is disposed — the // registry must survive to observe the unregistration. diff --git a/packages/mcp/mcp-client/tests/fixtures/repeated-cursor-server.ts b/packages/mcp/mcp-client/tests/fixtures/repeated-cursor-server.ts new file mode 100644 index 0000000000..d7c71f6394 --- /dev/null +++ b/packages/mcp/mcp-client/tests/fixtures/repeated-cursor-server.ts @@ -0,0 +1,18 @@ +/** MCP wire fixture returning a repeated discovery cursor over empty pages. */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' +import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js' + +const server = new McpServer( + { name: 'repeated-cursor', version: '1.0.0' }, + { capabilities: { tools: {} } }, +) +let requests = 0 +server.server.setRequestHandler(ListToolsRequestSchema, () => { + // Bound the broken-client path without relying on a test timeout to kill it. + if (++requests > 2) throw new Error('pagination continued after the repeated cursor') + return { tools: [], nextCursor: 'same-cursor' } +}) + +await server.connect(new StdioServerTransport()) diff --git a/packages/mcp/mcp-client/tests/fixtures/repeated-cursor.patch.yml b/packages/mcp/mcp-client/tests/fixtures/repeated-cursor.patch.yml new file mode 100644 index 0000000000..1a4d033b5b --- /dev/null +++ b/packages/mcp/mcp-client/tests/fixtures/repeated-cursor.patch.yml @@ -0,0 +1,17 @@ +- id: headless-startup + disabled: true + +- id: headless-runner + disabled: true + +- insert: + - id: mcp-pagination-cycle + name: '@deepseek-ai/dsh-mcp-client' + config: + transport: stdio + serverName: pagination-cycle + command: !!js process.execPath + args: !!js '[process.env.DSH_MCP_PAGINATION_FIXTURE]' + failOnStartupError: true + reconnect: + enabled: false diff --git a/packages/mcp/mcp-client/tests/mcp-client.spec.ts b/packages/mcp/mcp-client/tests/mcp-client.spec.ts index 605a31ece1..929331b98c 100644 --- a/packages/mcp/mcp-client/tests/mcp-client.spec.ts +++ b/packages/mcp/mcp-client/tests/mcp-client.spec.ts @@ -309,6 +309,50 @@ describe('syncTools', () => { expect(ctx.tools.get('mcp__srv__page2')).toBeDefined() }) + it.each([ + ['immediate', ['cursor1', 'cursor1']], + ['multi-page', ['cursor1', 'cursor2', 'cursor1']], + ])('rejects a pagination cycle through empty pages (%s)', async (_kind, cursors) => { + const client = createMockClient([]) + client.listTools.mockRejectedValue(new Error('pagination continued after the repeated cursor')) + for (const nextCursor of cursors) { + client.listTools.mockResolvedValueOnce({ tools: [], nextCursor }) + } + + await expect(syncTools(client as never, ctx, defaultOpts, new Map())) + .rejects.toThrow('mcp-client(srv): server repeated a tools/list continuation cursor — invalid tool list') + expect(client.listTools).toHaveBeenCalledTimes(cursors.length) + expect(ctx.tools.schemas()).toEqual([]) + }) + + it('keeps callable tools after a pagination cycle and accepts a later complete list', async () => { + const client = createMockClient([{ name: 'stable', inputSchema: { type: 'object' } }]) + const previous = await syncTools(client as never, ctx, defaultOpts, new Map()) + const stable = ctx.tools.get('mcp__srv__stable') + client.listTools + .mockResolvedValueOnce({ tools: [{ name: 'partial', inputSchema: { type: 'object' } }], nextCursor: 'cursor1' }) + .mockResolvedValueOnce({ tools: [], nextCursor: 'cursor1' }) + .mockRejectedValue(new Error('pagination continued after the repeated cursor')) + + await expect(syncTools(client as never, ctx, defaultOpts, previous)).rejects.toThrow(/repeated.*cursor/) + expect(client.listTools).toHaveBeenCalledTimes(3) + expect(ctx.tools.get('mcp__srv__stable')).toBe(stable) + expect(ctx.tools.get('mcp__srv__partial')).toBeUndefined() + const result = await ctx.tools.execute({ + signal: testToolSignal, callId: ToolCallId('pagination-retained'), name: 'mcp__srv__stable', arguments: {}, + }) + expect(result.isError).toBe(false) + expect(result.content).toEqual([{ type: 'text', text: 'ok' }]) + + client.listTools + .mockResolvedValueOnce({ tools: [], nextCursor: 'cursor1' }) + .mockResolvedValueOnce({ tools: [{ name: 'recovered', inputSchema: { type: 'object' } }], nextCursor: undefined }) + const recovered = await syncTools(client as never, ctx, defaultOpts, previous) + expect([...recovered.keys()]).toEqual(['mcp__srv__recovered']) + expect(ctx.tools.get('mcp__srv__stable')).toBeUndefined() + expect(client.listTools).toHaveBeenLastCalledWith({ cursor: 'cursor1' }) + }) + it('owns output validation independently of the SDK per-page cache', async () => { const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() serverTransport.onmessage = (message) => { From 4158a96e14ecb615e4f3d3f7c31c347bb45ee5dd Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 9 Sep 2026 11:27:02 +0800 Subject: [PATCH 02/20] feat(web): add native file actions to artifact cards --- ...8-present-workspace-source-files.i18n.yaml | 4 +- ...26-09-08-present-workspace-source-files.md | 2 + ...09-08-present-workspace-source-files.zh.md | 2 + apps/web/tests/present.e2e.ts | 20 +++- apps/web/tests/present.overlay.yml | 4 + docs/subsystems/session.i18n.yaml | 4 +- docs/subsystems/session.md | 8 +- docs/subsystems/session.zh.md | 8 +- .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 2 +- packages/api/session-controller/README.zh.md | 2 +- packages/api/session-controller/src/index.ts | 19 +++- packages/api/session-controller/src/types.ts | 2 + .../session-open-workspace-path.host.spec.ts | 32 +++++++ .../session-controller/tests/test-remote.ts | 2 + .../client/ui-deliverables/README.i18n.yaml | 4 +- packages/client/ui-deliverables/README.md | 6 +- packages/client/ui-deliverables/README.zh.md | 6 +- .../src/client/Deliverables.module.css | 28 ++++-- .../src/client/Deliverables.tsx | 43 +++++---- .../src/client/PresentedFileCard.tsx | 71 ++++++++++++++ .../ui-deliverables/src/client/index.ts | 6 +- .../ui-deliverables/src/client/locales.ts | 34 +++++++ .../src/client/present-open.ts | 57 ++++++++--- .../ui-deliverables/src/present-open.ts | 12 ++- .../client/ui-deliverables/src/presented.ts | 26 +++++ .../tests/present-open.client.spec.ts | 55 +++++++++++ .../tests/present-open.host.spec.ts | 27 +++++- .../tests/presented-file-card.client.spec.tsx | 95 +++++++++++++++++++ .../tests/produced-files.client.spec.tsx | 28 +++++- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- .../ui-primitives/src/Button.module.css | 3 + packages/client/ui-primitives/src/Button.tsx | 6 +- packages/client/ui-primitives/src/Menu.tsx | 23 ++++- .../extensions/tool-cordis/src/api-catalog.ts | 8 +- packages/util/native-command/README.i18n.yaml | 4 +- packages/util/native-command/README.md | 2 + packages/util/native-command/README.zh.md | 2 + packages/util/native-command/src/index.ts | 3 + .../util/native-command/src/path-opener.ts | 53 ++++++++++- .../native-command/tests/path-opener.spec.ts | 54 ++++++++++- snapshots/web/present/ui.expected.md | 17 +++- 44 files changed, 702 insertions(+), 94 deletions(-) create mode 100644 apps/web/tests/present.overlay.yml create mode 100644 packages/client/ui-deliverables/src/client/PresentedFileCard.tsx create mode 100644 packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx diff --git a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml index 9d992637b1..45b58e1d36 100644 --- a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.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 .agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md -2026-09-08-present-workspace-source-files.md: 6a8ebfcf1fd7be22a5bda5a209d0fdc3586931bb -2026-09-08-present-workspace-source-files.zh.md: d9361f2281eeb027e4c44b84c7f01af1639b66f2 +2026-09-08-present-workspace-source-files.md: 525a470d09cb5cf1d5bdbeef26b2414e31e900c9 +2026-09-08-present-workspace-source-files.zh.md: 3d969d0f4488b19f4c62d4d2e867b84519000f0a diff --git a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md index 6a8ebfcf1f..525a470d09 100644 --- a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md +++ b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md @@ -18,6 +18,8 @@ The tool remains an ordinary package with shared filesystem and tool error class An authenticated POST selects a declaration by viewed Session, event sequence, and original file index. The event carries no owning Session ID; relative paths in inherited history resolve against the viewed Session's workspace. The Host rechecks canonical workspace containment and regular-file existence before native opening. Route disposal cancels and awaits pending commands. The “Files changed” row lists successful file-tool mutations and retains its separate text-preview behavior. Its Chinese label is “本轮文件改动”; neither label implies final delivery. +File cards separate default-app opening from file-manager navigation. The Host selects the file in Finder or Explorer, or opens its containing folder through the default Linux file manager. Both actions resolve the same saved declaration and recheck workspace containment; neither accepts a browser-supplied replacement path. Host-derived desktop metadata keeps remote-browser labels and availability honest, and the route enforces the configured availability on each gesture. A split button preserves one-click default-app opening while keeping the folder action explicit. + ## Alternatives considered **Immutable attachment snapshots and editable temporary copies** preserve delivered versions after source edits or deletion, but make desktop edits diverge from workspace files and introduce retention work without a current product requirement. This decision supersedes the [snapshot-delivery design](../../archived/feature/2026-09-08-web-explicit-file-delivery.md). Neither a download endpoint nor a fallback copy remains; both require an explicit future product decision. diff --git a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md index d9361f2281..3d969d0f44 100644 --- a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md +++ b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md @@ -18,6 +18,8 @@ Status: implemented 经过认证的 POST 按当前查看的 Session、事件序号和原始文件索引选择声明。事件不携带所属 Session ID;继承历史中的相对路径按当前查看的 Session 工作区解析。Host 在原生打开前重新检查规范路径的工作区包含关系和普通文件是否存在。路由释放时取消并等待进行中的命令。“本轮文件改动”行列出成功的文件工具修改,并保留独立的文本预览行为。其英文标签为“Files changed”;两个标签均不表示最终交付。 +文件卡片区分默认应用打开与文件管理器导航。Host 在 Finder 或文件资源管理器中选中文件,或通过 Linux 默认文件管理器打开所在文件夹。两个操作都解析同一份已保存声明并重新检查工作区包含关系;均不接受浏览器提供的替代路径。来自 Host 的桌面信息使远程浏览器中的文案和可用性保持准确,路由在每次操作时执行配置的可用性检查。分段按钮保留一键默认应用打开,同时提供明确的文件夹操作。 + ## 考虑过的替代方案 **不可变附件快照和可编辑临时副本**可在源文件编辑或删除后保留交付版本,但会使桌面编辑与工作区文件分离,并在缺少当前产品需求时引入保留工作。本决策取代[快照交付设计](../../archived/feature/2026-09-08-web-explicit-file-delivery.md)。不保留下载端点或回退副本;两者都需要未来明确的产品决策。 diff --git a/apps/web/tests/present.e2e.ts b/apps/web/tests/present.e2e.ts index 52aee329fa..802e5eed1d 100644 --- a/apps/web/tests/present.e2e.ts +++ b/apps/web/tests/present.e2e.ts @@ -35,7 +35,7 @@ describe.skipIf(process.platform === 'win32' || release().toLowerCase().includes const events: SessionEvent[] = [] let nativeRoot: string | undefined let openLog: string - const opened = async (): Promise> => (await readFile(openLog, 'utf8')).split('\n').filter(Boolean).map(line => JSON.parse(line) as { path: string; content: string }) + const opened = async (): Promise> => (await readFile(openLog, 'utf8')).split('\n').filter(Boolean).map(line => JSON.parse(line) as { path: string; content: string | null; action: 'open' | 'reveal' }) const downloads: string[] = [] beforeAll(async () => { @@ -46,11 +46,14 @@ describe.skipIf(process.platform === 'win32' || release().toLowerCase().includes const command = process.platform === 'darwin' ? 'open' : 'xdg-open' await writeFile(join(nativeRoot, command), `#!${process.execPath} const fs = require('node:fs'); -fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path: process.argv[2], content: fs.readFileSync(process.argv[2], 'utf8') }) + '\\n'); +const path = process.argv[2] === '-R' ? process.argv[3] : process.argv[2]; +const action = process.argv[2] === '-R' || fs.statSync(path).isDirectory() ? 'reveal' : 'open'; +fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, content: action === 'open' ? fs.readFileSync(path, 'utf8') : null }) + '\\n'); `, { mode: 0o700 }) vi.stubEnv('PATH', `${nativeRoot}${delimiter}${process.env.PATH ?? ''}`) await mkdir(DIR, { recursive: true }) scaffold = await launchWebScaffold({ + extraOverlayPath: fileURLToPath(new URL('./present.overlay.yml', import.meta.url)), agentPresets: { roots: [], default: 'ptc' }, compareReplaySession: true, ...(MODE === 'record' ? {} : { replayFixture: FIXTURE }), }) @@ -118,7 +121,14 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path: process.arg } const row = page.locator('[data-presented-files-row]') await row.waitFor() - expect(await row.getByRole('button').count()).toBe(2) + expect(await row.getByRole('button').count()).toBe(4) + const beforeReveal = (await opened()).length + await row.getByRole('button', { name: 'More file actions for report.txt', exact: true }).click() + const revealResponse = page.waitForResponse(response => response.url().includes('action=reveal') && response.request().method() === 'POST') + await page.getByRole('menuitem', { name: process.platform === 'darwin' ? /Show in Finder/ : /Open containing folder/ }).click() + expect((await revealResponse).status()).toBe(204) + await expect.poll(opened).toHaveLength(beforeReveal + 1) + expect((await opened()).at(-1)).toEqual({ action: 'reveal', content: null, path: await realpath(process.platform === 'darwin' ? join(cwd, 'report.txt') : cwd) }) for (const [name, bytes] of [['report.txt', 'EDITED_REPORT\n'], ['说明.txt', 'EDITED_NOTE\n']] as const) { const count = (await opened()).length const response = page.waitForResponse(response => response.url().includes('/api/present.open?') && response.request().method() === 'POST') @@ -126,7 +136,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path: process.arg expect((await response).status()).toBe(204) await page.waitForFunction(() => document.querySelector('[data-presented-files-row] button:disabled') === null) expect(await opened()).toHaveLength(count + 1) - expect((await opened()).at(-1)).toEqual({ path: await realpath(join(cwd, name)), content: bytes }) + expect((await opened()).at(-1)).toEqual({ action: 'open', path: await realpath(join(cwd, name)), content: bytes }) } } const count = (await opened()).length @@ -135,7 +145,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path: process.arg await page.waitForFunction(() => document.querySelector('[data-presented-files-row] button:disabled') === null) expect((await openedResponse).status()).toBe(204) expect(await opened()).toHaveLength(count + 1) - expect((await opened()).at(-1)).toEqual({ path: await realpath(join(cwd, 'report.txt')), content: 'EDITED_REPORT\n' }) + expect((await opened()).at(-1)).toEqual({ action: 'open', path: await realpath(join(cwd, 'report.txt')), content: 'EDITED_REPORT\n' }) expect(downloads).toEqual([]) const response = await page.request.get(new URL(`/api/session.export?sessionId=${sessionId}`, scaffold.authenticatedUrl).href) expect(response.status()).toBe(200) diff --git a/apps/web/tests/present.overlay.yml b/apps/web/tests/present.overlay.yml new file mode 100644 index 0000000000..a39a66dad9 --- /dev/null +++ b/apps/web/tests/present.overlay.yml @@ -0,0 +1,4 @@ +# Native commands are owned fixtures; desktop availability must be identical in headless CI. +- id: session-controller + config: + nativeOpen: true diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 26fee2e323..e8789c3d41 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.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 docs/subsystems/session.md -session.md: de0930ea7effcba69bc1f9a4dd405ceda23919d7 -session.zh.md: ec15dbdec3a8887d8fa87c9698b8ad14699a534b +session.md: 8b4da0f7969fad652ac6ea2288746198f9813954 +session.zh.md: ae554ebaf620d5182804cf4604498d843dbeadaf diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index de0930ea7e..8b4da0f796 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -680,7 +680,7 @@ The backends that consume this contract are on [persistence.md](persistence.md). `ModelCatalog` is the Host-generation model directory returned by `session/modelCatalog`: it carries the deployment default, routable provider ids, successful provider groups, and isolated provider failures. It is not derived from one Session and remains separate from Session projections. -`SessionOpenWorkspacePathRequest` carries an absolute or workspace-resolved `path`. `SessionOpenWorkspacePathValue` confirms that the Host accepted the native handoff. A Session-aware Client resolves relative paths against its current Session cwd when known; the controller hands the path to the opener unchanged and reports invalid requests, cancellation, and opener failures through the Session Remote error vocabulary. +`SessionOpenWorkspacePathRequest` carries an absolute or workspace-resolved `path`; optional `action: "reveal"` selects file-manager navigation instead of default-application opening. `SessionOpenWorkspacePathValue` confirms that the Host accepted the native handoff. A Session-aware Client resolves relative paths against its current Session cwd when known; the controller hands the path to the opener unchanged and reports invalid requests, cancellation, and opener failures through the Session Remote error vocabulary. @@ -754,6 +754,12 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise @@ -758,6 +758,12 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise Promise + /** Native file-manager handoff. */ + readonly revealPath?: (path: string, signal: AbortSignal) => Promise /** Native handoff availability probe. */ readonly canOpenPath?: () => boolean } @@ -105,6 +108,7 @@ export class SessionController extends TypertRemoteService { private readonly history: SessionHistoryController private readonly listState: ApiSessionList private readonly openPath: (path: string, signal: AbortSignal) => Promise + private readonly revealPath: (path: string, signal: AbortSignal) => Promise private readonly canOpenPath: () => boolean private readonly promotions = new Set>() @@ -132,6 +136,7 @@ export class SessionController extends TypertRemoteService { this.history = new SessionHistoryController(ctx, (observation) => { this.promote(observation) }) this.listState = new ApiSessionList(ctx) this.openPath = internals.openPath ?? openNativePath + this.revealPath = internals.revealPath ?? revealNativePath this.canOpenPath = internals.canOpenPath ?? (() => config.nativeOpen ?? (internals.openPath !== undefined || canOpenNativePath())) ctx.plugin(SessionFileReferences) @@ -268,6 +273,15 @@ export class SessionController extends TypertRemoteService { return this.canOpenPath() } + /** + * Describe the serving desktop for authenticated file-action routes. + * @returns Host name, configured availability, and platform-specific file-manager behavior. + */ + workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | 'explorer' | 'directory' | null } { + const fileManager = nativeFileManager() + return { name: hostname(), available: fileManager !== null && this.canOpenPath(), fileManager } + } + /** * Open one path prepared by a Session-aware caller on the Host desktop. * @param request - path after best-effort Session workspace resolution. @@ -289,7 +303,8 @@ export class SessionController extends TypertRemoteService { } signal.throwIfAborted() try { - await this.openPath(request.path, signal) + if (request.action === 'reveal') await this.revealPath(request.path, signal) + else await this.openPath(request.path, signal) return { opened: true } } catch (error: unknown) { if (signal.aborted) throw new RemoteError('gateway/cancelled', 'path open was aborted', {}) diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index 607baed3cf..60f7a93e56 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -358,6 +358,8 @@ export interface SessionCancelValue { /** Request to open one path prepared by a Session-aware caller on the Host desktop. */ export interface SessionOpenWorkspacePathRequest { + /** File-manager navigation when requested; omission uses the default application. */ + readonly action?: 'reveal' /** Path after best-effort Session workspace resolution, in Host filesystem syntax. */ readonly path: string } diff --git a/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts b/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts index ee199ddcdd..eb789cb374 100644 --- a/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts +++ b/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts @@ -1,3 +1,4 @@ +import * as nativeCommand from '@deepseek-ai/dsh-native-command' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import SessionStore from '@deepseek-ai/dsh-session' @@ -138,3 +139,34 @@ describe('session/openWorkspacePath', () => { }) }) }) + + +it('reports Host file-manager metadata and dispatches reveal separately from default-app open', async () => { + const ctx = await context() + const revealPath = vi.fn(async (_path: string, _signal: AbortSignal) => {}) + const openPath = vi.fn(async (_path: string, _signal: AbortSignal) => {}) + const controller = createSessionTestController(ctx, { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', openPath, revealPath, + }) + try { + expect(controller.workspaceDesktop()).toMatchObject({ available: true, name: expect.any(String) as string }) + const signal = new AbortController().signal + await controller.openWorkspacePath({ path: '/workspace/report.txt', action: 'reveal' }, signal) + expect(revealPath).toHaveBeenCalledWith('/workspace/report.txt', signal) + expect(openPath).not.toHaveBeenCalled() + } finally { await ctx.fiber.dispose() } +}) + +it('uses the native reveal adapter without a test override and respects unsupported desktop metadata', async () => { + const ctx = await context() + const reveal = vi.spyOn(nativeCommand, 'revealNativePath').mockResolvedValue(undefined) + const manager = vi.spyOn(nativeCommand, 'nativeFileManager').mockReturnValue(null) + try { + const controller = createSessionTestController(ctx, { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', nativeOpen: true, + }) + expect(controller.workspaceDesktop()).toMatchObject({ available: false, fileManager: null }) + await controller.openWorkspacePath({ path: '/report.txt', action: 'reveal' }, new AbortController().signal) + expect(reveal).toHaveBeenCalledOnce() + } finally { manager.mockRestore(); reveal.mockRestore(); await ctx.fiber.dispose() } +}) diff --git a/packages/api/session-controller/tests/test-remote.ts b/packages/api/session-controller/tests/test-remote.ts index ac992baced..721288f7dc 100644 --- a/packages/api/session-controller/tests/test-remote.ts +++ b/packages/api/session-controller/tests/test-remote.ts @@ -91,6 +91,7 @@ export interface TestSessionRemoteDefaults { readonly nativeOpen?: boolean readonly saveDefaultModelSelection?: (selection: AgentModelSelection) => void | Promise readonly openPath?: (path: string, signal: AbortSignal) => Promise + readonly revealPath?: (path: string, signal: AbortSignal) => Promise readonly canOpenPath?: () => boolean } @@ -284,6 +285,7 @@ function installControllers( }, { ...defaults.openPath === undefined ? {} : { openPath: defaults.openPath }, + ...defaults.revealPath === undefined ? {} : { revealPath: defaults.revealPath }, ...defaults.canOpenPath === undefined ? {} : { canOpenPath: defaults.canOpenPath }, }, ) diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index a72595b84a..654494e779 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/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-deliverables/README.md -README.md: 4886a5817e6118ef76d5e7b49ed7fe3ed319650f -README.zh.md: 43c68d4563f9ec32bb15fdd3df9a2e21225ebc13 +README.md: 3196202a053ae35bb3055666a680c9d6cfc2a2f8 +README.zh.md: bb394ae756820d7b536ea9326c0837cf59a85a01 diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index 4886a5817e..3196202a05 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -30,9 +30,9 @@ Mount this plugin alongside `ui-conversation`; a finished turn then ends with th ### Explicit deliveries -The Web `standard`, `ptc`, and `cordis` presets expose `present` for final workspace files, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../fs/tool-present/README.md) owns file-count limits and Session declarations. The closing turn shows responsive cards with file names, types, descriptions, and buttons that open the source in the Host’s default application. Matching inline-code references open the same source files without starting a browser download. Repeated declaration of a path selects its latest description before the closing reply. +The Web `standard`, `ptc`, and `cordis` presets expose `present` for final workspace files, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../fs/tool-present/README.md) owns file-count limits and Session declarations. The closing turn shows responsive cards with file names, types, descriptions, workspace paths, and a split Open button. Its primary action opens the source in the Host’s default application; the menu offers Show in Finder on macOS, Show in File Explorer on Windows and WSL, or Open containing folder through the default Linux file manager. The menu names the serving Host, independently of the browser’s operating system. Matching inline-code references open the same source files without starting a browser download. Repeated declaration of a path selects its latest description before the closing reply. -The `present` tool row shows running, delivered, failed, or interrupted status; expanding a settled row reveals its recorded result. File cards include every delivered file. Opening shows progress, confirmation, or a retryable error on the card. It requires a desktop and a suitable default application on the serving Host; a remote browser does not open applications on its own device. +The `present` tool row shows running, delivered, failed, or interrupted status; expanding a settled row reveals its recorded result. File cards include every delivered file. Both actions share pending state and show progress, acknowledgement, or an action-specific retryable error. A missing desktop disables both actions; a failed desktop-information read offers Retry. It requires a desktop and a suitable default application on the serving Host; a remote browser does not open applications on its own device. ### The row @@ -52,7 +52,7 @@ The closing prose carries the same vocabulary: an inline-code token resolves by The Node half registers the static `ui:deliverable-file-references` system-prompt section asking the model to mention primary files from successful creation or modification calls and to write those and any other changed-file references as Markdown inline code. The browser half registers a wrapper around `ProducedFiles` and explicit deliveries into the chat view's `conversation.chat.turnTail` hole. `deliverablesDefinition` folds each Turn's successful first-party mutation calls into `DeliverablesTurnData` from the validated raw arguments of `write`, `edit`, and mutating `str_replace_editor` commands. Reads, deletes, unsupported tools, malformed calls, and failed results contribute nothing. A new mutation tool needs an explicit Client contribution before it joins the list. The package also provides the `chatFileMentions` service the chat view consults per closing message; composing the plugin out removes both surfaces and leaves the view's empty chain at zero cost. -Native opening uses an authenticated POST addressed by the viewed Session, event sequence, and original file index. The Host resolves the declaration against that Session’s workspace and checks the current file exists within it before launching the default application. Edits affect subsequent opens; deletion returns an error. No file-content copy or attachment is created. Plugin disposal cancels and awaits pending native-open requests. +Native opening uses an authenticated POST addressed by the viewed Session, event sequence, and original file index. The Host resolves the declaration against that Session’s workspace and checks the current file exists within it before launching either native action. The same configured desktop availability governs metadata and execution. Edits affect subsequent opens; deletion returns an error. No file-content copy or attachment is created. Plugin disposal cancels and awaits pending native-open requests. diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 43c68d4563..bb394ae756 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -30,9 +30,9 @@ kind: "package-reference" ### 显式交付 -Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交付最终工作区文件,包括通过 Bash 创建的文件。创建文件后,以 `files: [{ path, description? }]` 调用。[present 工具](../../fs/tool-present/README.zh.md)拥有文件数量限制和 Session 声明。收尾 turn 显示响应式卡片,包含文件名称、类型、说明和在 Host 默认应用中打开源文件的按钮。匹配的行内代码引用打开相同源文件,不触发浏览器下载。同一路径重复声明时,选择收尾回复之前最近一次的说明。 +Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交付最终工作区文件,包括通过 Bash 创建的文件。创建文件后,以 `files: [{ path, description? }]` 调用。[present 工具](../../fs/tool-present/README.zh.md)拥有文件数量限制和 Session 声明。收尾 turn 显示响应式卡片,包含文件名称、类型、说明、工作区路径和分段“打开”按钮。主按钮在 Host 默认应用中打开源文件;菜单在 macOS 上提供“在 Finder 中显示”,在 Windows 和 WSL 上提供“在文件资源管理器中显示”,在 Linux 上通过默认文件管理器“打开所在文件夹”。菜单显示实际提供服务的 Host 名称,不依赖浏览器的操作系统。匹配的行内代码引用打开相同源文件,不触发浏览器下载。同一路径重复声明时,选择收尾回复之前最近一次的说明。 -`present` 工具行显示正在交付、已交付、失败或中断状态;展开已结束的调用可查看其记录的结果。文件卡片展示全部交付文件。打开时,卡片显示进度、成功确认或可重试的错误。服务 Host 必须具备桌面和合适的默认应用;远程浏览器不会打开其所在设备上的应用。 +`present` 工具行显示正在交付、已交付、失败或中断状态;展开已结束的调用可查看其记录的结果。文件卡片展示全部交付文件。两个操作共享等待状态,并显示进度、请求确认或各自可重试的错误。Host 没有桌面时禁用两个操作;桌面信息读取失败时提供“重试”。服务 Host 必须具备桌面和合适的默认应用;远程浏览器不会打开其所在设备上的应用。 ### 该行 @@ -52,7 +52,7 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交 Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,要求模型点名成功创建或修改的主要文件,并把这些文件以及正文中提到的其他本轮变更文件写成 Markdown 行内代码。浏览器半部把组合 `ProducedFiles` 与显式交付的包装组件注册进 chat 视图的 `conversation.chat.turnTail` 洞。`deliverablesDefinition` 根据 `write`、`edit` 和有修改作用的 `str_replace_editor` 命令中经过校验的原始参数,把每个轮次成功的第一方修改调用折叠进 `DeliverablesTurnData`。读取、删除、不受支持的工具、格式错误的调用和失败结果不贡献任何条目。新的修改工具必须增加显式 Client contribution 才能加入列表。本包还提供 chat 视图按收尾消息查询的 `chatFileMentions` 服务;把插件组合出去会同时移除两个表面,视图的空链以零成本留下。 -原生打开使用经过认证的 POST,通过当前查看的 Session、事件序号和原始文件索引定位声明。Host 按该 Session 的工作区解析路径,检查当前文件存在且位于工作区内,再启动默认应用。编辑会影响后续打开的内容;删除后返回错误。不创建文件内容副本或附件。插件释放时取消并等待进行中的原生打开请求。 +原生打开使用经过认证的 POST,通过当前查看的 Session、事件序号和原始文件索引定位声明。Host 按该 Session 的工作区解析路径,检查当前文件存在且位于工作区内,再启动所选原生操作。同一份桌面可用性配置同时约束信息查询和实际执行。编辑会影响后续打开的内容;删除后返回错误。不创建文件内容副本或附件。插件释放时取消并等待进行中的原生打开请求。 diff --git a/packages/client/ui-deliverables/src/client/Deliverables.module.css b/packages/client/ui-deliverables/src/client/Deliverables.module.css index 97befd2946..e76ca5dff1 100644 --- a/packages/client/ui-deliverables/src/client/Deliverables.module.css +++ b/packages/client/ui-deliverables/src/client/Deliverables.module.css @@ -1,14 +1,24 @@ -/** Immutable file deliveries at the end of a turn. */ +/** File metadata and joined native actions remain readable in narrow conversation columns. */ .root { display: flex; flex-direction: column; gap: 8px; min-width: 0; margin-top: 12px; } -.label { font-size: 12px; color: var(--dsw-alias-label-secondary); } +.label { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; font-size: 12px; color: var(--dsw-alias-label-secondary); } +.hostStatus { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--dsw-alias-label-secondary); } .presented { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); gap: 8px; } -.file { display: flex; align-items: center; gap: 12px; min-width: 0; padding: 14px; border: 0.5px solid var(--dsw-alias-border-l2); border-radius: 12px; background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); text-decoration: none; font: inherit; text-align: left; cursor: pointer; } -.file:hover { background: var(--dsw-alias-bg-layer-2); border-color: var(--dsw-alias-border-l3); } -.file:focus-visible { outline: 2px solid var(--dsw-alias-link); outline-offset: 2px; } -.fileIcon { flex: 0 0 auto; width: 24px; height: 24px; } +.file { display: flex; flex-direction: column; gap: 16px; min-width: 0; padding: 16px; border: 0.5px solid var(--dsw-alias-border-l2); border-radius: 14px; background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); } +.heading { display: flex; align-items: center; gap: 12px; min-width: 0; } +.fileIcon { flex: 0 0 auto; display: grid; place-items: center; width: 40px; height: 46px; border: 0.5px solid var(--dsw-alias-border-l2); border-radius: 8px; background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-link); } +.fileIcon svg { width: 22px; height: 22px; } .details { display: flex; flex-direction: column; gap: 4px; min-width: 0; flex: 1; } +.nameRow { display: flex; align-items: center; gap: 8px; min-width: 0; } .fileName { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; font-weight: 500; } -.metadata { color: var(--dsw-alias-label-tertiary); font-size: 11px; } +.metadata { flex-shrink: 0; color: var(--dsw-alias-label-secondary); background: var(--dsw-alias-interactive-bg-hover); border-radius: 4px; padding: 1px 4px; font-size: 11px; } .description { color: var(--dsw-alias-label-secondary); font-size: 12px; overflow-wrap: anywhere; } -.open { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 0 0 auto; color: var(--dsw-alias-link); font-size: 11px; } -.file:disabled { cursor: wait; opacity: 0.65; } +.footer { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; margin-top: auto; } +.path { display: flex; align-items: center; gap: 6px; min-width: 0; color: var(--dsw-alias-label-secondary); font-size: 12px; } +.path svg { flex-shrink: 0; } +.path span { overflow-wrap: anywhere; } +.split { display: inline-flex; align-items: stretch; flex-shrink: 0; margin-left: auto; border: 0.5px solid var(--dsw-alias-border-l2); border-radius: 8px; } +.open { color: var(--dsw-alias-link); font-weight: 500; } +.chevron { border-left: 0.5px solid var(--dsw-alias-border-l2); } +.menuLabel { display: flex; flex-direction: column; gap: 2px; } +.menuDetail { color: var(--dsw-alias-label-secondary); font-size: 12px; line-height: 18px; } +@media (pointer: coarse) { .open, .chevron { min-height: 44px; min-width: 44px; } } diff --git a/packages/client/ui-deliverables/src/client/Deliverables.tsx b/packages/client/ui-deliverables/src/client/Deliverables.tsx index 2faf259ad4..420741512a 100644 --- a/packages/client/ui-deliverables/src/client/Deliverables.tsx +++ b/packages/client/ui-deliverables/src/client/Deliverables.tsx @@ -1,20 +1,25 @@ /** Existing changed-file chips and explicitly declared files for a closing turn. */ import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client' -import { LinkIcon, classifyLinkPath, IconRightUpOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { Button } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { PresentedOpenController } from './present-open.ts' import { ProducedFiles } from './ProducedFiles.tsx' -import { basename, presentedForClosing, selectProducedFiles, type PresentedPath } from './turn-deliverables.ts' +import { presentedForClosing, selectProducedFiles, type PresentedPath } from './turn-deliverables.ts' import type { NS } from './locales.ts' import { presentedFileUrl } from '../presented.ts' +import { PresentedFileCard } from './PresentedFileCard.tsx' import css from './Deliverables.module.css' interface DeliverablesMatch { produced: readonly string[]; presented: readonly PresentedPath[] } /** Native-open callbacks and shared gesture status supplied by the plugin. */ export interface DeliverablesInjected { - hooks: { presentedOpen: ObservableSnapshot> } + hooks: { + presentedOpen: ObservableSnapshot> + presentedHost: ObservableSnapshot> + } + reloadPresentedHost: PresentedOpenController['loadHost'] openPresented: PresentedOpenController['open'] } @@ -34,30 +39,28 @@ export function selectDeliverables(owner: TurnTailOwnerProps): DeliverablesMatch * @param props - matched files, workspace opener, and localized copy. * @returns the closing turn's file rows. */ -export function Deliverables({ matched, openFile, t, sessionId, openPresented, usePresentedOpen }: Pick & { +export function Deliverables({ matched, openFile, t, sessionId, openPresented, usePresentedOpen, usePresentedHost, reloadPresentedHost }: Pick & { matched: DeliverablesMatch } & PropsLocale & Pick & InjectFace) { const states = usePresentedOpen(value => value) + const host = usePresentedHost(value => value) return <> {matched.produced.length > 0 && } {matched.presented.length > 0 &&
- {t('presented.label')} +
+ {t('presented.label')} + {host !== null && host !== 'error' && {t('presented.target')}} +
+ {host === 'error' &&
+ {t('presented.hostError')} + +
} + {host !== null && host !== 'error' && !host.available && {t('presented.unavailable')}}
- {matched.presented.map((file) => { - const phase = states[presentedFileUrl(sessionId, file.seq, file.index)] - return })} + {matched.presented.map(file => { void openPresented(sessionId, file.seq, file.index, action) }} />)}
} diff --git a/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx new file mode 100644 index 0000000000..d3af07034a --- /dev/null +++ b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx @@ -0,0 +1,71 @@ +/** File identity and explicit default-app or file-manager actions for one delivery. */ +import { useState } from 'react' +import { + Button, Menu, LinkIcon, classifyLinkPath, IconRightUpOutline16, + IconChevronDownOutline14, IconFolderOpenOutline16, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import type { PresentedAction, PresentedHost } from '../presented.ts' +import type { PresentedOpenPhase } from './present-open.ts' +import { basename, type PresentedPath } from './turn-deliverables.ts' +import type { NS } from './locales.ts' +import css from './Deliverables.module.css' + +/** + * Render independent file actions without nesting buttons inside a clickable card. + * @param props - durable file metadata, Host capabilities, gesture status, and localized copy. + * @returns the file card and its anchored action menu. + */ +export function PresentedFileCard({ file, phase, host, onAction, t }: { + file: PresentedPath + phase: PresentedOpenPhase | undefined + host: PresentedHost | null + onAction: (action: PresentedAction) => void +} & PropsLocale) { + const [menuOpen, setMenuOpen] = useState(false) + const pending = phase === 'opening' || phase === 'revealing' + const disabled = pending || host === null || !host.available + const reveal = host?.fileManager ?? 'directory' + const act = (action: PresentedAction) => { setMenuOpen(false); onAction(action) } + const description = (title: string, detail: string) => + {title}{detail} + + return
+
+ +
+
+ {basename(file.path)} + {basename(file.path).match(/\.([^.]+)$/)?.[1]?.toUpperCase() ?? t('presented.file')} +
+ {file.description && {file.description}} +
+
+
+ {file.path} +
+ + { setMenuOpen(false) }} + anchor={} + items={[ + { type: 'label', id: 'host', text: t('presented.host', { name: host?.name ?? '' }) }, + { id: 'open', icon: , + label: description(t('presented.defaultApp'), t('presented.openDetail')) }, + { type: 'separator', id: 'separator' }, + { id: 'reveal', icon: , + label: description(t(`presented.${reveal}`), t(reveal === 'directory' ? 'presented.directoryDetail' : 'presented.revealDetail')) }, + ]} + onSelect={(id) => { act(id === 'reveal' ? 'reveal' : 'open') }} /> +
+
+ {phase !== undefined && + {t(phase === 'revealed' && reveal === 'directory' ? 'presented.directoryOpened' : `presented.${phase}`)} + } +
+} diff --git a/packages/client/ui-deliverables/src/client/index.ts b/packages/client/ui-deliverables/src/client/index.ts index 09156448b4..86ba4e57e4 100644 --- a/packages/client/ui-deliverables/src/client/index.ts +++ b/packages/client/ui-deliverables/src/client/index.ts @@ -41,6 +41,7 @@ export const inject = ['slots', 'locale', 'uiConversation', 'remote', 'remote.se export function apply(ctx: ClientContext): void { const opener = new PresentedOpenController() ctx.effect(() => () => opener.dispose()) + void opener.loadHost() ctx.uiConversation.events.register(deliverablesDefinition) ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-deliverables: dictionaries') ctx.slots.inject( @@ -50,8 +51,9 @@ export function apply(ctx: ClientContext): void { select: selectDeliverables, locale: NS, inject: (): DeliverablesInjected => ({ - hooks: { presentedOpen: opener.state }, - openPresented: (sessionId, seq, index) => opener.open(sessionId, seq, index), + hooks: { presentedOpen: opener.state, presentedHost: opener.host }, + reloadPresentedHost: () => opener.loadHost(), + openPresented: (sessionId, seq, index, action) => opener.open(sessionId, seq, index, action), }), }, Deliverables), ) diff --git a/packages/client/ui-deliverables/src/client/locales.ts b/packages/client/ui-deliverables/src/client/locales.ts index c206f2d23a..7dec4ffdbf 100644 --- a/packages/client/ui-deliverables/src/client/locales.ts +++ b/packages/client/ui-deliverables/src/client/locales.ts @@ -6,6 +6,23 @@ export const NS = 'deliverables' /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { 'presented.label': '交付文件', + 'presented.revealError': '无法打开所在文件夹,请重试', + 'presented.directoryOpened': '已请求打开所在文件夹', + 'presented.revealed': '已请求在文件管理器中显示', + 'presented.revealing': '正在打开所在文件夹…', + 'presented.unavailable': '此主机没有可用的桌面,无法打开文件或文件夹', + 'presented.retry': '重试', + 'presented.hostError': '无法读取主机桌面信息', + 'presented.host': '操作主机:{name}', + 'presented.target': '在 DSH 主机上打开', + 'presented.directoryDetail': '使用系统默认文件管理器', + 'presented.revealDetail': '打开所在文件夹并选中文件', + 'presented.directory': '打开所在文件夹', + 'presented.explorer': '在文件资源管理器中显示', + 'presented.finder': '在 Finder 中显示', + 'presented.openDetail': '打开此文件', + 'presented.defaultApp': '用默认应用打开', + 'presented.more': '{name} 的更多文件操作', 'presented.action': '打开', 'presented.opening': '正在打开…', 'presented.opened': '已在默认程序中打开', @@ -27,6 +44,23 @@ export const zh = { /** English dictionary (same key set). */ export const en: Record = { 'presented.label': 'Deliverables', + 'presented.revealError': 'Could not open containing folder. Try again.', + 'presented.directoryOpened': 'Requested opening containing folder', + 'presented.revealed': 'Requested display in file manager', + 'presented.revealing': 'Opening containing folder…', + 'presented.unavailable': 'This Host has no desktop available to open files or folders', + 'presented.retry': 'Retry', + 'presented.hostError': 'Could not read the Host desktop information', + 'presented.host': 'Opens on {name}', + 'presented.target': 'Opens on the DSH Host', + 'presented.directoryDetail': 'Use the system default file manager', + 'presented.revealDetail': 'Open its folder and select the file', + 'presented.directory': 'Open containing folder', + 'presented.explorer': 'Show in File Explorer', + 'presented.finder': 'Show in Finder', + 'presented.openDetail': 'Open this file', + 'presented.defaultApp': 'Open in default app', + 'presented.more': 'More file actions for {name}', 'presented.action': 'Open', 'presented.opening': 'Opening…', 'presented.opened': 'Opened in default app', diff --git a/packages/client/ui-deliverables/src/client/present-open.ts b/packages/client/ui-deliverables/src/client/present-open.ts index 5b49941a57..8d74c1615f 100644 --- a/packages/client/ui-deliverables/src/client/present-open.ts +++ b/packages/client/ui-deliverables/src/client/present-open.ts @@ -1,15 +1,18 @@ /** Shared native-open status for delivery cards and closing-message file mentions. */ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import { presentedFileUrl } from '../presented.ts' +import { presentedFileUrl, PRESENT_HOST_PATH, isPresentedHost, type PresentedAction, type PresentedHost } from '../presented.ts' /** State of the latest explicit open gesture for one saved file. */ -export type PresentedOpenPhase = 'opening' | 'opened' | 'error' +export type PresentedOpenPhase = 'opening' | 'opened' | 'revealing' | 'revealed' | 'error' | 'revealError' /** One browser plugin's file-open requests, cancelled when that plugin is disposed. */ export class PresentedOpenController { /** File action URLs key the state across Sessions, turns, and both clickable surfaces. */ readonly state = createSnapshotStore>({}) + /** Native destination metadata, or a retryable read failure. */ + readonly host = createSnapshotStore(null) + private loading: Promise | undefined private readonly lifetime = new AbortController() private readonly pending = new Set>() @@ -19,13 +22,15 @@ export class PresentedOpenController { * @param sessionId - viewed Session, including a fork's own identity. * @param seq - durable delivery event sequence. * @param index - original file index within that event. + * @param action - default application open or file-manager reveal. * @returns after the Host acknowledges opening or the error state is published. */ - async open(sessionId: SessionId, seq: number, index: number): Promise { + async open(sessionId: SessionId, seq: number, index: number, action: PresentedAction = 'open'): Promise { const url = presentedFileUrl(sessionId, seq, index) - if (this.lifetime.signal.aborted || this.state.getSnapshot()[url] === 'opening') return - this.state.update((state) => { state[url] = 'opening' }) - const task = this.request(url) + const phase = this.state.getSnapshot()[url] + if (this.lifetime.signal.aborted || phase === 'opening' || phase === 'revealing') return + this.state.update((state) => { state[url] = action === 'open' ? 'opening' : 'revealing' }) + const task = this.request(url, action) this.pending.add(task) try { await task @@ -34,20 +39,50 @@ export class PresentedOpenController { } } + /** + * Read the serving desktop metadata, coalescing concurrent reads; a later call retries failure. + * @returns after metadata or a retryable error is published. + */ + async loadHost(): Promise { + if (this.lifetime.signal.aborted) return + if (this.loading !== undefined) return this.loading + this.host.set(null) + const task = this.readHost() + this.loading = task + this.pending.add(task) + try { await task } + finally { this.loading = undefined; this.pending.delete(task) } + } + + private async readHost(): Promise { + let host: PresentedHost | 'error' = 'error' + try { + const response = await fetch(PRESENT_HOST_PATH, { signal: this.lifetime.signal }) + if (response.ok) { + const value: unknown = await response.json() + if (isPresentedHost(value)) host = value + } + } catch { + // HTTP, JSON, and transport failures leave a retryable metadata read. + } + if (!this.lifetime.signal.aborted) this.host.set(host) + } + /** Cancel outstanding requests and wait until no request can publish state. */ async dispose(): Promise { this.lifetime.abort() await Promise.all(this.pending) } - private async request(url: string): Promise { - let phase: PresentedOpenPhase = 'opened' + private async request(url: string, action: PresentedAction): Promise { + const failure = action === 'open' ? 'error' : 'revealError' + let phase: PresentedOpenPhase = action === 'open' ? 'opened' : 'revealed' try { - const response = await fetch(url, { method: 'POST', signal: this.lifetime.signal }) - if (!response.ok) phase = 'error' + const response = await fetch(action === 'open' ? url : `${url}&action=reveal`, { method: 'POST', signal: this.lifetime.signal }) + if (!response.ok) phase = failure } catch { // Transport failures share the retryable card state with Host open failures. - phase = 'error' + phase = failure } if (!this.lifetime.signal.aborted) this.state.update((state) => { state[url] = phase }) } diff --git a/packages/client/ui-deliverables/src/present-open.ts b/packages/client/ui-deliverables/src/present-open.ts index beac77657a..ba5880a0e9 100644 --- a/packages/client/ui-deliverables/src/present-open.ts +++ b/packages/client/ui-deliverables/src/present-open.ts @@ -6,13 +6,18 @@ import type {} from '@deepseek-ai/dsh-api-session-controller' import type {} from '@deepseek-ai/dsh-client-connection' import type {} from '@deepseek-ai/dsh-session-query' import type { SessionId, SessionSeq } from '@deepseek-ai/dsh-session' -import { isPresentedData, isPresentedFile, PRESENT_OPEN_PATH } from './presented.ts' +import { isPresentedData, isPresentedFile, PRESENT_OPEN_PATH, PRESENT_HOST_PATH, type PresentedHost } from './presented.ts' /** * Register native opening inside Connection's authentication fence. * @param ctx - Session lookup, native opener, and route lifetime. */ export function registerPresentOpen(ctx: Context): void { + ctx.connection.fetch.register({ + path: PRESENT_HOST_PATH, methods: ['GET'], requestBody: 'buffered', + fetch: () => Promise.resolve(Response.json(ctx.sessionController.workspaceDesktop() satisfies PresentedHost, + { headers: { 'cache-control': 'no-store' } })), + }) const lifetime = new AbortController() const pending = new Set>() ctx.effect(() => async () => { @@ -36,6 +41,8 @@ export function registerPresentOpen(ctx: Context): void { async function handlePresentOpen(ctx: Context, request: Request): Promise { const query = new URL(request.url).searchParams + const action = query.get('action') ?? 'open' + if (action !== 'open' && action !== 'reveal') return new Response('Invalid file action.', { status: 400 }) const id = query.get('sessionId') const seq = query.get('seq') const index = query.get('index') @@ -45,6 +52,7 @@ async function handlePresentOpen(ctx: Context, request: Request): Promise + return typeof host.name === 'string' && typeof host.available === 'boolean' + && (host.fileManager === null || host.fileManager === 'finder' + || host.fileManager === 'explorer' || host.fileManager === 'directory') +} + /** * Validate a file declaration read from a Session log. * @param value - decoded durable data. diff --git a/packages/client/ui-deliverables/tests/present-open.client.spec.ts b/packages/client/ui-deliverables/tests/present-open.client.spec.ts index b201895445..aa022adf4c 100644 --- a/packages/client/ui-deliverables/tests/present-open.client.spec.ts +++ b/packages/client/ui-deliverables/tests/present-open.client.spec.ts @@ -61,3 +61,58 @@ it('awaits cancellation and prevents late state publication or new requests afte await controller.open(id, 2, 1) expect(fetcher).toHaveBeenCalledOnce() }) + + +it('shares pending state across open and reveal and retries the selected action', async () => { + const reply = Promise.withResolvers() + const fetcher = vi.fn().mockReturnValueOnce(reply.promise).mockResolvedValue(new Response(null, { status: 204 })) + vi.stubGlobal('fetch', fetcher) + const controller = new PresentedOpenController() + const revealing = controller.open(id, 2, 1, 'reveal') + await controller.open(id, 2, 1) + expect(controller.state.getSnapshot()[url]).toBe('revealing') + expect(fetcher).toHaveBeenCalledOnce() + expect(fetcher.mock.calls[0]?.[0]).toBe(`${url}&action=reveal`) + reply.resolve(new Response(null, { status: 500 })) + await revealing + expect(controller.state.getSnapshot()[url]).toBe('revealError') + await controller.open(id, 2, 1, 'reveal') + expect(controller.state.getSnapshot()[url]).toBe('revealed') + await controller.dispose() +}) + +it.each([null, {}, { name: 'host', available: 'yes', fileManager: 'finder' }, + { name: 'host', available: true, fileManager: 'unknown' }, 'invalid json', 'http', 'network', +])('makes invalid Host metadata retryable: %j', async (value) => { + const host = { name: 'linux-host', available: true, fileManager: 'directory' } + const fetcher = vi.fn() + if (value === 'network') fetcher.mockRejectedValueOnce(new Error('offline')) + else if (value === 'http') fetcher.mockResolvedValueOnce(new Response(null, { status: 500 })) + else if (value === 'invalid json') fetcher.mockResolvedValueOnce(new Response('bad JSON')) + else fetcher.mockResolvedValueOnce(Response.json(value)) + fetcher.mockResolvedValueOnce(Response.json(host)) + vi.stubGlobal('fetch', fetcher) + const controller = new PresentedOpenController() + await controller.loadHost() + expect(controller.host.getSnapshot()).toBe('error') + await controller.loadHost() + expect(controller.host.getSnapshot()).toEqual(host) + await controller.dispose() + await controller.loadHost() + expect(fetcher).toHaveBeenCalledTimes(2) +}) + +it('coalesces metadata reads and suppresses their publication after disposal', async () => { + const reply = Promise.withResolvers() + const fetcher = vi.fn().mockReturnValue(reply.promise) + vi.stubGlobal('fetch', fetcher) + const controller = new PresentedOpenController() + const first = controller.loadHost() + const second = controller.loadHost() + expect(fetcher).toHaveBeenCalledOnce() + const disposal = controller.dispose() + expect((fetcher.mock.calls[0]?.[1] as RequestInit).signal?.aborted).toBe(true) + reply.resolve(Response.json({ name: 'host', available: false, fileManager: null })) + await Promise.all([first, second, disposal]) + expect(controller.host.getSnapshot()).toBeNull() +}) diff --git a/packages/client/ui-deliverables/tests/present-open.host.spec.ts b/packages/client/ui-deliverables/tests/present-open.host.spec.ts index b9319587b6..fc12bc71d3 100644 --- a/packages/client/ui-deliverables/tests/present-open.host.spec.ts +++ b/packages/client/ui-deliverables/tests/present-open.host.spec.ts @@ -36,8 +36,8 @@ async function fixture() { return { session, target: { type: 'deliverables/presented', data: { turn: 1, callId: 'present-call', files: [file] } } as SessionEvent } }) ctx.provide('sessionQuery', { readEvent } as never) - const opener = vi.fn(async (_request: { path: string }, _signal: AbortSignal) => ({ opened: true as const })) - ctx.provide('sessionController', { openWorkspacePath: opener } as never) + const opener = vi.fn(async (_request: { path: string; action?: 'reveal' }, _signal: AbortSignal) => ({ opened: true as const })) + ctx.provide('sessionController', { openWorkspacePath: opener, workspaceDesktop: () => ({ name: 'desktop', available: true, fileManager: 'finder' }) } as never) const connection = new HostConnectionService(ctx, [], {} as BrowserAuth) const fiber = ctx.plugin({ inject: ['connection', 'sessionQuery', 'sessionController'], apply: registerPresentOpen }) await fiber @@ -176,3 +176,26 @@ describe('Presented workspace file native open route', () => { await Promise.all([request, disposal]) }) }) + + +it('reports the serving desktop and reveals only an authorized declared source', async () => { + const { cwd, file, open, opener, handler } = await fixture() + const info = await handler.fetch(new Request('http://localhost/api/present.host')) + expect(await info.json()).toEqual({ name: 'desktop', available: true, fileManager: 'finder' }) + expect((await open('?sessionId=owner&seq=7&index=0&action=reveal')).status).toBe(204) + expect(opener).toHaveBeenCalledWith({ path: await realpath(join(cwd, file.path)), action: 'reveal' }, expect.any(AbortSignal)) + expect((await open('?sessionId=owner&seq=7&index=0&action=delete')).status).toBe(400) + file.path = '..' + expect((await open('?sessionId=owner&seq=7&index=0&action=reveal')).status).toBe(403) + expect(opener).toHaveBeenCalledOnce() +}) + +it('refuses native actions when the configured Host desktop is unavailable', async () => { + const { ctx, open, opener, handler } = await fixture() + vi.spyOn(ctx.sessionController, 'workspaceDesktop').mockReturnValue({ name: 'desktop', available: false, fileManager: 'finder' }) + expect(await (await handler.fetch(new Request('http://localhost/api/present.host'))).json()).toMatchObject({ available: false }) + for (const action of ['open', 'reveal']) { + expect((await open(`?sessionId=owner&seq=7&index=0&action=${action}`)).status).toBe(409) + } + expect(opener).not.toHaveBeenCalled() +}) diff --git a/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx b/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx new file mode 100644 index 0000000000..69a31fc224 --- /dev/null +++ b/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx @@ -0,0 +1,95 @@ +// @vitest-environment jsdom +/** Explicit file actions preserve their destination, availability, and independent failure state. */ +import { cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { PresentedFileCard } from '../src/client/PresentedFileCard.tsx' +import { en, zh } from '../src/client/locales.ts' + +afterEach(cleanup) +const props = () => ({ + file: { path: 'out/report.pdf', description: 'Final report', seq: 4, index: 1 }, + host: { name: 'remote-desktop', available: true, fileManager: 'finder' as const }, + phase: undefined, + onAction: vi.fn(), + t: makeTranslate(en), +}) + +it.each([ + ['finder', 'Show in Finder'], ['explorer', 'Show in File Explorer'], ['directory', 'Open containing folder'], +] as const)('uses the Host %s action and closes the menu after selection', (fileManager, label) => { + const p = props() + const view = render() + fireEvent.click(view.getByRole('button', { name: 'More file actions for out/report.pdf' })) + expect(view.getByText('Opens on remote-desktop')).toBeTruthy() + fireEvent.click(view.getByRole('menuitem', { name: new RegExp(label) })) + expect(p.onAction).toHaveBeenCalledWith('reveal') + expect(view.queryByRole('menu')).toBeNull() + fireEvent.click(view.getByRole('button', { name: 'Open out/report.pdf in default app' })) + expect(p.onAction).toHaveBeenLastCalledWith('open') + fireEvent.click(view.getByRole('button', { name: 'More file actions for out/report.pdf' })) + fireEvent.click(view.getByRole('menuitem', { name: /Open in default app/ })) + expect(p.onAction).toHaveBeenCalledTimes(3) +}) + +it('dismisses the menu with Escape or an outside click without launching anything', () => { + const p = props() + const view = render() + const trigger = view.getByRole('button', { name: 'More file actions for out/report.pdf' }) + fireEvent.click(trigger) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(view.queryByRole('menu')).toBeNull() + fireEvent.click(trigger) + fireEvent.pointerDown(document.body) + expect(view.queryByRole('menu')).toBeNull() + expect(p.onAction).not.toHaveBeenCalled() +}) + +it.each(['opening', 'revealing'] as const)('disables both gestures while %s', (phase) => { + const p = props() + const view = render() + for (const button of view.getAllByRole('button')) { + expect((button as HTMLButtonElement).disabled).toBe(true) + fireEvent.click(button) + } + expect(p.onAction).not.toHaveBeenCalled() +}) + +it('keeps actions disabled until a desktop is available', () => { + const p = props() + const view = render() + expect(view.getAllByRole('button').every(button => (button as HTMLButtonElement).disabled)).toBe(true) + view.rerender() + expect(view.getAllByRole('button').every(button => (button as HTMLButtonElement).disabled)).toBe(true) +}) + +it('localizes reveal failures and accurately reports a directory-only action', () => { + const p = props() + const view = render() + expect(view.getByRole('status').textContent).toBe(zh['presented.revealError']) + view.rerender() + expect(view.getByRole('status').textContent).toBe(en['presented.directoryOpened']) + view.rerender() + expect(view.getByRole('status').textContent).toBe(en['presented.revealed']) +}) + + +it('supports keyboard selection and returns focus to the trigger on Escape', () => { + const view = render() + const trigger = view.getByRole('button', { name: 'More file actions for out/report.pdf' }) + fireEvent.click(trigger) + const items = view.getAllByRole('menuitem') + expect(document.activeElement).toBe(items[0]) + fireEvent.keyDown(document.activeElement!, { key: 'ArrowDown' }) + expect(document.activeElement).toBe(items[1]) + fireEvent.keyDown(document.activeElement!, { key: 'ArrowDown' }) + expect(document.activeElement).toBe(items[0]) + fireEvent.keyDown(document.activeElement!, { key: 'End' }) + expect(document.activeElement).toBe(items[1]) + fireEvent.keyDown(document.activeElement!, { key: 'ArrowUp' }) + expect(document.activeElement).toBe(items[0]) + fireEvent.keyDown(document.activeElement!, { key: 'Home' }) + expect(document.activeElement).toBe(items[0]) + fireEvent.keyDown(document.activeElement!, { key: 'Escape' }) + expect(document.activeElement).toBe(trigger) +}) diff --git a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx index 15c95017f3..a2773f6dab 100644 --- a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx @@ -34,7 +34,11 @@ import { SessionId } from '@deepseek-ai/dsh-session/types' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' function openProps(controller = new PresentedOpenController()) { + controller.host.set({ name: 'desktop', available: true, fileManager: 'finder' }) return { + reloadPresentedHost: vi.fn(() => controller.loadHost()), + usePresentedHost: (select: (state: ReturnType) => T): T => + select(controller.host.getSnapshot()), openPresented: vi.fn((...args: Parameters) => controller.open(...args)), usePresentedOpen: (select: (state: ReturnType) => T): T => select(controller.state.getSnapshot()), @@ -549,6 +553,7 @@ describe('plugin registration', () => { service?.forClosing(delivered, SessionId('child-session'))?.resolve('report.docx')?.open() expect(fetcher).toHaveBeenCalledWith('/api/present.open?sessionId=child-session&seq=2&index=0', { method: 'POST', signal: expect.any(AbortSignal) as AbortSignal }) const face = entry!.inject!(SessionId('child-session') as never) as unknown as DeliverablesInjected + await face.reloadPresentedHost() await face.openPresented(SessionId('child-session'), 2, 0) expect(face.hooks.presentedOpen.getSnapshot()['/api/present.open?sessionId=child-session&seq=2&index=0']).toBe('opened') // A turn that produced nothing yields no vocabulary at all. @@ -591,10 +596,10 @@ describe('presented files', () => { const props = openProps() props.openPresented.mockResolvedValue(undefined) const view = render() - expect(view.getAllByRole('button')).toHaveLength(8) + expect(view.getAllByRole('button')).toHaveLength(16) expect(view.queryByRole('link')).toBeNull() fireEvent.click(view.getByRole('button', { name: 'Open report-0.docx in default app' })) - expect(props.openPresented).toHaveBeenCalledWith('child-session', 2, 0) + expect(props.openPresented).toHaveBeenCalledWith('child-session', 2, 0, 'open') expect(view.queryByText('Files changed')).toBeNull() }) }) @@ -629,7 +634,7 @@ it('shows file metadata and descriptions without hiding extensionless deliveries expect(view.getByText('Quarterly summary')).toBeTruthy() expect(view.getByText('TXT')).toBeTruthy() expect(view.getByText('File')).toBeTruthy() - expect(view.getByRole('button', { name: 'Open out/report.txt in default app' }).getAttribute('title')).toBe('Open out/report.txt in default app') + expect(view.getByText('out/report.txt')).toBeTruthy() }) @@ -641,5 +646,20 @@ it.each(['opening', 'opened', 'error'] as const)('shows the %s state and permits { path: 'report.txt', seq: 2, index: 0 }, ] }} openFile={() => {}} sessionId={SessionId('session')} t={makeTranslate(en)} />) expect(view.getByRole('status').textContent).toBe(en[`presented.${phase}`]) - expect((view.getByRole('button') as HTMLButtonElement).disabled).toBe(phase === 'opening') + expect((view.getByRole('button', { name: 'Open report.txt in default app' }) as HTMLButtonElement).disabled).toBe(phase === 'opening') +}) + + +it('explains a missing desktop and retries failed Host metadata', () => { + const controller = new PresentedOpenController() + const props = openProps(controller) + const matched = { produced: [], presented: [{ path: 'file.txt', seq: 2, index: 0 }] } + controller.host.set('error') + const view = render( {}} sessionId={SessionId('session')} t={makeTranslate(en)} />) + props.reloadPresentedHost.mockResolvedValue(undefined) + fireEvent.click(view.getByRole('button', { name: 'Retry' })) + expect(props.reloadPresentedHost).toHaveBeenCalledOnce() + controller.host.set({ name: 'server', available: false, fileManager: null }) + view.rerender( {}} sessionId={SessionId('session')} t={makeTranslate(en)} />) + expect(view.getByText(en['presented.unavailable'])).toBeTruthy() }) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 138d94440f..fc91ac631b 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/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-primitives/README.md -README.md: 8fd59c5ecc98a609f1383e428701cf590da95e73 -README.zh.md: 2478330825681f65aa83415c698cf0edc54fdad6 +README.md: 194fc2a3ac97a8b40bb6c385af3b4ee6b3930b52 +README.zh.md: e2dcea7a7c03556fef789db21cef8187c8b72d66 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 8fd59c5ecc..194fc2a3ac 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -67,7 +67,7 @@ Writing your own component in your own package is fine when the need is genuinel ### Controls and icons -The catalog above lists what each export is for; this section covers the behavior that props alone do not show. The `ic_ds_*` icon set and `FishLogo`/`BrandWordmark` marks fill brand and inline-icon slots. `LinkIcon` draws the leading category glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, all riding `currentColor` — and `classifyLinkPath` derives a file path's category from its extension. `FileTypeIcon` is the one glyph that does not ride `currentColor`: its sheet colour is the type's identity, so a consumer that wants it muted (an empty state) applies `filter: grayscale(1)` itself. `classifyFileType` and `classifyLinkPath` read one extension vocabulary (`file-extensions.ts`), so a path classifies consistently on a link and on a sheet; the sheet is the finer of the two. `ConnectionIndicator` renders a warning-colored disconnected action, a connecting label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Hover or keyboard focus shows only the reconnect action label, including while the connecting dots animate. Every state reserves the widest supplied label and uses fixed icon and text columns, so copy changes do not move or resize the control. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. +The catalog above lists what each export is for; this section covers the behavior that props alone do not show. The `ic_ds_*` icon set and `FishLogo`/`BrandWordmark` marks fill brand and inline-icon slots. `LinkIcon` draws the leading category glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, all riding `currentColor` — and `classifyLinkPath` derives a file path's category from its extension. `FileTypeIcon` is the one glyph that does not ride `currentColor`: its sheet colour is the type's identity, so a consumer that wants it muted (an empty state) applies `filter: grayscale(1)` itself. `classifyFileType` and `classifyLinkPath` read one extension vocabulary (`file-extensions.ts`), so a path classifies consistently on a link and on a sheet; the sheet is the finer of the two. `ConnectionIndicator` renders a warning-colored disconnected action, a connecting label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Hover or keyboard focus shows only the reconnect action label, including while the connecting dots animate. Every state reserves the widest supplied label and uses fixed icon and text columns, so copy changes do not move or resize the control. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. `Button.segment` joins the start and end controls of a split action without changing standalone button geometry. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and restores the trigger on Escape; it is opt-in for action menus. ### Rendering agent output diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 2478330825..e2dcea7a7c 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -67,7 +67,7 @@ kind: "package-library" ### 控件与图标 -上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。`ic_ds_*` 图标集与 `FishLogo`/`BrandWordmark` 标记填充品牌与行内图标 slot。`LinkIcon` 为可点击产物链接绘制前置分类图形——地球、文件夹、代码、图片、文档或纸张,全部随 `currentColor`——`classifyLinkPath` 按扩展名推导文件路径的类别。`FileTypeIcon` 是唯一不随 `currentColor` 的字形:纸片颜色就是类型的身份,需要压灰的使用方(空状态)自行加 `filter: grayscale(1)`。`classifyFileType` 与 `classifyLinkPath` 读同一份扩展名词表(`file-extensions.ts`),一条路径在链接上与纸片上的归类一致;纸片分得更细。`ConnectionIndicator` 可渲染警告色的断联操作、以独立于 retry 时序的 500ms 节奏推进一至三个点的连接中状态,或成功色的恢复状态。悬停或键盘聚焦时只显示重连操作文案,连接中的圆点动画也保持隐藏。所有状态都为最长的输入 label 预留空间,并使用固定的图标列和文字列,因此文案变化不会移动控件或改变其宽度。它的 owner 提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。 `Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。 `rankByName` 是 `/` 菜单命令源与 skill 源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 +上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。`ic_ds_*` 图标集与 `FishLogo`/`BrandWordmark` 标记填充品牌与行内图标 slot。`LinkIcon` 为可点击产物链接绘制前置分类图形——地球、文件夹、代码、图片、文档或纸张,全部随 `currentColor`——`classifyLinkPath` 按扩展名推导文件路径的类别。`FileTypeIcon` 是唯一不随 `currentColor` 的字形:纸片颜色就是类型的身份,需要压灰的使用方(空状态)自行加 `filter: grayscale(1)`。`classifyFileType` 与 `classifyLinkPath` 读同一份扩展名词表(`file-extensions.ts`),一条路径在链接上与纸片上的归类一致;纸片分得更细。`ConnectionIndicator` 可渲染警告色的断联操作、以独立于 retry 时序的 500ms 节奏推进一至三个点的连接中状态,或成功色的恢复状态。悬停或键盘聚焦时只显示重连操作文案,连接中的圆点动画也保持隐藏。所有状态都为最长的输入 label 预留空间,并使用固定的图标列和文字列,因此文案变化不会移动控件或改变其宽度。它的 owner 提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。 `Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。 `rankByName` 是 `/` 菜单命令源与 skill 源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 `Button.segment` 将分段操作的首尾按钮连接起来,不改变独立按钮的几何样式。`Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时将焦点还给触发按钮;操作菜单可显式启用。 ### 渲染 agent 输出 diff --git a/packages/client/ui-primitives/src/Button.module.css b/packages/client/ui-primitives/src/Button.module.css index 9fa1712a66..b8efa4391d 100644 --- a/packages/client/ui-primitives/src/Button.module.css +++ b/packages/client/ui-primitives/src/Button.module.css @@ -77,3 +77,6 @@ align-items: center; justify-content: center; } + +.start { border-radius: 8px 0 0 8px; } +.end { border-radius: 0 8px 8px 0; } diff --git a/packages/client/ui-primitives/src/Button.tsx b/packages/client/ui-primitives/src/Button.tsx index d2e39dbf23..9519388d4d 100644 --- a/packages/client/ui-primitives/src/Button.tsx +++ b/packages/client/ui-primitives/src/Button.tsx @@ -12,18 +12,20 @@ export type ButtonVariant = 'primary' | 'ghost' | 'outline' | 'toolbar' * Render a button. * @param props.variant - visual family (default 'ghost'). * @param props.size - 'md' 36px capsule (figma Button) or 'sm' 28px compact. + * @param props.segment - joined button edge within an owner-rendered split control. * @param props.icon - optional leading 16px icon node. * @returns the button element; native button attributes pass through. */ -export function Button({ variant = 'ghost', size = 'md', icon, className, children, ...rest }: { +export function Button({ variant = 'ghost', size = 'md', icon, segment, className, children, ...rest }: { variant?: ButtonVariant size?: 'md' | 'sm' + segment?: 'start' | 'end' icon?: ReactNode className?: string | undefined children?: ReactNode } & ButtonHTMLAttributes) { return ( - diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx index 3130086777..ed19fbb70c 100644 --- a/packages/client/ui-primitives/src/Menu.tsx +++ b/packages/client/ui-primitives/src/Menu.tsx @@ -48,6 +48,7 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } /** * Render an anchored dropdown menu. + * @param props.autoFocus - focus the first item on open and enable arrow-key navigation; Escape restores the trigger. * @param props.open - whether the list is showing (owner-controlled). * @param props.anchor - the trigger element (rendered in place). * @param props.items - selectable rows and optional separators. @@ -81,8 +82,9 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } * crowds the cell). * @returns anchor wrapper with the conditional list. */ -export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, dense = false, compact = false, selection = 'check', getAnchorRect, footer, className }: { +export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, dense = false, compact = false, autoFocus = false, selection = 'check', getAnchorRect, footer, className }: { open: boolean + autoFocus?: boolean anchor: ReactNode items: readonly MenuEntry[] footer?: readonly MenuEntry[] @@ -159,6 +161,10 @@ export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, o } }, [open, portal, align, side, getAnchorRect]) + useEffect(() => { + if (open && autoFocus) listRef.current?.querySelector('button:not(:disabled)')?.focus() + }, [open, autoFocus]) + useEffect(() => { if (!open) { setOpenSubmenuId(null) @@ -172,7 +178,18 @@ export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, o onClose() } const onKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose() + if (e.key === 'Escape') { + onClose() + if (autoFocus) rootRef.current?.querySelector('button')?.focus() + } + if (!autoFocus || !['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) return + const buttons = Array.from(listRef.current?.querySelectorAll('button:not(:disabled)') ?? []) + const index = buttons.indexOf(document.activeElement as HTMLButtonElement) + if (index < 0) return + e.preventDefault() + const next = e.key === 'Home' ? 0 : e.key === 'End' ? buttons.length - 1 + : (index + (e.key === 'ArrowDown' ? 1 : -1) + buttons.length) % buttons.length + buttons[next]?.focus() } document.addEventListener('pointerdown', onPointerDown) document.addEventListener('keydown', onKeyDown) @@ -180,7 +197,7 @@ export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, o document.removeEventListener('pointerdown', onPointerDown) document.removeEventListener('keydown', onKeyDown) } - }, [open, onClose]) + }, [open, onClose, autoFocus]) // A close from selection/Escape/outside click outruns a pending grace close; // left armed it would shut a list reopened inside the grace window. Its own diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 4606b99848..1e3ce94363 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1493,6 +1493,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [], returns: 'true when the matching open operation is available.', }, + { + signature: 'workspaceDesktop(): { name: string; available: boolean; fileManager: \'finder\' | \'explorer\' | \'directory\' | null }', + description: 'Describe the serving desktop for authenticated file-action routes.', + parameters: [], + returns: 'Host name, configured availability, and platform-specific file-manager behavior.', + }, { signature: '@Remote(\'openWorkspacePath\') async openWorkspacePath( request: SessionOpenWorkspacePathRequest, signal: AbortSignal, ): Promise', description: 'Open one path prepared by a Session-aware caller on the Host desktop.', @@ -5266,7 +5272,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionOpenWorkspacePathRequest', - declaration: 'export interface SessionOpenWorkspacePathRequest {\n readonly path: string;\n}', + declaration: 'export interface SessionOpenWorkspacePathRequest {\n readonly action?: \'reveal\';\n readonly path: string;\n}', }, { name: 'SessionOpenWorkspacePathValue', diff --git a/packages/util/native-command/README.i18n.yaml b/packages/util/native-command/README.i18n.yaml index 46c49a2810..5dba1e1698 100644 --- a/packages/util/native-command/README.i18n.yaml +++ b/packages/util/native-command/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/util/native-command/README.md -README.md: 06baa6d631198e42c6de3e20c8a0bd562564b779 -README.zh.md: 6df6ee25e32e5f7290af1c4af580fe108a0d3b9f +README.md: b127c1f03fc91282cd2a847af84f8183f147f9ac +README.zh.md: 7ff612fe9bead184ace2b5ee2c54700c8d673df0 diff --git a/packages/util/native-command/README.md b/packages/util/native-command/README.md index 06baa6d631..b127c1f03f 100644 --- a/packages/util/native-command/README.md +++ b/packages/util/native-command/README.md @@ -47,6 +47,8 @@ The `NativeCommandRunner` type is the injectable command boundary for host integ `openNativePath(path, signal)` hands a path to the default application and prefers the named default browser for HTML and SVG where the platform can identify one. `openNativeTextFile(path, signal)` selects text-editor intent; on macOS it uses `open -t`. WSL paths are translated with `wslpath -w` before the Windows desktop receives them. `canOpenNativePath()` reports whether the current Host plausibly has a desktop target. +`revealNativePath(path, signal)` selects the file in Finder or Explorer, including WSL path translation, and opens its parent directory through `xdg-open` on desktop Linux. `nativeFileManager()` identifies that action for Host-derived UI labels; desktop availability remains a separate `canOpenNativePath()` check. Callers must authorize the absolute file path before invoking either operation. Platform dispatch is covered by injected-runner tests; native desktop verification belongs to the corresponding platform. + ----- diff --git a/packages/util/native-command/README.zh.md b/packages/util/native-command/README.zh.md index 6df6ee25e3..7ff612fe9b 100644 --- a/packages/util/native-command/README.zh.md +++ b/packages/util/native-command/README.zh.md @@ -47,6 +47,8 @@ const { stdout, stderr } = await runNativeCommand('osascript', ['-e', script], s `openNativePath(path, signal)` 将路径交给默认应用;平台能够确定默认浏览器时,HTML 与 SVG 会优先交给该浏览器。`openNativeTextFile(path, signal)` 选择文本编辑器意图;macOS 使用 `open -t`。WSL 路径先通过 `wslpath -w` 转换,再交给 Windows 桌面。`canOpenNativePath()` 报告当前 Host 是否可能具备桌面目标。 +`revealNativePath(path, signal)` 在 Finder 或文件资源管理器中选中文件,包含 WSL 路径转换;在桌面 Linux 上通过 `xdg-open` 打开上层目录。`nativeFileManager()` 标识该操作,供 UI 根据 Host 选择文案;桌面是否可用仍由独立的 `canOpenNativePath()` 检查决定。调用方必须先授权绝对文件路径,再执行操作。平台分派由注入运行器的测试覆盖;原生桌面验证由对应平台负责。 + ----- diff --git a/packages/util/native-command/src/index.ts b/packages/util/native-command/src/index.ts index 14100af799..a2e23a35be 100644 --- a/packages/util/native-command/src/index.ts +++ b/packages/util/native-command/src/index.ts @@ -7,10 +7,13 @@ export { runNativeCommand } from './runner.ts' export type { NativeCommandRunner } from './runner.ts' export { canOpenNativePath, + nativeFileManager, + revealNativePath, openNativePath, openNativeTextFile, } from './path-opener.ts' export type { + NativeFileManager, PathOpenerInternals, PathOpenerRunner, } from './path-opener.ts' diff --git a/packages/util/native-command/src/path-opener.ts b/packages/util/native-command/src/path-opener.ts index 97d2084e61..0d8dcb32da 100644 --- a/packages/util/native-command/src/path-opener.ts +++ b/packages/util/native-command/src/path-opener.ts @@ -10,7 +10,7 @@ */ import { release as osRelease } from 'node:os' -import { extname } from 'node:path' +import { dirname, extname } from 'node:path' import { runNativeCommand, type NativeCommandRunner } from './runner.ts' /** Testable command boundary; native implementations never invoke a shell. */ @@ -201,3 +201,54 @@ export function openNativeTextFile( ): Promise { return openNativePathWithIntent(path, signal, 'text-editor', internals) } + +/** File-manager behavior available on the serving Host, including WSL's Windows desktop. */ +export type NativeFileManager = 'finder' | 'explorer' | 'directory' + +/** + * Identify the native file-manager action without inspecting the browser's platform. + * @param internals - platform and WSL facts. + * @returns the supported file-manager action, or null on unsupported platforms. + */ +export function nativeFileManager(internals: PathOpenerInternals = {}): NativeFileManager | null { + const platform = internals.platform ?? process.platform + if (platform === 'darwin') return 'finder' + if (platform === 'win32' || (platform === 'linux' && isWsl(internals))) return 'explorer' + return platform === 'linux' ? 'directory' : null +} + +/** + * Reveal a file in Finder or Explorer, or open its parent in the Linux default file manager. + * @param path - absolute file path already authorized by the caller. + * @param signal - caller lifetime; abort terminates the native command. + * @param internals - platform, environment, and command runner for adapter tests. + * @returns after the file-manager command accepts the request; launch failures reject. + */ +export async function revealNativePath( + path: string, signal: AbortSignal, internals: PathOpenerInternals = {}, +): Promise { + signal.throwIfAborted() + const platform = internals.platform ?? process.platform + const run = internals.run ?? runNativeCommand + const manager = nativeFileManager({ ...internals, platform }) + if (manager === 'finder') { + await run('open', ['-R', path], signal) + return + } + if (manager === 'explorer') { + let windowsPath = path + if (platform === 'linux') { + const translated = await run('wslpath', ['-w', path], signal) + signal.throwIfAborted() + windowsPath = translated.stdout.replace(/[\r\n]+$/, '') + if (windowsPath === '') throw new Error('wslpath returned no Windows path') + } + await run('explorer.exe', [`/select,${windowsPath}`], signal) + return + } + if (manager === 'directory') { + await run('xdg-open', [dirname(path)], signal) + return + } + throw new Error(`native file manager is unsupported on ${platform}`) +} diff --git a/packages/util/native-command/tests/path-opener.spec.ts b/packages/util/native-command/tests/path-opener.spec.ts index d5cd1bb751..347588095d 100644 --- a/packages/util/native-command/tests/path-opener.spec.ts +++ b/packages/util/native-command/tests/path-opener.spec.ts @@ -17,7 +17,7 @@ vi.mock('node:child_process', () => ({ execFile: execFileMock })) import { release as osRelease } from 'node:os' import { describe, expect, it, vi } from 'vitest' -import { canOpenNativePath, openNativePath, openNativeTextFile, type PathOpenerRunner } from '../src/index.ts' +import { canOpenNativePath, nativeFileManager, revealNativePath, openNativePath, openNativeTextFile, type PathOpenerRunner } from '../src/index.ts' const signal = () => new AbortController().signal @@ -326,3 +326,55 @@ describe('canOpenNativePath', () => { expect(canOpenNativePath()).toBe(canOpenNativePath({ platform: process.platform })) }) }) + + +describe('native file manager', () => { + it.each([ + ['darwin', 'finder', '/tmp/my report.txt', 'open', ['-R', '/tmp/my report.txt']], + ['win32', 'explorer', 'C:\\work\\my report.txt', 'explorer.exe', ['/select,C:\\work\\my report.txt']], + ['linux', 'directory', '/tmp/a $b; report.txt', 'xdg-open', ['/tmp']], + ] as const)('reveals through %s without opening the file association', async (platform, manager, path, command, args) => { + const run = vi.fn(async () => ({ stdout: '', stderr: '' })) + const internals = { platform, env: {}, osRelease: 'generic', run } + expect(nativeFileManager(internals)).toBe(manager) + await revealNativePath(path, signal(), internals) + expect(run).toHaveBeenCalledExactlyOnceWith(command, args, expect.any(AbortSignal)) + }) + + it('selects a translated WSL path in Explorer and never starts a Linux file manager', async () => { + const run = vi.fn(async () => ({ stdout: 'C:\\work\\报告.txt\r\n', stderr: '' })) + const internals = { platform: 'linux' as const, env: { WSL_DISTRO_NAME: 'Ubuntu' }, run } + expect(nativeFileManager(internals)).toBe('explorer') + await revealNativePath('/mnt/c/work/报告.txt', signal(), internals) + expect(run.mock.calls.map(([cmd, args]) => [cmd, args])).toEqual([ + ['wslpath', ['-w', '/mnt/c/work/报告.txt']], ['explorer.exe', ['/select,C:\\work\\报告.txt']], + ]) + }) + + it('refuses empty WSL translations and cancelled translation without launching Explorer', async () => { + const abort = new AbortController() + const run = vi.fn(async () => ({ stdout: '', stderr: '' })) + const internals = { platform: 'linux' as const, env: {}, osRelease: 'microsoft', run } + await expect(revealNativePath('/file', signal(), internals)).rejects.toThrow('no Windows path') + run.mockImplementationOnce(async () => { abort.abort(new Error('stopped')); return { stdout: 'C:\\file', stderr: '' } }) + await expect(revealNativePath('/file', abort.signal, internals)).rejects.toThrow('stopped') + expect(run.mock.calls.every(([cmd]) => cmd === 'wslpath')).toBe(true) + }) + + it('rejects unsupported platforms, cancellation, and launcher errors', async () => { + const run = vi.fn().mockRejectedValue(new Error('desktop failed')) + expect(nativeFileManager({ platform: 'aix' })).toBeNull() + await expect(revealNativePath('/file', signal(), { platform: 'aix', run })).rejects.toThrow('unsupported') + await expect(revealNativePath('/file', AbortSignal.abort(new Error('cancelled')), { run })).rejects.toThrow('cancelled') + expect(run).not.toHaveBeenCalled() + await expect(revealNativePath('/file', signal(), { platform: 'darwin', run })).rejects.toThrow('desktop failed') + expect(nativeFileManager()).toBe(process.platform === 'darwin' ? 'finder' : process.platform === 'win32' ? 'explorer' : 'directory') + }) +}) + + +it('uses the native runner for a file-manager handoff when none is injected', async () => { + execFileMock.mockImplementation((_command, _args, _options, callback) => { callback(null, '', '') }) + await revealNativePath('/tmp/report.txt', signal()) + expect(execFileMock).toHaveBeenCalled() +}) diff --git a/snapshots/web/present/ui.expected.md b/snapshots/web/present/ui.expected.md index 1a79b00463..1346420ed9 100644 --- a/snapshots/web/present/ui.expected.md +++ b/snapshots/web/present/ui.expected.md @@ -63,17 +63,24 @@ - code: AFTER_PRESENT - text: — no retries, no extra files. - paragraph: PRESENT_DONE -- text: Deliverables +- text: Deliverables Opens on the DSH Host report.txt TXT delivered report +- img +- text: report.txt - button "Open report.txt in default app": - - text: report.txt TXT delivered report - - status: Opened in default app - img - text: Open +- button "More file actions for report.txt": + - img +- status: Opened in default app +- text: 说明.txt TXT delivered note +- img +- text: 说明.txt - button "Open 说明.txt in default app": - - text: 说明.txt TXT delivered note - - status: Opened in default app - img - text: Open +- button "More file actions for 说明.txt": + - img +- status: Opened in default app - button "Copy": - img - button "Good response": From b27d861741d3258de1fd5f480ed80f73d191bdf6 Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 9 Sep 2026 11:28:17 +0800 Subject: [PATCH 03/20] docs: refresh native file action catalog references --- docs/config-catalog.i18n.yaml | 2 +- docs/config-catalog.md | 2 +- docs/event-producer-consumer.i18n.yaml | 4 ++-- docs/event-producer-consumer.md | 10 +++++----- docs/event-producer-consumer.zh.md | 10 +++++----- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 2f4b0e6e79..6462aa0d55 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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 docs/config-catalog.md -config-catalog.md: 4e92bbfcf4bcf190b9b17436eefc3255dda9132a +config-catalog.md: 7b677ae08d84c9ad94f0ecd59820f52202b90e93 config-catalog.zh.md: df4b107f28040962f38802a77f8b91c47ad2931d diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 4e92bbfcf4..7b677ae08d 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -213,7 +213,7 @@ export interface Config { } ``` -Source: [`packages/api/session-controller/src/index.ts:70`](../packages/api/session-controller/src/index.ts) +Source: [`packages/api/session-controller/src/index.ts:71`](../packages/api/session-controller/src/index.ts) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 09eecc6d42..7f7e38eff1 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.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 docs/event-producer-consumer.md -event-producer-consumer.md: 2239c9786f9e18b6a8e9514bfd53da7f87cfb8a4 -event-producer-consumer.zh.md: e43d48e2ee163843752bcda9fe3578839e05252b +event-producer-consumer.md: 323e9f3eea8703c46f3be082db6ab67e9339084b +event-producer-consumer.zh.md: 1f34a6f3d53bfd6ddf6c19adb0a3c7cb7043c6ab diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 2239c9786f..323e9f3eea 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -22,11 +22,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:316`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:277`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:391`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:597`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:577`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:604`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:583`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:590`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:599`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:579`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:606`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:585`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:592`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:87`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index e43d48e2ee..1f34a6f3d5 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -24,11 +24,11 @@ | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:316`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:277`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:391`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:597`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:577`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:604`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:583`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:590`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:599`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:579`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:606`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:585`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:592`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:87`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | From 5b671d7df3df08b3dee8cb681d8977cfd2f61fee Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 9 Sep 2026 12:00:24 +0800 Subject: [PATCH 04/20] fix(web): show artifact paths relative to the workspace --- apps/web/tests/present.e2e.ts | 1 + .../client/ui-deliverables/README.i18n.yaml | 4 ++-- packages/client/ui-deliverables/README.md | 2 +- packages/client/ui-deliverables/README.zh.md | 2 +- packages/client/ui-deliverables/package.json | 3 ++- .../src/client/Deliverables.tsx | 9 +++++---- .../src/client/PresentedFileCard.tsx | 10 +++++++--- .../tests/presented-file-card.client.spec.tsx | 13 +++++++++++++ .../tests/produced-files.client.spec.tsx | 4 +++- .../ui-deliverables/tsconfig.client.json | 3 +++ .../client/tool/models/image-card-model.ts | 4 ++-- .../src/client/tool/models/read-card-model.ts | 4 ++-- .../src/client/tool/models/tool-call-model.ts | 14 +------------- packages/util/workspace-path/README.i18n.yaml | 4 ++-- packages/util/workspace-path/README.md | 2 +- packages/util/workspace-path/README.zh.md | 2 +- packages/util/workspace-path/src/index.ts | 13 +++++++++++++ .../util/workspace-path/tests/index.spec.ts | 19 +++++++++++++++++-- pnpm-lock.yaml | 3 +++ 19 files changed, 80 insertions(+), 36 deletions(-) diff --git a/apps/web/tests/present.e2e.ts b/apps/web/tests/present.e2e.ts index 802e5eed1d..7ccaa42b68 100644 --- a/apps/web/tests/present.e2e.ts +++ b/apps/web/tests/present.e2e.ts @@ -122,6 +122,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con const row = page.locator('[data-presented-files-row]') await row.waitFor() expect(await row.getByRole('button').count()).toBe(4) + expect(await row.getByTitle(join(cwd, 'report.txt'), { exact: true }).innerText()).toBe('report.txt') const beforeReveal = (await opened()).length await row.getByRole('button', { name: 'More file actions for report.txt', exact: true }).click() const revealResponse = page.waitForResponse(response => response.url().includes('action=reveal') && response.request().method() === 'POST') diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index 654494e779..5303dce4bb 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/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-deliverables/README.md -README.md: 3196202a053ae35bb3055666a680c9d6cfc2a2f8 -README.zh.md: bb394ae756820d7b536ea9326c0837cf59a85a01 +README.md: 3ff1f50999ee7efbf8c3e3c2e996d1ac208db5d4 +README.zh.md: 23b7ca1275c4a4315ef890627d745cf5157d0660 diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index 3196202a05..3ff1f50999 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -30,7 +30,7 @@ Mount this plugin alongside `ui-conversation`; a finished turn then ends with th ### Explicit deliveries -The Web `standard`, `ptc`, and `cordis` presets expose `present` for final workspace files, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../fs/tool-present/README.md) owns file-count limits and Session declarations. The closing turn shows responsive cards with file names, types, descriptions, workspace paths, and a split Open button. Its primary action opens the source in the Host’s default application; the menu offers Show in Finder on macOS, Show in File Explorer on Windows and WSL, or Open containing folder through the default Linux file manager. The menu names the serving Host, independently of the browser’s operating system. Matching inline-code references open the same source files without starting a browser download. Repeated declaration of a path selects its latest description before the closing reply. +The Web `standard`, `ptc`, and `cordis` presets expose `present` for final workspace files, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../fs/tool-present/README.md) owns file-count limits and Session declarations. The closing turn shows responsive cards with file names, types, descriptions, workspace-relative paths, and a split Open button. Hovering the path shows its full location. Its primary action opens the source in the Host’s default application; the menu offers Show in Finder on macOS, Show in File Explorer on Windows and WSL, or Open containing folder through the default Linux file manager. The menu names the serving Host, independently of the browser’s operating system. Matching inline-code references open the same source files without starting a browser download. Repeated declaration of a path selects its latest description before the closing reply. The `present` tool row shows running, delivered, failed, or interrupted status; expanding a settled row reveals its recorded result. File cards include every delivered file. Both actions share pending state and show progress, acknowledgement, or an action-specific retryable error. A missing desktop disables both actions; a failed desktop-information read offers Retry. It requires a desktop and a suitable default application on the serving Host; a remote browser does not open applications on its own device. diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index bb394ae756..23b7ca1275 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -30,7 +30,7 @@ kind: "package-reference" ### 显式交付 -Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交付最终工作区文件,包括通过 Bash 创建的文件。创建文件后,以 `files: [{ path, description? }]` 调用。[present 工具](../../fs/tool-present/README.zh.md)拥有文件数量限制和 Session 声明。收尾 turn 显示响应式卡片,包含文件名称、类型、说明、工作区路径和分段“打开”按钮。主按钮在 Host 默认应用中打开源文件;菜单在 macOS 上提供“在 Finder 中显示”,在 Windows 和 WSL 上提供“在文件资源管理器中显示”,在 Linux 上通过默认文件管理器“打开所在文件夹”。菜单显示实际提供服务的 Host 名称,不依赖浏览器的操作系统。匹配的行内代码引用打开相同源文件,不触发浏览器下载。同一路径重复声明时,选择收尾回复之前最近一次的说明。 +Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交付最终工作区文件,包括通过 Bash 创建的文件。创建文件后,以 `files: [{ path, description? }]` 调用。[present 工具](../../fs/tool-present/README.zh.md)拥有文件数量限制和 Session 声明。收尾 turn 显示响应式卡片,包含文件名称、类型、说明、工作区相对路径和分段“打开”按钮。悬停路径可查看完整位置。主按钮在 Host 默认应用中打开源文件;菜单在 macOS 上提供“在 Finder 中显示”,在 Windows 和 WSL 上提供“在文件资源管理器中显示”,在 Linux 上通过默认文件管理器“打开所在文件夹”。菜单显示实际提供服务的 Host 名称,不依赖浏览器的操作系统。匹配的行内代码引用打开相同源文件,不触发浏览器下载。同一路径重复声明时,选择收尾回复之前最近一次的说明。 `present` 工具行显示正在交付、已交付、失败或中断状态;展开已结束的调用可查看其记录的结果。文件卡片展示全部交付文件。两个操作共享等待状态,并显示进度、请求确认或各自可重试的错误。Host 没有桌面时禁用两个操作;桌面信息读取失败时提供“重试”。服务 Host 必须具备桌面和合适的默认应用;远程浏览器不会打开其所在设备上的应用。 diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json index 67970786a0..ca55e77f16 100644 --- a/packages/client/ui-deliverables/package.json +++ b/packages/client/ui-deliverables/package.json @@ -67,7 +67,8 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-client-ui-tool": "workspace:^", "@deepseek-ai/dsh-tool-present": "workspace:^", - "@deepseek-ai/dsh-api-session-controller": "workspace:^" + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-util-workspace-path": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-deliverables/src/client/Deliverables.tsx b/packages/client/ui-deliverables/src/client/Deliverables.tsx index 420741512a..ab83af8f4e 100644 --- a/packages/client/ui-deliverables/src/client/Deliverables.tsx +++ b/packages/client/ui-deliverables/src/client/Deliverables.tsx @@ -1,7 +1,7 @@ /** Existing changed-file chips and explicitly declared files for a closing turn. */ import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client' import { Button } from '@deepseek-ai/dsh-client-ui-primitives' -import type { InjectFace, PropsLocale, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots' +import type { GlobalStandardProps, InjectFace, PropsLocale, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { PresentedOpenController } from './present-open.ts' import { ProducedFiles } from './ProducedFiles.tsx' @@ -39,9 +39,10 @@ export function selectDeliverables(owner: TurnTailOwnerProps): DeliverablesMatch * @param props - matched files, workspace opener, and localized copy. * @returns the closing turn's file rows. */ -export function Deliverables({ matched, openFile, t, sessionId, openPresented, usePresentedOpen, usePresentedHost, reloadPresentedHost }: Pick & { +export function Deliverables({ matched, openFile, t, sessionId, useSessions, openPresented, usePresentedOpen, usePresentedHost, reloadPresentedHost }: Pick & { matched: DeliverablesMatch -} & PropsLocale & Pick & InjectFace) { +} & PropsLocale & Pick & Pick & InjectFace) { + const cwd = useSessions(state => state.byId[sessionId]?.cwd) const states = usePresentedOpen(value => value) const host = usePresentedHost(value => value) return <> @@ -57,7 +58,7 @@ export function Deliverables({ matched, openFile, t, sessionId, openPresented, u } {host !== null && host !== 'error' && !host.available && {t('presented.unavailable')}}
- {matched.presented.map(file => { void openPresented(sessionId, file.seq, file.index, action) }} />)} diff --git a/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx index d3af07034a..65db02e5ca 100644 --- a/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx +++ b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx @@ -1,5 +1,6 @@ /** File identity and explicit default-app or file-manager actions for one delivery. */ import { useState } from 'react' +import { relativizeToCwd, resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path' import { Button, Menu, LinkIcon, classifyLinkPath, IconRightUpOutline16, IconChevronDownOutline14, IconFolderOpenOutline16, @@ -13,11 +14,12 @@ import css from './Deliverables.module.css' /** * Render independent file actions without nesting buttons inside a clickable card. - * @param props - durable file metadata, Host capabilities, gesture status, and localized copy. + * @param props - durable file metadata, Session workspace root, Host capabilities, gesture status, and localized copy. * @returns the file card and its anchored action menu. */ -export function PresentedFileCard({ file, phase, host, onAction, t }: { +export function PresentedFileCard({ file, cwd, phase, host, onAction, t }: { file: PresentedPath + cwd: string | undefined phase: PresentedOpenPhase | undefined host: PresentedHost | null onAction: (action: PresentedAction) => void @@ -42,7 +44,9 @@ export function PresentedFileCard({ file, phase, host, onAction, t }: {
- {file.path} + + {relativizeToCwd(file.path, cwd)} +
} {host !== null && host !== 'error' && !host.available && {t('presented.unavailable')}} -
- {matched.presented.map(file => + {presented.map(file => { openFile(file.path) }} onAction={(action) => { void openPresented(sessionId, file.seq, file.index, action) }} />)}
+ {collapsible && }
} } diff --git a/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx index 65db02e5ca..2ca532182f 100644 --- a/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx +++ b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx @@ -1,8 +1,8 @@ /** File identity and explicit default-app or file-manager actions for one delivery. */ import { useState } from 'react' -import { relativizeToCwd, resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path' +import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path' import { - Button, Menu, LinkIcon, classifyLinkPath, IconRightUpOutline16, + Menu, LinkIcon, classifyLinkPath, IconRightUpOutline16, IconChevronDownOutline14, IconFolderOpenOutline16, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' @@ -12,64 +12,66 @@ import { basename, type PresentedPath } from './turn-deliverables.ts' import type { NS } from './locales.ts' import css from './Deliverables.module.css' +function cardDescription(description: string | undefined, fallback: string): string { + const trimmed = description?.replace(/\s*(?:\([^()]*\)|([^()]*))\s*$/u, '').trim() + return trimmed === undefined || trimmed === '' ? fallback : trimmed +} + /** * Render independent file actions without nesting buttons inside a clickable card. - * @param props - durable file metadata, Session workspace root, Host capabilities, gesture status, and localized copy. + * @param props - durable file metadata, Sidebar preview, Host capabilities, gesture status, and localized copy. * @returns the file card and its anchored action menu. */ -export function PresentedFileCard({ file, cwd, phase, host, onAction, t }: { +export function PresentedFileCard({ file, cwd, phase, host, onPreview, onAction, t }: { file: PresentedPath cwd: string | undefined phase: PresentedOpenPhase | undefined host: PresentedHost | null + onPreview: () => void onAction: (action: PresentedAction) => void } & PropsLocale) { const [menuOpen, setMenuOpen] = useState(false) const pending = phase === 'opening' || phase === 'revealing' - const disabled = pending || host === null || !host.available + const menuDisabled = pending || host === null || !host.available const reveal = host?.fileManager ?? 'directory' const act = (action: PresentedAction) => { setMenuOpen(false); onAction(action) } - const description = (title: string, detail: string) => - {title}{detail} - + const name = basename(file.path) + const metadata = name.match(/\.([^.]+)$/)?.[1]?.toUpperCase() ?? t('presented.file') + const status = phase === undefined + ? cardDescription(file.description, metadata) + : t(phase === 'revealed' && reveal === 'directory' ? 'presented.directoryOpened' : `presented.${phase}`) return
-
- + - { setMenuOpen(false) }} - anchor={ + { setMenuOpen(false) }} + anchor={} + onClick={() => { setMenuOpen(value => !value) }}> + + } items={[ - { type: 'label', id: 'host', text: t('presented.host', { name: host?.name ?? '' }) }, - { id: 'open', icon: , - label: description(t('presented.defaultApp'), t('presented.openDetail')) }, - { type: 'separator', id: 'separator' }, + { id: 'open', icon: , + label: t('presented.defaultApp') }, { id: 'reveal', icon: , - label: description(t(`presented.${reveal}`), t(reveal === 'directory' ? 'presented.directoryDetail' : 'presented.revealDetail')) }, + label: t(`presented.${reveal}`) }, ]} onSelect={(id) => { act(id === 'reveal' ? 'reveal' : 'open') }} />
- {phase !== undefined && - {t(phase === 'revealed' && reveal === 'directory' ? 'presented.directoryOpened' : `presented.${phase}`)} - } } diff --git a/packages/client/ui-deliverables/src/client/locales.ts b/packages/client/ui-deliverables/src/client/locales.ts index 7dec4ffdbf..de5310bbf0 100644 --- a/packages/client/ui-deliverables/src/client/locales.ts +++ b/packages/client/ui-deliverables/src/client/locales.ts @@ -5,7 +5,6 @@ export const NS = 'deliverables' /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { - 'presented.label': '交付文件', 'presented.revealError': '无法打开所在文件夹,请重试', 'presented.directoryOpened': '已请求打开所在文件夹', 'presented.revealed': '已请求在文件管理器中显示', @@ -13,17 +12,19 @@ export const zh = { 'presented.unavailable': '此主机没有可用的桌面,无法打开文件或文件夹', 'presented.retry': '重试', 'presented.hostError': '无法读取主机桌面信息', - 'presented.host': '操作主机:{name}', - 'presented.target': '在 DSH 主机上打开', - 'presented.directoryDetail': '使用系统默认文件管理器', - 'presented.revealDetail': '打开所在文件夹并选中文件', 'presented.directory': '打开所在文件夹', 'presented.explorer': '在文件资源管理器中显示', 'presented.finder': '在 Finder 中显示', - 'presented.openDetail': '打开此文件', 'presented.defaultApp': '用默认应用打开', 'presented.more': '{name} 的更多文件操作', 'presented.action': '打开', + 'presented.preview': '在侧边栏预览', + 'presented.previewButton': '在侧边栏打开 {name}', + 'presented.previewCard': '在侧边栏预览 {name}', + 'presented.all': '全部 {count} 个文件', + 'presented.expandAria': '展开全部 {count} 个交付文件', + 'presented.collapse': '收起', + 'presented.collapseAria': '收起交付文件列表', 'presented.opening': '正在打开…', 'presented.opened': '已在默认程序中打开', 'presented.error': '打开失败,点击重试', @@ -43,7 +44,6 @@ export const zh = { /** English dictionary (same key set). */ export const en: Record = { - 'presented.label': 'Deliverables', 'presented.revealError': 'Could not open containing folder. Try again.', 'presented.directoryOpened': 'Requested opening containing folder', 'presented.revealed': 'Requested display in file manager', @@ -51,17 +51,19 @@ export const en: Record = { 'presented.unavailable': 'This Host has no desktop available to open files or folders', 'presented.retry': 'Retry', 'presented.hostError': 'Could not read the Host desktop information', - 'presented.host': 'Opens on {name}', - 'presented.target': 'Opens on the DSH Host', - 'presented.directoryDetail': 'Use the system default file manager', - 'presented.revealDetail': 'Open its folder and select the file', 'presented.directory': 'Open containing folder', 'presented.explorer': 'Show in File Explorer', 'presented.finder': 'Show in Finder', - 'presented.openDetail': 'Open this file', 'presented.defaultApp': 'Open in default app', 'presented.more': 'More file actions for {name}', 'presented.action': 'Open', + 'presented.preview': 'Preview in sidebar', + 'presented.previewButton': 'Open {name} in sidebar', + 'presented.previewCard': 'Preview {name} in sidebar', + 'presented.all': 'All {count} files', + 'presented.expandAria': 'Show all {count} delivered files', + 'presented.collapse': 'Collapse', + 'presented.collapseAria': 'Collapse delivered files', 'presented.opening': 'Opening…', 'presented.opened': 'Opened in default app', 'presented.error': 'Could not open. Click to retry.', diff --git a/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx b/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx index 3b9a96af65..0f060643b4 100644 --- a/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx @@ -12,6 +12,7 @@ const props = () => ({ file: { path: 'out/report.pdf', description: 'Final report', seq: 4, index: 1 }, host: { name: 'remote-desktop', available: true, fileManager: 'finder' as const }, phase: undefined, + onPreview: vi.fn(), onAction: vi.fn(), t: makeTranslate(en), }) @@ -22,15 +23,13 @@ it.each([ const p = props() const view = render() fireEvent.click(view.getByRole('button', { name: 'More file actions for out/report.pdf' })) - expect(view.getByText('Opens on remote-desktop')).toBeTruthy() fireEvent.click(view.getByRole('menuitem', { name: new RegExp(label) })) expect(p.onAction).toHaveBeenCalledWith('reveal') expect(view.queryByRole('menu')).toBeNull() - fireEvent.click(view.getByRole('button', { name: 'Open out/report.pdf in default app' })) - expect(p.onAction).toHaveBeenLastCalledWith('open') fireEvent.click(view.getByRole('button', { name: 'More file actions for out/report.pdf' })) fireEvent.click(view.getByRole('menuitem', { name: /Open in default app/ })) - expect(p.onAction).toHaveBeenCalledTimes(3) + expect(p.onAction).toHaveBeenLastCalledWith('open') + expect(p.onAction).toHaveBeenCalledTimes(2) }) it('dismisses the menu with Escape or an outside click without launching anything', () => { @@ -46,32 +45,42 @@ it('dismisses the menu with Escape or an outside click without launching anythin expect(p.onAction).not.toHaveBeenCalled() }) -it.each(['opening', 'revealing'] as const)('disables both gestures while %s', (phase) => { +it.each(['opening', 'revealing'] as const)('keeps sidebar previews available while the native action is %s', (phase) => { const p = props() const view = render() - for (const button of view.getAllByRole('button')) { - expect((button as HTMLButtonElement).disabled).toBe(true) - fireEvent.click(button) - } + expect((view.getByRole('button', { name: 'More file actions for out/report.pdf' }) as HTMLButtonElement).disabled).toBe(true) + fireEvent.click(view.getByRole('button', { name: 'Preview out/report.pdf in sidebar' })) + fireEvent.click(view.getByRole('button', { name: 'Open out/report.pdf in sidebar' })) + expect(p.onPreview).toHaveBeenCalledTimes(2) expect(p.onAction).not.toHaveBeenCalled() }) -it('keeps actions disabled until a desktop is available', () => { +it('keeps the native menu disabled until a desktop is available', () => { const p = props() const view = render() - expect(view.getAllByRole('button').every(button => (button as HTMLButtonElement).disabled)).toBe(true) + expect((view.getByRole('button', { name: 'More file actions for out/report.pdf' }) as HTMLButtonElement).disabled).toBe(true) + expect((view.getByRole('button', { name: 'Open out/report.pdf in sidebar' }) as HTMLButtonElement).disabled).toBe(false) view.rerender() - expect(view.getAllByRole('button').every(button => (button as HTMLButtonElement).disabled)).toBe(true) + expect((view.getByRole('button', { name: 'More file actions for out/report.pdf' }) as HTMLButtonElement).disabled).toBe(true) +}) + +it('opens the right sidebar from either the card or its primary button', () => { + const p = props() + const view = render() + fireEvent.click(view.getByRole('button', { name: 'Preview out/report.pdf in sidebar' })) + fireEvent.click(view.getByRole('button', { name: 'Open out/report.pdf in sidebar' })) + expect(p.onPreview).toHaveBeenCalledTimes(2) + expect(p.onAction).not.toHaveBeenCalled() }) it('localizes reveal failures and accurately reports a directory-only action', () => { const p = props() const view = render() - expect(view.getByRole('status').textContent).toBe(zh['presented.revealError']) + expect(view.getByText(zh['presented.revealError'])).toBeTruthy() view.rerender() - expect(view.getByRole('status').textContent).toBe(en['presented.directoryOpened']) + expect(view.getByText(en['presented.directoryOpened'])).toBeTruthy() view.rerender() - expect(view.getByRole('status').textContent).toBe(en['presented.revealed']) + expect(view.getByText(en['presented.revealed'])).toBeTruthy() }) @@ -96,13 +105,26 @@ it('supports keyboard selection and returns focus to the trigger on Escape', () }) -it('shortens the workspace prefix while retaining the full location on hover and in action labels', () => { +it('shows the basename while retaining the full location for hover and actions', () => { const p = props() const path = '/work/reports/result.pdf' const view = render() - expect(view.getByTitle(path).textContent).toBe('reports/result.pdf') - fireEvent.click(view.getByRole('button', { name: `Open ${path} in default app` })) + expect(view.getByTitle(path)).toBeTruthy() + expect(view.getByText('result.pdf')).toBeTruthy() + fireEvent.click(view.getByRole('button', { name: `More file actions for ${path}` })) + fireEvent.click(view.getByRole('menuitem', { name: 'Open in default app' })) expect(p.onAction).toHaveBeenCalledWith('open') view.rerender() - expect(view.getByTitle('/work/out/report.pdf').textContent).toBe('out/report.pdf') + expect(view.getByTitle('/work/out/report.pdf')).toBeTruthy() + expect(view.getByText('report.pdf')).toBeTruthy() +}) + +it.each([ + ['Quarterly summary (.pdf)', 'Quarterly summary'], + ['季度总结(PDF)', '季度总结'], +] as const)('omits a trailing parenthesized file suffix from %s', (description, expected) => { + const p = props() + const view = render() + expect(view.getByText(expected)).toBeTruthy() + expect(view.queryByText(description)).toBeNull() }) diff --git a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx index 9f1537e3ea..3c72544468 100644 --- a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx @@ -588,20 +588,33 @@ describe('presented files', () => { expect(selectDeliverables(tailOwner(deliverablesOf(value, 2), 9))).toBeNull() }) - it('uses the viewed fork Session in every open action and retains all delivered files', () => { + it('uses the viewed fork Session in every open action and expands all delivered files', () => { const value = assembler([ at(1, 'turn/start', { turn: 1 }), at(2, 'deliverables/presented', { turn: 1, callId: 'nested', files: Array.from({ length: 8 }, (_, i) => file(`report-${i}.docx`)) }), ]) - const owner = tailOwner(deliverablesOf(value), 3) + const preview = vi.fn() + const owner = tailOwner(deliverablesOf(value), 3, preview) const matched = selectDeliverables(owner)! const props = openProps() props.openPresented.mockResolvedValue(undefined) const view = render() - expect(view.getAllByRole('button')).toHaveLength(16) + expect(view.container.querySelectorAll('[data-presented-file]')).toHaveLength(4) + const expand = view.getByRole('button', { name: 'Show all 8 delivered files' }) + expect(expand.getAttribute('aria-expanded')).toBe('false') + fireEvent.click(expand) + expect(view.container.querySelectorAll('[data-presented-file]')).toHaveLength(8) + expect(view.getByRole('button', { name: 'Collapse delivered files' }).getAttribute('aria-expanded')).toBe('true') expect(view.queryByRole('link')).toBeNull() - fireEvent.click(view.getByRole('button', { name: 'Open report-0.docx in default app' })) + fireEvent.click(view.getByRole('button', { name: 'Preview report-0.docx in sidebar' })) + fireEvent.click(view.getByRole('button', { name: 'Open report-0.docx in sidebar' })) + expect(preview).toHaveBeenCalledTimes(2) + expect(preview).toHaveBeenLastCalledWith('report-0.docx') + fireEvent.click(view.getByRole('button', { name: 'More file actions for report-0.docx' })) + fireEvent.click(view.getByRole('menuitem', { name: 'Open in default app' })) expect(props.openPresented).toHaveBeenCalledWith('child-session', 2, 0, 'open') + fireEvent.click(view.getByRole('button', { name: 'Collapse delivered files' })) + expect(view.container.querySelectorAll('[data-presented-file]')).toHaveLength(4) expect(view.queryByText('Files changed')).toBeNull() }) }) @@ -628,15 +641,23 @@ it.each([{}, { turn: '1', callId: 'bad', files: [] }, expect(view.queryByText('Deliverables')).toBeNull() }) -it('shows file metadata and descriptions without hiding extensionless deliveries', () => { +it('shows descriptions and falls back to file metadata without hiding extensionless deliveries', () => { const view = render( {}} sessionId={SessionId('session')} t={makeTranslate(en)} />) expect(view.getByText('Quarterly summary')).toBeTruthy() - expect(view.getByText('TXT')).toBeTruthy() expect(view.getByText('File')).toBeTruthy() - expect(view.getByText('out/report.txt')).toBeTruthy() + expect(view.getByTitle('out/report.txt')).toBeTruthy() + expect(view.getByText('report.txt')).toBeTruthy() +}) + +it('lets one delivered file span the complete row without an expansion control', () => { + const view = render( {}} sessionId={SessionId('session')} t={makeTranslate(en)} />) + expect(view.container.querySelector('[data-presented-files-row]')?.getAttribute('data-single')).toBe('true') + expect(view.queryByRole('button', { name: /delivered files/ })).toBeNull() }) @@ -647,8 +668,8 @@ it.each(['opening', 'opened', 'error'] as const)('shows the %s state and permits const view = render( {}} sessionId={SessionId('session')} t={makeTranslate(en)} />) - expect(view.getByRole('status').textContent).toBe(en[`presented.${phase}`]) - expect((view.getByRole('button', { name: 'Open report.txt in default app' }) as HTMLButtonElement).disabled).toBe(phase === 'opening') + expect(view.getByText(en[`presented.${phase}`])).toBeTruthy() + expect((view.getByRole('button', { name: 'More file actions for report.txt' }) as HTMLButtonElement).disabled).toBe(phase === 'opening') }) diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index 23ad8cebb9..f5112b64f1 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -462,7 +462,7 @@ export const IconRightUpOutline14 = ({ size = 8, className }: IconProps) => ( export const IconRightUpOutline16 = ({ size = 16, className }: IconProps) => ( diff --git a/snapshots/web/present/ui.expected.md b/snapshots/web/present/ui.expected.md index 1346420ed9..534e366533 100644 --- a/snapshots/web/present/ui.expected.md +++ b/snapshots/web/present/ui.expected.md @@ -63,24 +63,18 @@ - code: AFTER_PRESENT - text: — no retries, no extra files. - paragraph: PRESENT_DONE -- text: Deliverables Opens on the DSH Host report.txt TXT delivered report -- img +- button "Preview report.txt in sidebar" - text: report.txt -- button "Open report.txt in default app": - - img - - text: Open +- status: Opened in default app +- button "Open report.txt in sidebar": Open - button "More file actions for report.txt": - img -- status: Opened in default app -- text: 说明.txt TXT delivered note -- img +- button "Preview 说明.txt in sidebar" - text: 说明.txt -- button "Open 说明.txt in default app": - - img - - text: Open +- status: Opened in default app +- button "Open 说明.txt in sidebar": Open - button "More file actions for 说明.txt": - img -- status: Opened in default app - button "Copy": - img - button "Good response": From 74bf7aaa2fdd56c419e46292215d15d1a3efee63 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 9 Sep 2026 16:18:24 +0800 Subject: [PATCH 07/20] fix(web): preserve artifact actions across reconnects and keyboard use --- ...8-present-workspace-source-files.i18n.yaml | 4 +- ...26-09-08-present-workspace-source-files.md | 2 +- ...09-08-present-workspace-source-files.zh.md | 2 +- apps/web/tests/present.e2e.ts | 2 + .../client/ui-deliverables/README.i18n.yaml | 4 +- packages/client/ui-deliverables/README.md | 2 +- packages/client/ui-deliverables/README.zh.md | 2 +- .../src/client/PresentedFileCard.tsx | 16 ++++++-- .../ui-deliverables/src/client/index.ts | 2 + .../ui-deliverables/src/client/locales.ts | 12 ++++-- .../src/client/present-open.ts | 26 ++++++++++--- .../tests/present-open.client.spec.ts | 36 +++++++++++++++++ .../tests/presented-file-card.client.spec.tsx | 36 +++++++++++++++++ .../tests/produced-files.client.spec.tsx | 11 ++++++ .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- packages/client/ui-primitives/src/Menu.tsx | 2 +- packages/util/native-command/README.i18n.yaml | 4 +- packages/util/native-command/README.md | 2 +- packages/util/native-command/README.zh.md | 2 +- .../util/native-command/src/path-opener.ts | 13 ++++++- .../native-command/tests/path-opener.spec.ts | 39 ++++++++++++++++++- 23 files changed, 192 insertions(+), 35 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml index 3c1e280e32..715341ab78 100644 --- a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.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 .agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md -2026-09-08-present-workspace-source-files.md: 0dade8823d1385a28b7d9fe6085895d64dd9e898 -2026-09-08-present-workspace-source-files.zh.md: d849b62870a42ad3643e8909f70bf9c29f9115fa +2026-09-08-present-workspace-source-files.md: b8956b17a20090847da6de3c301254a73701e6f2 +2026-09-08-present-workspace-source-files.zh.md: 2e82f4e27a99f484005243a4769f3aadad290666 diff --git a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md index 0dade8823d..b8956b17a2 100644 --- a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md +++ b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md @@ -18,7 +18,7 @@ The tool remains an ordinary package with shared filesystem and tool error class An authenticated POST selects a declaration by viewed Session, event sequence, and original file index. The event carries no owning Session ID; relative paths in inherited history resolve against the viewed Session's workspace. The Host rechecks canonical workspace containment and regular-file existence before native opening. Route disposal cancels and awaits pending commands. The “Files changed” row lists successful file-tool mutations and retains its separate text-preview behavior. Its Chinese label is “本轮文件改动”; neither label implies final delivery. -File cards use the same split-control pattern as the Session header. The card and the left Open segment preview the source in the right Sidebar; the chevron opens the standard menu for default-app and file-manager actions. The Host selects the file in Finder or Explorer, or opens its containing folder through the default Linux file manager. Both native actions resolve the same saved declaration and recheck workspace containment; neither accepts a browser-supplied replacement path. Host-derived desktop metadata keeps remote-browser labels and availability honest, and the route enforces the configured availability on each native gesture. One delivery spans the row; multiple deliveries use at most two columns, retain every declaration, and collapse after the first four cards until the user expands the list. +File cards use the same split-control pattern as the Session header. The card and the left Open segment preview the source in the right Sidebar; the chevron opens the standard menu for default-app and file-manager actions. The Host selects the file in Finder or Explorer, or opens its containing folder through the default Linux file manager. Both native actions resolve the same saved declaration and recheck workspace containment; neither accepts a browser-supplied replacement path. Host-derived desktop metadata keeps remote-browser labels and availability honest, and the route enforces the configured availability on each native gesture. One delivery spans the row; multiple deliveries use at most two columns, retain every declaration, and collapse after the first four cards until the user expands the list. Desktop metadata is invalidated with the connection generation so an old Host cannot keep native actions disabled or supply the wrong file-manager labels. Old metadata requests are cancelled and cannot replace the new generation’s response. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md index d849b62870..2e82f4e27a 100644 --- a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md +++ b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md @@ -18,7 +18,7 @@ Status: implemented 经过认证的 POST 按当前查看的 Session、事件序号和原始文件索引选择声明。事件不携带所属 Session ID;继承历史中的相对路径按当前查看的 Session 工作区解析。Host 在原生打开前重新检查规范路径的工作区包含关系和普通文件是否存在。路由释放时取消并等待进行中的命令。“本轮文件改动”行列出成功的文件工具修改,并保留独立的文本预览行为。其英文标签为“Files changed”;两个标签均不表示最终交付。 -文件卡片采用与 Session 顶栏相同的分段控件。点击卡片或左侧“打开”区域会在右侧 Sidebar 预览源文件;右侧箭头打开包含默认应用与文件管理器操作的标准菜单。Host 在 Finder 或文件资源管理器中选中文件,或通过 Linux 默认文件管理器打开所在文件夹。两个原生操作都解析同一份已保存声明并重新检查工作区包含关系;均不接受浏览器提供的替代路径。来自 Host 的桌面信息使远程浏览器中的文案和可用性保持准确,路由在每次原生操作时执行配置的可用性检查。单个交付占满整行;多个交付每行最多两列,并保留所有声明,前四张卡片之后的内容在用户展开列表前保持收起。 +文件卡片采用与 Session 顶栏相同的分段控件。点击卡片或左侧“打开”区域会在右侧 Sidebar 预览源文件;右侧箭头打开包含默认应用与文件管理器操作的标准菜单。Host 在 Finder 或文件资源管理器中选中文件,或通过 Linux 默认文件管理器打开所在文件夹。两个原生操作都解析同一份已保存声明并重新检查工作区包含关系;均不接受浏览器提供的替代路径。来自 Host 的桌面信息使远程浏览器中的文案和可用性保持准确,路由在每次原生操作时执行配置的可用性检查。单个交付占满整行;多个交付每行最多两列,并保留所有声明,前四张卡片之后的内容在用户展开列表前保持收起。 桌面元数据随连接代次失效,避免旧主机信息让原生操作持续禁用或显示错误的文件管理器名称。旧元数据请求会被取消,不能覆盖新代次的响应。 ## 考虑过的替代方案 diff --git a/apps/web/tests/present.e2e.ts b/apps/web/tests/present.e2e.ts index 7b2f110f76..d1b4bc713c 100644 --- a/apps/web/tests/present.e2e.ts +++ b/apps/web/tests/present.e2e.ts @@ -128,6 +128,8 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con const revealResponse = page.waitForResponse(response => response.url().includes('action=reveal') && response.request().method() === 'POST') await page.getByRole('menuitem', { name: process.platform === 'darwin' ? /Show in Finder/ : /Open containing folder/ }).click() expect((await revealResponse).status()).toBe(204) + expect(await row.getByRole('button', { name: 'Open report.txt in sidebar', exact: true }) + .evaluate(button => button === document.activeElement)).toBe(true) await expect.poll(opened).toHaveLength(beforeReveal + 1) expect((await opened()).at(-1)).toEqual({ action: 'reveal', content: null, path: await realpath(process.platform === 'darwin' ? join(cwd, 'report.txt') : cwd) }) for (const [name, bytes] of [['report.txt', 'EDITED_REPORT\n'], ['说明.txt', 'EDITED_NOTE\n']] as const) { diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index d5c876bb0c..ab6fce907a 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/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-deliverables/README.md -README.md: 87d4b1acbafbab3d1c35a14688671b9ae974c32c -README.zh.md: 9ad0b8102735448836445fdc80b2371ef539d241 +README.md: 9faea88696f3d6a7d39429e82449978647330a5f +README.zh.md: 8b2c846dffe674d2112f9b7680040a762d8b0c60 diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index 87d4b1acba..9faea88696 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -32,7 +32,7 @@ Mount this plugin alongside `ui-conversation`; a finished turn then ends with th The Web `standard`, `ptc`, and `cordis` presets expose `present` for final workspace files, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../fs/tool-present/README.md) owns file-count limits and Session declarations. The closing turn shows one delivery as a full-width card and multiple deliveries in a grid of at most two cards per row. A list longer than four files starts collapsed and provides a control that reveals or hides the complete list. Each card shows the basename and description, or the file type when no description exists; a trailing parenthesized suffix in the description is omitted, and hovering the card replaces that line with the Sidebar-preview action. Clicking the card or the left side of its split Open control previews the file in the right Sidebar. The chevron opens the standard menu for the Host default application plus Show in Finder on macOS, Show in File Explorer on Windows and WSL, or Open containing folder through the default Linux file manager. Matching inline-code references open the same source files without starting a browser download. Repeated declaration of a path selects its latest description before the closing reply. -The `present` tool row shows running, delivered, failed, or interrupted status; expanding a settled row reveals its recorded result. The collapsible card grid retains every delivered file. Both menu actions share pending state and show progress, acknowledgement, or an action-specific retryable error. Desktop information is read when delivery cards appear. A missing desktop disables the Open menu; a failed desktop-information read offers Retry. It requires a desktop and a suitable default application on the serving Host; a remote browser does not open applications on its own device. +The `present` tool row shows running, delivered, failed, or interrupted status; expanding a settled row reveals its recorded result. The collapsible card grid retains every delivered file. Both menu actions share pending state and show progress, acknowledgement, or an action-specific retryable error. Desktop information is read when delivery cards appear and invalidated on connection replacement; responses from a replaced connection cannot publish metadata. Selecting a native menu action returns keyboard focus to the available Sidebar Open button. Pending actions close the menu until another explicit gesture. A missing desktop disables the Open menu; a failed desktop-information read offers Retry. It requires a desktop and a suitable default application on the serving Host; a remote browser does not open applications on its own device. ### The row diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 9ad0b81027..8b2c846dff 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -32,7 +32,7 @@ kind: "package-reference" Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交付最终工作区文件,包括通过 Bash 创建的文件。创建文件后,以 `files: [{ path, description? }]` 调用。[present 工具](../../fs/tool-present/README.zh.md)拥有文件数量限制和 Session 声明。收尾 turn 把单个交付显示为横向占满内容区的卡片,把多个交付显示为每行最多两张卡片的网格。文件超过四个时,列表默认收起,并提供显示或隐藏完整列表的控件。每张卡片显示 basename 与说明;没有说明时显示文件类型,说明末尾的括号后缀会被省略,悬停卡片时该行切换为侧栏预览提示。点击卡片或分段“打开”控件的左侧会在右侧 Sidebar 中预览文件;右侧箭头打开标准菜单,其中提供 Host 默认应用,以及 macOS 上的“在 Finder 中显示”、Windows 和 WSL 上的“在文件资源管理器中显示”或 Linux 默认文件管理器的“打开所在文件夹”。匹配的行内代码引用打开相同源文件,不触发浏览器下载。同一路径重复声明时,选择收尾回复之前最近一次的说明。 -`present` 工具行显示正在交付、已交付、失败或中断状态;展开已结束的调用可查看其记录的结果。可折叠卡片网格保留全部交付文件。菜单中的两个操作共享等待状态,并显示进度、请求确认或各自可重试的错误。交付卡片出现时才读取桌面信息。Host 没有桌面时禁用“打开”菜单;桌面信息读取失败时提供“重试”。服务 Host 必须具备桌面和合适的默认应用;远程浏览器不会打开其所在设备上的应用。 +`present` 工具行显示正在交付、已交付、失败或中断状态;展开已结束的调用可查看其记录的结果。可折叠卡片网格保留全部交付文件。菜单中的两个操作共享等待状态,并显示进度、请求确认或各自可重试的错误。交付卡片出现时读取桌面信息,连接更换时清除缓存,旧连接的响应不能更新元数据。选择原生菜单操作后,键盘焦点回到仍可用的侧边栏“打开”按钮。等待操作完成时关闭菜单,用户再次点击才会打开。Host 没有桌面时禁用“打开”菜单;桌面信息读取失败时提供“重试”。服务 Host 必须具备桌面和合适的默认应用;远程浏览器不会打开其所在设备上的应用。 ### 该行 diff --git a/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx index 2ca532182f..73e29f5523 100644 --- a/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx +++ b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx @@ -1,5 +1,5 @@ /** File identity and explicit default-app or file-manager actions for one delivery. */ -import { useState } from 'react' +import { useRef, useState } from 'react' import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path' import { Menu, LinkIcon, classifyLinkPath, IconRightUpOutline16, @@ -31,15 +31,23 @@ export function PresentedFileCard({ file, cwd, phase, host, onPreview, onAction, onAction: (action: PresentedAction) => void } & PropsLocale) { const [menuOpen, setMenuOpen] = useState(false) + const previewRef = useRef(null) const pending = phase === 'opening' || phase === 'revealing' const menuDisabled = pending || host === null || !host.available + if (menuDisabled && menuOpen) setMenuOpen(false) const reveal = host?.fileManager ?? 'directory' - const act = (action: PresentedAction) => { setMenuOpen(false); onAction(action) } + const act = (action: PresentedAction) => { + setMenuOpen(false) + previewRef.current?.focus() + onAction(action) + } const name = basename(file.path) const metadata = name.match(/\.([^.]+)$/)?.[1]?.toUpperCase() ?? t('presented.file') const status = phase === undefined ? cardDescription(file.description, metadata) - : t(phase === 'revealed' && reveal === 'directory' ? 'presented.directoryOpened' : `presented.${phase}`) + : t(reveal === 'directory' && phase === 'revealed' ? 'presented.directoryOpened' + : reveal === 'directory' && phase === 'revealing' ? 'presented.directoryOpening' + : reveal === 'directory' && phase === 'revealError' ? 'presented.directoryError' : `presented.${phase}`) return
- { setMenuOpen(false) }} diff --git a/packages/client/ui-deliverables/src/client/index.ts b/packages/client/ui-deliverables/src/client/index.ts index 2a324c0be4..7ce723aa09 100644 --- a/packages/client/ui-deliverables/src/client/index.ts +++ b/packages/client/ui-deliverables/src/client/index.ts @@ -9,6 +9,7 @@ */ import type { Context as ClientContext } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-api-remotes/client' +import type {} from '@deepseek-ai/dsh-client-connection/client' import type { ChatFileMentions } from '@deepseek-ai/dsh-client-ui-chat/client' import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -41,6 +42,7 @@ export const inject = ['slots', 'locale', 'uiConversation', 'remote', 'remote.se export function apply(ctx: ClientContext): void { const opener = new PresentedOpenController() ctx.effect(() => () => opener.dispose()) + ctx.on('connection/reset', () => { opener.resetHost() }) ctx.uiConversation.events.register(deliverablesDefinition) ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-deliverables: dictionaries') ctx.slots.inject( diff --git a/packages/client/ui-deliverables/src/client/locales.ts b/packages/client/ui-deliverables/src/client/locales.ts index de5310bbf0..5b9fa7781d 100644 --- a/packages/client/ui-deliverables/src/client/locales.ts +++ b/packages/client/ui-deliverables/src/client/locales.ts @@ -5,10 +5,12 @@ export const NS = 'deliverables' /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { - 'presented.revealError': '无法打开所在文件夹,请重试', + 'presented.revealError': '无法在文件管理器中显示,请重试', + 'presented.directoryError': '无法打开所在文件夹,请重试', + 'presented.directoryOpening': '正在打开所在文件夹…', 'presented.directoryOpened': '已请求打开所在文件夹', 'presented.revealed': '已请求在文件管理器中显示', - 'presented.revealing': '正在打开所在文件夹…', + 'presented.revealing': '正在文件管理器中显示…', 'presented.unavailable': '此主机没有可用的桌面,无法打开文件或文件夹', 'presented.retry': '重试', 'presented.hostError': '无法读取主机桌面信息', @@ -44,10 +46,12 @@ export const zh = { /** English dictionary (same key set). */ export const en: Record = { - 'presented.revealError': 'Could not open containing folder. Try again.', + 'presented.revealError': 'Could not show in file manager. Try again.', + 'presented.directoryError': 'Could not open containing folder. Try again.', + 'presented.directoryOpening': 'Opening containing folder…', 'presented.directoryOpened': 'Requested opening containing folder', 'presented.revealed': 'Requested display in file manager', - 'presented.revealing': 'Opening containing folder…', + 'presented.revealing': 'Showing in file manager…', 'presented.unavailable': 'This Host has no desktop available to open files or folders', 'presented.retry': 'Retry', 'presented.hostError': 'Could not read the Host desktop information', diff --git a/packages/client/ui-deliverables/src/client/present-open.ts b/packages/client/ui-deliverables/src/client/present-open.ts index 8d74c1615f..bb47634011 100644 --- a/packages/client/ui-deliverables/src/client/present-open.ts +++ b/packages/client/ui-deliverables/src/client/present-open.ts @@ -13,6 +13,7 @@ export class PresentedOpenController { /** Native destination metadata, or a retryable read failure. */ readonly host = createSnapshotStore(null) private loading: Promise | undefined + private metadata = new AbortController() private readonly lifetime = new AbortController() private readonly pending = new Set>() @@ -47,25 +48,38 @@ export class PresentedOpenController { if (this.lifetime.signal.aborted) return if (this.loading !== undefined) return this.loading this.host.set(null) - const task = this.readHost() + const task = this.readHost(AbortSignal.any([this.lifetime.signal, this.metadata.signal])) this.loading = task this.pending.add(task) try { await task } - finally { this.loading = undefined; this.pending.delete(task) } + finally { + if (this.loading === task) this.loading = undefined + this.pending.delete(task) + } } - private async readHost(): Promise { + /** Invalidate desktop metadata on connection replacement; mounted cards request the new Host. */ + resetHost(): void { + const wasLoading = this.loading !== undefined + this.metadata.abort() + this.metadata = new AbortController() + this.loading = undefined + this.host.set(null) + if (wasLoading) void this.loadHost() + } + + private async readHost(signal: AbortSignal): Promise { let host: PresentedHost | 'error' = 'error' try { - const response = await fetch(PRESENT_HOST_PATH, { signal: this.lifetime.signal }) + const response = await fetch(PRESENT_HOST_PATH, { signal }) if (response.ok) { const value: unknown = await response.json() if (isPresentedHost(value)) host = value } } catch { - // HTTP, JSON, and transport failures leave a retryable metadata read. + host = 'error' } - if (!this.lifetime.signal.aborted) this.host.set(host) + if (!signal.aborted) this.host.set(host) } /** Cancel outstanding requests and wait until no request can publish state. */ diff --git a/packages/client/ui-deliverables/tests/present-open.client.spec.ts b/packages/client/ui-deliverables/tests/present-open.client.spec.ts index aa022adf4c..bdcaaab3b9 100644 --- a/packages/client/ui-deliverables/tests/present-open.client.spec.ts +++ b/packages/client/ui-deliverables/tests/present-open.client.spec.ts @@ -116,3 +116,39 @@ it('coalesces metadata reads and suppresses their publication after disposal', a await Promise.all([first, second, disposal]) expect(controller.host.getSnapshot()).toBeNull() }) + + +it('invalidates cached desktop metadata without eagerly fetching an unused Host', async () => { + const fetcher = vi.fn().mockResolvedValue(Response.json({ name: 'old', available: false, fileManager: null })) + vi.stubGlobal('fetch', fetcher) + const controller = new PresentedOpenController() + await controller.loadHost() + controller.resetHost() + expect(controller.host.getSnapshot()).toBeNull() + expect(fetcher).toHaveBeenCalledOnce() + fetcher.mockResolvedValue(Response.json({ name: 'new', available: true, fileManager: 'finder' })) + await controller.loadHost() + expect(controller.host.getSnapshot()).toMatchObject({ name: 'new', available: true }) + await controller.dispose() +}) + +it('discards a replaced Host response and keeps the new metadata request coalesced', async () => { + const oldReply = Promise.withResolvers() + const newReply = Promise.withResolvers() + const fetcher = vi.fn().mockReturnValueOnce(oldReply.promise).mockReturnValue(newReply.promise) + vi.stubGlobal('fetch', fetcher) + const controller = new PresentedOpenController() + const oldLoad = controller.loadHost() + controller.resetHost() + expect((fetcher.mock.calls[0]?.[1] as RequestInit).signal?.aborted).toBe(true) + const newLoad = controller.loadHost() + oldReply.resolve(Response.json({ name: 'old', available: false, fileManager: null })) + await oldLoad + expect(controller.host.getSnapshot()).toBeNull() + const coalesced = controller.loadHost() + expect(fetcher).toHaveBeenCalledTimes(2) + newReply.resolve(Response.json({ name: 'new', available: true, fileManager: 'finder' })) + await Promise.all([newLoad, coalesced]) + expect(controller.host.getSnapshot()).toMatchObject({ name: 'new' }) + await controller.dispose() +}) diff --git a/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx b/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx index 0f060643b4..28207b5a29 100644 --- a/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx @@ -128,3 +128,39 @@ it.each([ expect(view.getByText(expected)).toBeTruthy() expect(view.queryByText(description)).toBeNull() }) + + +it('does not reopen a menu after a shared native request settles', () => { + const p = props() + const view = render() + fireEvent.click(view.getByRole('button', { name: 'More file actions for out/report.pdf' })) + expect(view.getByRole('menu')).toBeTruthy() + view.rerender() + expect(view.queryByRole('menu')).toBeNull() + view.rerender() + expect(view.queryByRole('menu')).toBeNull() +}) + +it('keeps focus on the available preview button after selecting a native action', () => { + const p = props() + const view = render() + fireEvent.click(view.getByRole('button', { name: 'More file actions for out/report.pdf' })) + const item = view.getByRole('menuitem', { name: 'Show in Finder' }) + item.focus() + fireEvent.click(item) + view.rerender() + const preview = view.getByRole('button', { name: 'Open out/report.pdf in sidebar' }) + expect(document.activeElement).toBe(preview) + view.rerender() + expect(document.activeElement).toBe(preview) +}) + +it.each([en, zh])('distinguishes directory-only progress and errors in each locale', (dictionary) => { + const p = { ...props(), t: makeTranslate(dictionary) } + const view = render() + expect(view.getByText(dictionary['presented.revealing'])).toBeTruthy() + view.rerender() + expect(view.getByText(dictionary['presented.directoryOpening'])).toBeTruthy() + view.rerender() + expect(view.getByText(dictionary['presented.directoryError'])).toBeTruthy() +}) diff --git a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx index 3c72544468..4b7ae935b0 100644 --- a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx @@ -555,13 +555,24 @@ describe('plugin registration', () => { service?.forClosing(delivered, SessionId('child-session'))?.resolve('report.docx')?.open() expect(fetcher).toHaveBeenCalledWith('/api/present.open?sessionId=child-session&seq=2&index=0', { method: 'POST', signal: expect.any(AbortSignal) as AbortSignal }) const face = entry!.inject!(SessionId('child-session') as never) as unknown as DeliverablesInjected + fetcher.mockResolvedValueOnce(Response.json({ name: 'desktop', available: true, fileManager: 'finder' })) await face.reloadPresentedHost() + expect(face.hooks.presentedHost.getSnapshot()).toMatchObject({ name: 'desktop' }) + ctx.emit('connection/reset') + expect(face.hooks.presentedHost.getSnapshot()).toBeNull() await face.openPresented(SessionId('child-session'), 2, 0) expect(face.hooks.presentedOpen.getSnapshot()['/api/present.open?sessionId=child-session&seq=2&index=0']).toBe('opened') // A turn that produced nothing yields no vocabulary at all. expect(service?.forClosing(tailOwner(undefined, 2), SessionId('viewed-session'))).toBeUndefined() + fetcher.mockResolvedValueOnce(Response.json({ name: 'last-host', available: true, fileManager: 'finder' })) + await face.reloadPresentedHost() await fiber.dispose() + const reset = vi.fn() + const unsubscribe = face.hooks.presentedHost.subscribe(reset) + ctx.emit('connection/reset') + expect(reset).not.toHaveBeenCalled() + unsubscribe() expect(ctx.slots.entries('conversation.chat.turnTail')).toHaveLength(0) expect(ctx.slots.entries('tool.call.toolview')).toHaveLength(0) // Fiber teardown retracts the service: the consumer's ctx.get sees the off state. diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index fc91ac631b..361c454a4d 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/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-primitives/README.md -README.md: 194fc2a3ac97a8b40bb6c385af3b4ee6b3930b52 -README.zh.md: e2dcea7a7c03556fef789db21cef8187c8b72d66 +README.md: 1c3bb7a7d36884234340b29da9f8763e46796e61 +README.zh.md: a8bc94f4afeb5c13d95ccafea4ef1b926524abd0 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 194fc2a3ac..1c3bb7a7d3 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -67,7 +67,7 @@ Writing your own component in your own package is fine when the need is genuinel ### Controls and icons -The catalog above lists what each export is for; this section covers the behavior that props alone do not show. The `ic_ds_*` icon set and `FishLogo`/`BrandWordmark` marks fill brand and inline-icon slots. `LinkIcon` draws the leading category glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, all riding `currentColor` — and `classifyLinkPath` derives a file path's category from its extension. `FileTypeIcon` is the one glyph that does not ride `currentColor`: its sheet colour is the type's identity, so a consumer that wants it muted (an empty state) applies `filter: grayscale(1)` itself. `classifyFileType` and `classifyLinkPath` read one extension vocabulary (`file-extensions.ts`), so a path classifies consistently on a link and on a sheet; the sheet is the finer of the two. `ConnectionIndicator` renders a warning-colored disconnected action, a connecting label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Hover or keyboard focus shows only the reconnect action label, including while the connecting dots animate. Every state reserves the widest supplied label and uses fixed icon and text columns, so copy changes do not move or resize the control. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. `Button.segment` joins the start and end controls of a split action without changing standalone button geometry. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and restores the trigger on Escape; it is opt-in for action menus. +The catalog above lists what each export is for; this section covers the behavior that props alone do not show. The `ic_ds_*` icon set and `FishLogo`/`BrandWordmark` marks fill brand and inline-icon slots. `LinkIcon` draws the leading category glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, all riding `currentColor` — and `classifyLinkPath` derives a file path's category from its extension. `FileTypeIcon` is the one glyph that does not ride `currentColor`: its sheet colour is the type's identity, so a consumer that wants it muted (an empty state) applies `filter: grayscale(1)` itself. `classifyFileType` and `classifyLinkPath` read one extension vocabulary (`file-extensions.ts`), so a path classifies consistently on a link and on a sheet; the sheet is the finer of the two. `ConnectionIndicator` renders a warning-colored disconnected action, a connecting label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Hover or keyboard focus shows only the reconnect action label, including while the connecting dots animate. Every state reserves the widest supplied label and uses fixed icon and text columns, so copy changes do not move or resize the control. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. `Button.segment` joins the start and end controls of a split action without changing standalone button geometry. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus. ### Rendering agent output diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index e2dcea7a7c..a8bc94f4af 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -67,7 +67,7 @@ kind: "package-library" ### 控件与图标 -上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。`ic_ds_*` 图标集与 `FishLogo`/`BrandWordmark` 标记填充品牌与行内图标 slot。`LinkIcon` 为可点击产物链接绘制前置分类图形——地球、文件夹、代码、图片、文档或纸张,全部随 `currentColor`——`classifyLinkPath` 按扩展名推导文件路径的类别。`FileTypeIcon` 是唯一不随 `currentColor` 的字形:纸片颜色就是类型的身份,需要压灰的使用方(空状态)自行加 `filter: grayscale(1)`。`classifyFileType` 与 `classifyLinkPath` 读同一份扩展名词表(`file-extensions.ts`),一条路径在链接上与纸片上的归类一致;纸片分得更细。`ConnectionIndicator` 可渲染警告色的断联操作、以独立于 retry 时序的 500ms 节奏推进一至三个点的连接中状态,或成功色的恢复状态。悬停或键盘聚焦时只显示重连操作文案,连接中的圆点动画也保持隐藏。所有状态都为最长的输入 label 预留空间,并使用固定的图标列和文字列,因此文案变化不会移动控件或改变其宽度。它的 owner 提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。 `Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。 `rankByName` 是 `/` 菜单命令源与 skill 源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 `Button.segment` 将分段操作的首尾按钮连接起来,不改变独立按钮的几何样式。`Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时将焦点还给触发按钮;操作菜单可显式启用。 +上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。`ic_ds_*` 图标集与 `FishLogo`/`BrandWordmark` 标记填充品牌与行内图标 slot。`LinkIcon` 为可点击产物链接绘制前置分类图形——地球、文件夹、代码、图片、文档或纸张,全部随 `currentColor`——`classifyLinkPath` 按扩展名推导文件路径的类别。`FileTypeIcon` 是唯一不随 `currentColor` 的字形:纸片颜色就是类型的身份,需要压灰的使用方(空状态)自行加 `filter: grayscale(1)`。`classifyFileType` 与 `classifyLinkPath` 读同一份扩展名词表(`file-extensions.ts`),一条路径在链接上与纸片上的归类一致;纸片分得更细。`ConnectionIndicator` 可渲染警告色的断联操作、以独立于 retry 时序的 500ms 节奏推进一至三个点的连接中状态,或成功色的恢复状态。悬停或键盘聚焦时只显示重连操作文案,连接中的圆点动画也保持隐藏。所有状态都为最长的输入 label 预留空间,并使用固定的图标列和文字列,因此文案变化不会移动控件或改变其宽度。它的 owner 提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。 `Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。 `rankByName` 是 `/` 菜单命令源与 skill 源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 `Button.segment` 将分段操作的首尾按钮连接起来,不改变独立按钮的几何样式。`Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。 ### 渲染 agent 输出 diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx index ed19fbb70c..edc4825bc1 100644 --- a/packages/client/ui-primitives/src/Menu.tsx +++ b/packages/client/ui-primitives/src/Menu.tsx @@ -48,7 +48,7 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } /** * Render an anchored dropdown menu. - * @param props.autoFocus - focus the first item on open and enable arrow-key navigation; Escape restores the trigger. + * @param props.autoFocus - focus the first item on open and enable arrow-key navigation; Escape focuses the anchor's first button. * @param props.open - whether the list is showing (owner-controlled). * @param props.anchor - the trigger element (rendered in place). * @param props.items - selectable rows and optional separators. diff --git a/packages/util/native-command/README.i18n.yaml b/packages/util/native-command/README.i18n.yaml index 5dba1e1698..6e9e0b3aad 100644 --- a/packages/util/native-command/README.i18n.yaml +++ b/packages/util/native-command/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/util/native-command/README.md -README.md: b127c1f03fc91282cd2a847af84f8183f147f9ac -README.zh.md: 7ff612fe9bead184ace2b5ee2c54700c8d673df0 +README.md: b0b1963a97c6d528c123d4900db529bd3dc38332 +README.zh.md: 6a245e3473a4e4dd9944dc53898e04be2fcba2e4 diff --git a/packages/util/native-command/README.md b/packages/util/native-command/README.md index b127c1f03f..b0b1963a97 100644 --- a/packages/util/native-command/README.md +++ b/packages/util/native-command/README.md @@ -47,7 +47,7 @@ The `NativeCommandRunner` type is the injectable command boundary for host integ `openNativePath(path, signal)` hands a path to the default application and prefers the named default browser for HTML and SVG where the platform can identify one. `openNativeTextFile(path, signal)` selects text-editor intent; on macOS it uses `open -t`. WSL paths are translated with `wslpath -w` before the Windows desktop receives them. `canOpenNativePath()` reports whether the current Host plausibly has a desktop target. -`revealNativePath(path, signal)` selects the file in Finder or Explorer, including WSL path translation, and opens its parent directory through `xdg-open` on desktop Linux. `nativeFileManager()` identifies that action for Host-derived UI labels; desktop availability remains a separate `canOpenNativePath()` check. Callers must authorize the absolute file path before invoking either operation. Platform dispatch is covered by injected-runner tests; native desktop verification belongs to the corresponding platform. +`revealNativePath(path, signal)` selects the file in Finder or Explorer, including WSL path translation, and opens its parent directory through `xdg-open` on desktop Linux. `nativeFileManager()` identifies that action for Host-derived UI labels; desktop availability remains a separate `canOpenNativePath()` check. Callers must authorize the absolute file path before invoking either operation. Platform dispatch is covered by injected-runner tests; native desktop verification belongs to the corresponding platform. Explorer receives an encoded file URI as a separate argument. Its exit code 1 is accepted as a delegated handoff; cancellation, missing executables, and other exit codes still reject. This acknowledgement does not prove that a desktop window selected the file. ----- diff --git a/packages/util/native-command/README.zh.md b/packages/util/native-command/README.zh.md index 7ff612fe9b..6a245e3473 100644 --- a/packages/util/native-command/README.zh.md +++ b/packages/util/native-command/README.zh.md @@ -47,7 +47,7 @@ const { stdout, stderr } = await runNativeCommand('osascript', ['-e', script], s `openNativePath(path, signal)` 将路径交给默认应用;平台能够确定默认浏览器时,HTML 与 SVG 会优先交给该浏览器。`openNativeTextFile(path, signal)` 选择文本编辑器意图;macOS 使用 `open -t`。WSL 路径先通过 `wslpath -w` 转换,再交给 Windows 桌面。`canOpenNativePath()` 报告当前 Host 是否可能具备桌面目标。 -`revealNativePath(path, signal)` 在 Finder 或文件资源管理器中选中文件,包含 WSL 路径转换;在桌面 Linux 上通过 `xdg-open` 打开上层目录。`nativeFileManager()` 标识该操作,供 UI 根据 Host 选择文案;桌面是否可用仍由独立的 `canOpenNativePath()` 检查决定。调用方必须先授权绝对文件路径,再执行操作。平台分派由注入运行器的测试覆盖;原生桌面验证由对应平台负责。 +`revealNativePath(path, signal)` 在 Finder 或文件资源管理器中选中文件,包含 WSL 路径转换;在桌面 Linux 上通过 `xdg-open` 打开上层目录。`nativeFileManager()` 标识该操作,供 UI 根据 Host 选择文案;桌面是否可用仍由独立的 `canOpenNativePath()` 检查决定。调用方必须先授权绝对文件路径,再执行操作。平台分派由注入运行器的测试覆盖;原生桌面验证由对应平台负责。 Explorer 接收独立参数中的编码文件 URI。退出码 1 按已转交请求处理;取消、找不到可执行文件和其他退出码仍然报错。该确认不能证明桌面窗口已选中文件。 ----- diff --git a/packages/util/native-command/src/path-opener.ts b/packages/util/native-command/src/path-opener.ts index 0d8dcb32da..8f3443b3c1 100644 --- a/packages/util/native-command/src/path-opener.ts +++ b/packages/util/native-command/src/path-opener.ts @@ -11,6 +11,7 @@ import { release as osRelease } from 'node:os' import { dirname, extname } from 'node:path' +import { pathToFileURL } from 'node:url' import { runNativeCommand, type NativeCommandRunner } from './runner.ts' /** Testable command boundary; native implementations never invoke a shell. */ @@ -222,7 +223,7 @@ export function nativeFileManager(internals: PathOpenerInternals = {}): NativeFi * @param path - absolute file path already authorized by the caller. * @param signal - caller lifetime; abort terminates the native command. * @param internals - platform, environment, and command runner for adapter tests. - * @returns after the file-manager command accepts the request; launch failures reject. + * @returns after command completion; Explorer exit 1 is accepted as a delegated handoff, not proof of selection. */ export async function revealNativePath( path: string, signal: AbortSignal, internals: PathOpenerInternals = {}, @@ -243,7 +244,15 @@ export async function revealNativePath( windowsPath = translated.stdout.replace(/[\r\n]+$/, '') if (windowsPath === '') throw new Error('wslpath returned no Windows path') } - await run('explorer.exe', [`/select,${windowsPath}`], signal) + // Explorer parses commas itself; a file URI preserves commas and whitespace in the path. + const target = pathToFileURL(windowsPath, { windows: true }).href.replaceAll(',', '%2C') + try { + await run('explorer.exe', ['/select,', target], signal) + } catch (error) { + signal.throwIfAborted() + // Explorer can exit 1 after delegating to the existing desktop process. + if (!(error instanceof Error) || !('code' in error) || error.code !== 1) throw error + } return } if (manager === 'directory') { diff --git a/packages/util/native-command/tests/path-opener.spec.ts b/packages/util/native-command/tests/path-opener.spec.ts index 347588095d..5082d141ed 100644 --- a/packages/util/native-command/tests/path-opener.spec.ts +++ b/packages/util/native-command/tests/path-opener.spec.ts @@ -331,7 +331,7 @@ describe('canOpenNativePath', () => { describe('native file manager', () => { it.each([ ['darwin', 'finder', '/tmp/my report.txt', 'open', ['-R', '/tmp/my report.txt']], - ['win32', 'explorer', 'C:\\work\\my report.txt', 'explorer.exe', ['/select,C:\\work\\my report.txt']], + ['win32', 'explorer', 'C:\\work\\my report.txt', 'explorer.exe', ['/select,', 'file:///C:/work/my%20report.txt']], ['linux', 'directory', '/tmp/a $b; report.txt', 'xdg-open', ['/tmp']], ] as const)('reveals through %s without opening the file association', async (platform, manager, path, command, args) => { const run = vi.fn(async () => ({ stdout: '', stderr: '' })) @@ -347,7 +347,7 @@ describe('native file manager', () => { expect(nativeFileManager(internals)).toBe('explorer') await revealNativePath('/mnt/c/work/报告.txt', signal(), internals) expect(run.mock.calls.map(([cmd, args]) => [cmd, args])).toEqual([ - ['wslpath', ['-w', '/mnt/c/work/报告.txt']], ['explorer.exe', ['/select,C:\\work\\报告.txt']], + ['wslpath', ['-w', '/mnt/c/work/报告.txt']], ['explorer.exe', ['/select,', 'file:///C:/work/%E6%8A%A5%E5%91%8A.txt']], ]) }) @@ -378,3 +378,38 @@ it('uses the native runner for a file-manager handoff when none is injected', as await revealNativePath('/tmp/report.txt', signal()) expect(execFileMock).toHaveBeenCalled() }) + + +it.each(['win32', 'linux'] as const)('accepts Explorer delegate exit 1 through the native runner on %s', async (platform) => { + execFileMock.mockImplementation((command, _args, _options, callback) => { + if (command === 'wslpath') callback(null, 'C:\\work\\report.txt', '') + else callback(Object.assign(new Error('delegated'), { code: 1 }), '', '') + }) + await expect(revealNativePath(platform === 'win32' ? 'C:\\work\\report.txt' : '/mnt/c/work/report.txt', signal(), + { platform, env: { WSL_DISTRO_NAME: 'Ubuntu' } })).resolves.toBeUndefined() +}) + +it.each([2, 'ENOENT', undefined])('preserves Explorer failure %s', async (code) => { + const failure = Object.assign(new Error('launch failed'), code === undefined ? {} : { code }) + execFileMock.mockImplementation((_command, _args, _options, callback) => { callback(failure, '', '') }) + await expect(revealNativePath('C:\\file.txt', signal(), { platform: 'win32' })).rejects.toMatchObject({ code }) +}) + +it('preserves cancellation even when Explorer returns delegate exit 1', async () => { + const abort = new AbortController() + const reason = new Error('cancelled') + const run = vi.fn(async () => { + abort.abort(reason) + throw Object.assign(new Error('delegated'), { code: 1 }) + }) + await expect(revealNativePath('C:\\file.txt', abort.signal, { platform: 'win32', run })).rejects.toBe(reason) +}) + +it.each([ + ['C:\\my files\\报告,#%.txt', 'file:///C:/my%20files/%E6%8A%A5%E5%91%8A%2C%23%25.txt'], + ['\\\\server\\share\\a,b.txt', 'file://server/share/a%2Cb.txt'], +])('preserves special characters in the Explorer target %s', async (path, target) => { + const run = vi.fn().mockResolvedValue({ stdout: '', stderr: '' }) + await revealNativePath(path, signal(), { platform: 'win32', run }) + expect(run).toHaveBeenCalledWith('explorer.exe', ['/select,', target], expect.any(AbortSignal)) +}) From 4ee9e055d549cd45850d549e5c375dc15d5fba83 Mon Sep 17 00:00:00 2001 From: yixiangihsiang Date: Wed, 9 Sep 2026 16:37:44 +0800 Subject: [PATCH 08/20] feat(client): add shared file type icons --- ...-09-04-web-clickable-link-styles.i18n.yaml | 4 +- .../2026-09-04-web-clickable-link-styles.md | 6 +- ...2026-09-04-web-clickable-link-styles.zh.md | 6 +- ...026-09-08-shared-file-type-icons.i18n.yaml | 6 + .../2026-09-08-shared-file-type-icons.md | 43 ++ .../2026-09-08-shared-file-type-icons.zh.md | 43 ++ .../client/ui-attachment/src/FileCard.tsx | 14 +- .../composer-attachments.client.spec.tsx | 13 + .../src/client/chat/MessageItem.module.css | 2 +- .../ui-chat/src/client/chat/MessageItem.tsx | 12 +- .../tests/chat-branch-tails.client.spec.tsx | 4 + .../src/client/queue/QueueDock.tsx | 4 +- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 8 +- packages/client/ui-primitives/README.zh.md | 8 +- .../client/ui-primitives/src/CodeFileIcon.tsx | 471 ++++++++++++++++++ .../ui-primitives/src/DocumentFileIcon.tsx | 2 +- .../ui-primitives/src/FileTypeIcon.module.css | 50 ++ .../client/ui-primitives/src/FileTypeIcon.tsx | 394 +++++++++------ .../client/ui-primitives/src/LinkIcon.tsx | 31 +- .../ui-primitives/src/code-file-types.ts | 339 +++++++++++++ .../ui-primitives/src/file-extensions.ts | 59 --- packages/client/ui-primitives/src/index.ts | 6 +- .../tests/code-file-icon.client.spec.tsx | 123 +++++ .../file-type-icon-styles.client.spec.ts | 38 ++ .../tests/file-type-icon.client.spec.tsx | 136 +++-- .../tests/link-icon.client.spec.tsx | 4 + .../tests/text-title.client.spec.tsx | 4 +- .../tests/files-body.client.spec.tsx | 3 + 29 files changed, 1539 insertions(+), 298 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md create mode 100644 .agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md create mode 100644 packages/client/ui-primitives/src/CodeFileIcon.tsx create mode 100644 packages/client/ui-primitives/src/FileTypeIcon.module.css create mode 100644 packages/client/ui-primitives/src/code-file-types.ts delete mode 100644 packages/client/ui-primitives/src/file-extensions.ts create mode 100644 packages/client/ui-primitives/tests/code-file-icon.client.spec.tsx create mode 100644 packages/client/ui-primitives/tests/file-type-icon-styles.client.spec.ts diff --git a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.i18n.yaml b/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.i18n.yaml index 4aec891a9f..ddf0632164 100644 --- a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.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 .agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md -2026-09-04-web-clickable-link-styles.md: 8781990a40de30e26d0c59c0ecc1a08ba41643ac -2026-09-04-web-clickable-link-styles.zh.md: 7b5fde4c87319f6ae34b76d1c8e712e6f9f18392 +2026-09-04-web-clickable-link-styles.md: 6fa61945143853a2812468638dafd23ebcd456dc +2026-09-04-web-clickable-link-styles.zh.md: 0b15a6c72df9151ffb79f7fb41d582e191e79be6 diff --git a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md b/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md index 8781990a40..6fa6194514 100644 --- a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md +++ b/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md @@ -13,7 +13,7 @@ Clickable artifact links in the chat transcript wore four different costumes: ma One link language across the transcript's clickable-link surfaces — markdown anchors (including reference links, mailto, and URL-promoted inline code), prose file mentions, web search source links and the fetch URL, produced-file chips, and workflow member links: - Color comes through a dedicated `--dsw-alias-link` alias in `design-platform.css` (light `deepseek-500`, dark `deepseek-400`), decoupled from `state-business-primary`; links render at `font-weight: 500` with no underline at rest and `underline dotted` at 3px offset on hover/focus. -- A leading category glyph — the new `LinkIcon` in ui-primitives with kinds `url` (globe), `folder`, `code`, `image`, `document`, and `other` (paper) — renders `currentColor` only; `classifyLinkPath` derives the file kinds from the extension, and code, web, and data extensions share the code glyph by design. Two anchor shapes carry no glyph: workflow member links (an in-app member view fits no file or URL category) and anchors wrapping only images (a badge or thumbnail — a dangling globe beside the picture leads no text). Inline glyphs sit at 1.1em with a −0.25em baseline offset; the flex-centered produced-file glyphs instead nudge 1.2px down because the 22px text box carries its glyphs below box center. +- A leading category glyph — the new `LinkIcon` in ui-primitives with kinds `url` (globe), `folder`, `code`, `image`, `document`, and `other` (paper) — renders `currentColor` only; `classifyLinkPath` folds the [shared detailed file-type classification](2026-09-08-shared-file-type-icons.md) into those six link categories, and code, web, and data extensions share the code glyph by design. Two anchor shapes carry no glyph: workflow member links (an in-app member view fits no file or URL category) and anchors wrapping only images (a badge or thumbnail — a dangling globe beside the picture leads no text). Inline glyphs sit at 1.1em with a −0.25em baseline offset; the flex-centered produced-file glyphs instead nudge 1.2px down because the 22px text box carries its glyphs below box center. - Produced-file chips drop the grey pill and the 96px cap: plain link-blue text at natural width that shrinks with ellipsis only when the row overflows; the container-query bands still budget 96px per chip when choosing how many chips to show. - Deliberately untouched: ToolRow's grey dotted file links, and the grey "Show in folder" action (it gains the folder glyph but keeps its grey style). - In the same pass, the inline-code chip tint moved from `neutral-bluish-100` to `neutral-50` (dark: `neutral-800`) and gained a 0.5px l1 border. @@ -22,8 +22,8 @@ Coverage: a LinkIcon unit spec (one distinct glyph per kind, classification tabl ## Alternatives considered -- **Colored Word/Excel/PPT/PDF brand glyphs.** Implemented, then removed: fixed brand fills break the icon set's currentColor-only rule, so those extensions fold into the single outline `document` glyph. -- **Per-extension icons.** Collapsed to six categories: more glyphs than the eye can parse at 14px adds noise, and per-site favicons remain possible later behind the same `url` category. +- **Colored Word/Excel/PPT/PDF link glyphs.** Rejected: fixed brand fills break the icon set's currentColor-only rule, so those extensions fold into the single outline `document` glyph. The larger file-card primitive uses distinct current-color silhouettes instead. +- **Per-extension link icons.** Collapsed to six categories: more glyphs than the eye can parse at 14px adds noise. The 28px `FileTypeIcon` owns the more detailed file identities, and per-site favicons remain possible later behind the same `url` category. - **Keeping links on `state-business-primary`.** Darker link blues (blue-600/650/700 were auditioned and reverted) would have dragged focus rings and state dots along; the dedicated alias localizes any future tuning to one line. - **Glyphs on ToolRow path links.** Rejected: tool rows keep their quieter grey dotted affordance, and leading glyphs there would stack icons in already dense rows. diff --git a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md b/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md index 7b5fde4c87..0b15a6c72d 100644 --- a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md +++ b/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md @@ -13,7 +13,7 @@ Status: implemented 会话记录的可点击链接表面——Markdown 锚点(含引用式链接、mailto、被提升为链接的 inline code)、正文文件引用、网页搜索来源链接与抓取 URL、产物 chips、workflow 成员链接——统一为一套链接语言: - 颜色经由 `design-platform.css` 中专用的 `--dsw-alias-link` 别名(亮色 `deepseek-500`,暗色 `deepseek-400`),与 `state-business-primary` 解耦;链接以 `font-weight: 500` 呈现,默认无下划线,hover/focus 时为 3px offset 的 `underline dotted`。 -- 前置分类图标——ui-primitives 新增的 `LinkIcon`,kind 为 `url`(地球)、`folder`、`code`、`image`、`document`、`other`(纸张)——只渲染 `currentColor`;`classifyLinkPath` 按扩展名推导文件类别,代码、网页、数据扩展名按设计共用 code 图形。两类锚点不带图标:workflow 成员链接(应用内成员视图不属于任何文件或 URL 类别)和只包图片的锚点(徽章或缩略图——图片旁悬着的地球没有可引导的文字)。行内图标为 1.1em、基线偏移 −0.25em;flex 居中的产物图标则下移 1.2px,因为 22px 文字盒的字形低于盒中心。 +- 前置分类图标——ui-primitives 新增的 `LinkIcon`,kind 为 `url`(地球)、`folder`、`code`、`image`、`document`、`other`(纸张)——只渲染 `currentColor`;`classifyLinkPath` 把[共享精细文件类型分类](2026-09-08-shared-file-type-icons.zh.md)折叠进这六种链接类别,代码、网页、数据扩展名按设计共用 code 图形。两类锚点不带图标:workflow 成员链接(应用内成员视图不属于任何文件或 URL 类别)和只包图片的锚点(徽章或缩略图——图片旁悬着的地球没有可引导的文字)。行内图标为 1.1em、基线偏移 −0.25em;flex 居中的产物图标则下移 1.2px,因为 22px 文字盒的字形低于盒中心。 - 产物 chips 去掉灰色药丸和 96px 上限:纯链接蓝文字按自然宽度展示,仅当整行溢出时才收缩出省略号;容器查询档位在决定展示几个 chip 时仍按每个 96px 预算。 - 刻意不动:ToolRow 的灰色点线文件链接,以及灰色的「在文件夹中显示」操作(它获得文件夹图标但保持灰色样式)。 - 同一批次中,inline code 底色从 `neutral-bluish-100` 换到 `neutral-50`(暗色:`neutral-800`),并新增 0.5px l1 描边。 @@ -22,8 +22,8 @@ Status: implemented ## 备选方案 -- **彩色 Word/Excel/PPT/PDF 品牌图形。** 实现后又移除:固定品牌填充违反图标集 currentColor-only 规则,这些扩展名并入单一的 outline `document` 图形。 -- **每个扩展名一个图标。** 收敛为六个类别:14px 下超出肉眼可分辨数量的图形只会增加噪音,按站点的 favicon 以后仍可在同一 `url` 类别之下引入。 +- **彩色 Word/Excel/PPT/PDF 链接图形。** 否决:固定品牌填充违反图标集 currentColor-only 规则,因此这些扩展名并入单一的 outline `document` 图形。较大的文件卡片 primitive 改用各自不同的 current-color 轮廓。 +- **每个扩展名一个链接图标。** 收敛为六个类别:14px 下超出肉眼可分辨数量的图形只会增加噪音。28px 的 `FileTypeIcon` 拥有更精细的文件身份,按站点的 favicon 以后仍可在同一 `url` 类别之下引入。 - **链接继续用 `state-business-primary`。** 更深的链接蓝(试过 blue-600/650/700 又回退)会连带焦点环和状态点;专用别名把未来的调色收敛到一行。 - **给 ToolRow 路径链接加图形。** 否决:工具行保持更安静的灰色点线示能,在已经很密的行里加前置图形会造成图标堆叠。 diff --git a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml new file mode 100644 index 0000000000..e8a971188f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.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-09-08-shared-file-type-icons.md +2026-09-08-shared-file-type-icons.md: 36b4ff8f57dd810da3d2af94aac15ec5afab799e +2026-09-08-shared-file-type-icons.zh.md: 4b33a8ffe5934953b12e82750ce57febc6f054f4 diff --git a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md new file mode 100644 index 0000000000..36b4ff8f57 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md @@ -0,0 +1,43 @@ +# Agent Note: Shared file-type icons + +Status: implemented + +English | [中文](2026-09-08-shared-file-type-icons.zh.md) + +## Problem + +Client feature plugins can share React components only through `@deepseek-ai/dsh-client-ui-primitives`, but file cards had no shared file-type presentation. `LinkIcon` owned the only extension table and deliberately collapsed paths into six link categories, while attachment cards, sent-message attachments, queued files, and workspace file rows used one generic document glyph. Two of those consumers also carried separate extension-display helpers. Adding a precise file icon anywhere else would have required another extension table or a runtime import between feature plugins. + +## Decision + +`ui-primitives` owns one Cordis-free file classification and rendering API. `fileExtension(path)` applies the shared basename and final-dot semantics to either path separator. `classifyFileType(path)` matches without case sensitivity and returns the closed `FileType` union: the traditional `code`, `excel`, `folder`, `html`, `image`, `markdown`, `other`, `pdf`, `ppt`, `video`, and `word` categories plus the detailed `CodeFileType` set imported from the supplied `square-code-icons` map. Exact filenames, filename prefixes, filename suffixes, optional project context, and extensions run in that order. The path classifier returns every member except `folder`; callers use the explicit `FileTypeIcon` `type` override when they know an entry is a directory. Unknown extensions, missing extensions, and trailing dots resolve to `other`, except the shared table recognizes named files such as `Dockerfile`, `Makefile`, `package.json`, `.gitignore`, `README`, and `CHANGELOG`. + +`FileTypeIcon` accepts a path, shared `IconProps`, the optional explicit type, and an optional project-file snapshot. Traditional file types render the supplied 28px document and folder contours as inline SVG. Excel, Markdown, PDF, PPT, and Word foreground marks scale to 122% around their visual center; the remaining marked traditional glyphs use 112%, while the file body and folded corner retain their source geometry and the generic file has no invented center mark. The sheet is a solid category color, the foreground mark and ordinary folded corner are white, and the generic file has a darker grey corner. CSS assigns the supplied category palette through static design tokens: DeepSeek blue for code/HTML/Markdown, the lighter DeepSeek blue for Word, green for Excel, two amber steps for folder/PPT, red for PDF, and neutral grey for unknown files. Image and video share the supplied violet through a component-local variable because the design platform has no matching violet token. A caller may override a traditional sheet through `--dsh-file-type-icon-color`. + +Recognized code and configuration files render the corresponding 20px square artwork scaled to the requested icon size. These technology marks retain their embedded multicolor fills and are the explicit exception to the ordinary current-color icon rule. The map selects React before TypeScript/JavaScript, Angular filename suffixes before their base extension, Docker/Node/Git/Make/CMake by filename rules, and Flutter only when the optional project snapshot contains a `pubspec.yaml` whose text includes `flutter:`. Markdown and SVG remain owned by the traditional Markdown and image categories. Every traditional and technology SVG is `aria-hidden`, and the card, row, or button that owns the file identity supplies the accessible name. + +`LinkIcon` delegates extension classification to `classifyFileType` and folds the detailed result into its existing link vocabulary: code and HTML use `code`, images use `image`, PDF/Word/Excel/PPT use `document`, and Markdown/video/unknown files use `other`. Extensionless names remain `other` in link contexts, so the 14px clickable-link appearance defined by the [clickable-link decision](2026-09-04-web-clickable-link-styles.md) does not change. + +Attachment upload cards, sent-message file cards, queued-file rows, and workspace file rows render `FileTypeIcon`. The two metadata rows use `fileExtension` rather than local parsers; a leading-dot basename such as `.env` therefore displays `ENV`, while an absent or trailing suffix displays no extension label. Image content continues to render as a preview rather than a file-type glyph, and produced-file links and Markdown file mentions continue to use `LinkIcon` because they are link surfaces. + +## Alternatives considered + +**Use `LinkIcon` for every file surface.** Rejected. Its six categories and 14px outline drawings communicate link destinations at text size; a 28px file card has room for the supplied HTML, Markdown, PDF, Word, Excel, PPT, and video identities. + +**Keep a second extension table beside `LinkIcon`.** Rejected. The same path could drift to different categories as either list grows. One detailed table plus an explicit detailed-to-link adapter preserves both consumers' semantics. + +**Put the supplied traditional-file colors directly in each SVG path.** Rejected. Traditional SVG geometry stays reusable and follows the icon set's `currentColor` rule; the component stylesheet owns the default category palette, and render sites retain one CSS-variable override instead of rewriting path fills. The technology artwork is exempt because its embedded multicolor marks identify the language or tool rather than decorating a generic file silhouette. + +**Add archive, audio, and data categories for symmetry.** Rejected. The supplied artwork and current consumers require no such glyphs. New `FileType` members require a shipped render site and artwork that remains legible at the 28px seat. + +## Testing + +The `ui-primitives` specs cover case-insensitive suffixes, both path separators, leading-dot files, missing and trailing suffixes, common named files, unknown fallback, all detailed code mappings, rule priority, Flutter context, every supplied technology SVG, instance-safe gradient ids, `aria-hidden`, sizing/class forwarding, distinct artwork, the 112% and 122% foreground-mark transforms, and the traditional solid-sheet/contrast-mark layers without literal SVG colors. A stylesheet spec pins every traditional category-to-color mapping, the caller override, and the local violet value. The existing `LinkIcon` classification table pins its coarse output, including `Makefile` remaining `other`. Attachment, chat, queue, and sidebar component suites exercise the migrated render paths; their accessibility output does not change because the glyphs remain decorative. + +## Consequences + +- Client packages use one filename parser and one detailed file-type table instead of importing or recreating feature-local logic. +- A new suffix joins the detailed table only when an existing glyph truthfully represents it. If its link category differs from the current adapter, the change must also decide whether the 14px link appearance changes. +- Code and configuration artwork preserves its embedded palette and does not accept the traditional `--dsh-file-type-icon-color` override. +- The primitive owns no copy but does own the default file-type palette. Consumers continue to own accessible labels and surrounding text, and may replace the category color through `--dsh-file-type-icon-color`. +- The detailed category names describe presentation, not MIME validation. A suffix is a display hint and does not establish file contents or trust. diff --git a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md new file mode 100644 index 0000000000..4b33a8ffe5 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md @@ -0,0 +1,43 @@ +# Agent Note: 共享文件类型图标 + +Status: implemented + +[English](2026-09-08-shared-file-type-icons.md) | 中文 + +## 问题 + +客户端功能插件只能通过 `@deepseek-ai/dsh-client-ui-primitives` 共享 React 组件,但文件卡片没有共享的文件类型呈现。`LinkIcon` 拥有唯一一份扩展名表,并有意把路径折叠为六种链接类别;附件卡片、已发送消息附件、排队文件和工作区文件行则统一使用一个通用文档图形。其中两个消费方还各自带着一份扩展名展示辅助函数。在其他位置增加精细文件图标,需要再复制一份扩展名表或让功能插件之间产生运行时 import。 + +## 决策 + +`ui-primitives` 统一拥有一套 Cordis-free 的文件分类与渲染 API。`fileExtension(path)` 对两种路径分隔符采用共享的 basename 与最终点号语义。`classifyFileType(path)` 不区分大小写,并返回闭合的 `FileType` 联合:传统的 `code`、`excel`、`folder`、`html`、`image`、`markdown`、`other`、`pdf`、`ppt`、`video`、`word` 类别,以及从所提供 `square-code-icons` 映射接入的细分 `CodeFileType` 集合。解析按完整文件名、文件名前缀、文件名后缀、可选项目上下文、扩展名的顺序执行。路径分类器返回 `folder` 之外的全部成员;调用方确认条目是目录时,通过 `FileTypeIcon` 的显式 `type` 覆盖指定目录。未知扩展名、无扩展名和末尾点号回退到 `other`,但共享表识别 `Dockerfile`、`Makefile`、`package.json`、`.gitignore`、`README`、`CHANGELOG` 等具名文件。 + +`FileTypeIcon` 接受路径、共享 `IconProps`、可选的显式类型和可选的项目文件快照。传统文件类型把所提供的 28px 文档与文件夹轮廓渲染为 inline SVG。Excel、Markdown、PDF、PPT、Word 的前景标记围绕自身视觉中心缩放至 122%,其余带标记的传统图形使用 112%;文件底板与折角保持源图几何,通用文件不凭空增加中心标记。底板使用实色分类颜色,前景标记与普通折角使用白色,通用文件使用较深的灰色折角。CSS 通过静态设计 token 分配所提供的分类调色板:code/HTML/Markdown 使用 DeepSeek 蓝,Word 使用较浅的 DeepSeek 蓝,Excel 使用绿色,folder/PPT 使用两档琥珀色,PDF 使用红色,未知文件使用中性灰。image 与 video 通过组件本地变量共用所提供的紫色,因为设计平台没有匹配的紫色 token。调用方可通过 `--dsh-file-type-icon-color` 覆盖传统底板颜色。 + +已识别的代码与配置文件把对应的 20px 方形图稿缩放到请求的图标尺寸。这些技术标记保留自身内嵌的多色填充,是普通 current-color 图标规则的明确例外。映射让 React 优先于 TypeScript/JavaScript、Angular 文件名后缀优先于基础扩展名,并按文件名识别 Docker/Node/Git/Make/CMake;只有可选项目快照包含内容带 `flutter:` 的 `pubspec.yaml` 时才选择 Flutter。Markdown 与 SVG 仍由传统 Markdown 和图片类别拥有。所有传统与技术 SVG 都是 `aria-hidden` 的,拥有文件身份的卡片、行或按钮提供无障碍名称。 + +`LinkIcon` 委托 `classifyFileType` 做扩展名分类,再把精细结果折叠进原有链接词汇:code 与 HTML 使用 `code`,图片使用 `image`,PDF/Word/Excel/PPT 使用 `document`,Markdown、video 与未知文件使用 `other`。无扩展名文件在链接语境中仍是 `other`,因此[可点击链接决策](2026-09-04-web-clickable-link-styles.zh.md)定义的 14px 外观不变。 + +附件上传卡片、已发送消息文件卡片、排队文件行和工作区文件行渲染 `FileTypeIcon`。两处元数据行使用 `fileExtension`,不再保留本地解析器;`.env` 这样的前导点 basename 会显示 `ENV`,无后缀或末尾点号则不显示扩展名 label。图片内容继续渲染为预览而不是文件类型图形,产物文件链接与 Markdown 文件提及继续使用 `LinkIcon`,因为它们属于链接表面。 + +## 备选方案 + +**所有文件表面都使用 `LinkIcon`。** 否决。它的六种类别和 14px outline 图形用于在文字尺寸下表达链接目标;28px 文件卡片有空间表达所提供的 HTML、Markdown、PDF、Word、Excel、PPT 与 video 身份。 + +**在 `LinkIcon` 旁保留第二份扩展名表。** 否决。任一列表增长后,同一路径可能漂移到不同类别。一份精细表加一个显式的精细到链接适配,能同时保住两类消费方的语义。 + +**把所提供的传统文件颜色直接写入每一条 SVG path。** 否决。传统 SVG 几何保持可复用,并遵守图标集的 `currentColor` 规则;组件样式表拥有默认分类调色板,渲染点只需一个 CSS 变量即可覆盖颜色,不需要重写 path 填充。技术图稿是例外,因为其内嵌的多色标记用于识别语言或工具,而不是装饰通用文件轮廓。 + +**为了对称增加 archive、audio 与 data 类别。** 否决。提供的图稿与当前消费方都不需要这些图形。新增 `FileType` 成员必须有已发布的渲染点,以及在 28px slot 中仍可辨认的图稿。 + +## 测试 + +`ui-primitives` 测试覆盖不区分大小写的后缀、两种路径分隔符、前导点文件、无后缀与末尾点号、常见具名文件、未知回退、全部细分代码映射、规则优先级、Flutter 上下文、每一份技术 SVG、实例安全的渐变 id、`aria-hidden`、尺寸/class 转发、不同图稿、112% 与 122% 前景标记变换,以及不含 SVG 字面颜色的传统实色底板/对比标记层。样式表测试钉住每一项传统类别到颜色的映射、调用方覆盖变量与本地紫色值。既有 `LinkIcon` 分类表钉住它的粗粒度输出,包括 `Makefile` 仍为 `other`。附件、聊天、队列和侧边栏组件测试覆盖迁移后的渲染路径;由于图形仍是装饰性的,其无障碍输出不变。 + +## 后果 + +- 客户端包使用一个文件名解析器与一份精细文件类型表,不再 import 或重新实现功能包本地逻辑。 +- 新后缀只在已有图形能准确表达它时加入精细表。若它的链接类别与当前适配不同,这次改动还必须决定是否改变 14px 链接外观。 +- 代码与配置图稿保留内嵌调色板,不接受传统图形的 `--dsh-file-type-icon-color` 覆盖。 +- primitive 不拥有文案,但拥有默认文件类型调色板。消费方继续拥有无障碍 label 与周围文字,并可通过 `--dsh-file-type-icon-color` 替换分类颜色。 +- 精细类别名称描述展示意图,不是 MIME 校验。后缀只是展示提示,不能证明文件内容或可信度。 diff --git a/packages/client/ui-attachment/src/FileCard.tsx b/packages/client/ui-attachment/src/FileCard.tsx index e10c91993c..3692302325 100644 --- a/packages/client/ui-attachment/src/FileCard.tsx +++ b/packages/client/ui-attachment/src/FileCard.tsx @@ -1,4 +1,4 @@ -import { DocumentFileIcon, fileSizeText, IconCloseFill14 } from '@deepseek-ai/dsh-client-ui-primitives' +import { fileExtension, FileTypeIcon, fileSizeText, IconCloseFill14 } from '@deepseek-ai/dsh-client-ui-primitives' import css from './FileCard.module.css' /** Localized strings consumed by one pending-file card. */ @@ -18,13 +18,7 @@ export interface FileCardLabels { /** Upload display state resolved by the owner. */ export type FileCardState = 'uploading' | 'ready' | 'error' -function extensionOf(name: string): string { - const dot = name.lastIndexOf('.') - if (dot <= 0 || dot === name.length - 1) return '' - return name.slice(dot + 1).toUpperCase().slice(0, 8) -} - -/** One pending generic-file card: name, size or upload status, remove, retry. */ +/** One pending file card: type glyph, name, size or upload status, remove, retry. */ export function FileCard({ name, bytes, state, progress, labels, onRemove, onRetry, }: { @@ -36,7 +30,7 @@ export function FileCard({ onRemove: () => void onRetry: () => void }) { - const extension = extensionOf(name) + const extension = fileExtension(name).toUpperCase().slice(0, 8) const meta = state === 'uploading' ? labels.uploading : state === 'error' @@ -51,7 +45,7 @@ export function FileCard({ {state === 'uploading' ? - : } + : } {retryable ? ( diff --git a/packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx b/packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx index 8e6f01474e..c0d0920c52 100644 --- a/packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx +++ b/packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx @@ -263,4 +263,17 @@ describe('ComposerAttachments file drafts', () => { expect(group.textContent).toContain('文件') expect(group.textContent).toContain('3B') }) + + it('uses the shared leading-dot suffix in ready-file metadata', () => { + const view = render() + expect(view.getByTitle('.env').textContent).toContain('ENV 3B') + }) }) diff --git a/packages/client/ui-chat/src/client/chat/MessageItem.module.css b/packages/client/ui-chat/src/client/chat/MessageItem.module.css index 2f44408b33..8507492a70 100644 --- a/packages/client/ui-chat/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-chat/src/client/chat/MessageItem.module.css @@ -317,7 +317,7 @@ .fileIcon { flex: none; - width: 24px; + width: 28px; height: 28px; } diff --git a/packages/client/ui-chat/src/client/chat/MessageItem.tsx b/packages/client/ui-chat/src/client/chat/MessageItem.tsx index fc4f257048..f4e0fffa54 100644 --- a/packages/client/ui-chat/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-chat/src/client/chat/MessageItem.tsx @@ -2,7 +2,7 @@ import { Fragment, memo, useEffect, useMemo, useState } from 'react' import type { ReactNode } from 'react' import type { PendingSubmission } from '@deepseek-ai/dsh-api-session-controller/client' import type { MessageImageSource } from '@deepseek-ai/dsh-client-ui-conversation/client' -import { DocumentFileIcon, fileSizeText, JsonBlock, projectUserText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' +import { fileExtension, FileTypeIcon, fileSizeText, JsonBlock, projectUserText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatNodeOwnerProps, ChatNodeViewProps, ChatViewSlotProps } from '../contract/slots.ts' import type { ModelRetryNode, TurnErrorNode, UserMessageNode } from '../contract/snapshot.ts' import { CompactionItem } from './CompactionItem.tsx' @@ -16,12 +16,6 @@ type PresentedAttachment = | { readonly type: 'image'; readonly image: MessageImageSource } | { readonly type: 'file'; readonly file: UserFile['attachment'] } -function extensionOf(name: string): string { - const dot = name.lastIndexOf('.') - if (dot <= 0 || dot === name.length - 1) return '' - return name.slice(dot + 1).toUpperCase().slice(0, 8) -} - function contentParts(content: readonly unknown[]): { text: string attachments: PresentedAttachment[] @@ -206,11 +200,11 @@ function UserStyleBubble({ ) : ( - + {attachment.file.name} - {[extensionOf(attachment.file.name), fileSizeText(attachment.file.bytes)] + {[fileExtension(attachment.file.name).toUpperCase().slice(0, 8), fileSizeText(attachment.file.bytes)] .filter(Boolean).join(' ')} diff --git a/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx b/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx index 5991449f44..a349f34576 100644 --- a/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx @@ -1085,6 +1085,10 @@ describe('user file attachments', () => { expect(view.getByTitle('notes.pdf').textContent).toContain('3.2MB') expect(view.getByTitle('tiny.txt').textContent).toContain('12B') expect(view.getByTitle('mid.csv').textContent).toContain('500KB') + const icons = ['notes.pdf', 'tiny.txt', 'mid.csv'].map(name => + view.getByTitle(name).querySelector('svg')?.innerHTML, + ) + expect(new Set(icons).size).toBe(icons.length) expect(view.getByText('summarize these')).toBeTruthy() }) }) diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx index 0dcbdda890..3492d37686 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx @@ -5,7 +5,7 @@ import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots import type { SessionId } from '@deepseek-ai/dsh-session/types' import { IconCheckOutline16, IconChevronDownOutline14, IconChevronUpOutline14, IconCloseOutline16, - DocumentFileIcon, fileSizeText, IconEditOutline16, IconQueueOutline14, IconSendOutline14, + FileTypeIcon, fileSizeText, IconEditOutline16, IconQueueOutline14, IconSendOutline14, IconTrashOutline16, projectUserText, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import type { QueueAction, QueueItemId, QueueRow } from '../contract/queue.ts' @@ -52,7 +52,7 @@ function queueAttachments(content: QueueRow['content']): Array< function QueueFile({ attachment, label }: { attachment: FileAttachmentRef; label: string }) { return ( - + {attachment.name} {fileSizeText(attachment.bytes)} diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 138d94440f..9e0a5fd104 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/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-primitives/README.md -README.md: 8fd59c5ecc98a609f1383e428701cf590da95e73 -README.zh.md: 2478330825681f65aa83415c698cf0edc54fdad6 +README.md: 93691e0203b259b0004f8048b01c09947f033989 +README.zh.md: a446ab71fb7422760830539388e7cfe59264bf9b diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 8fd59c5ecc..93691e0203 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Use `dsh-client-ui-primitives` to build web-client controls and render agent output with shared React UI. It includes standard controls, icons, anchored overlays, and renderers for Markdown with TeX, terminal output, file reads, diffs, search, web retrieval, and JSON. The renderers handle untrusted model output by dropping raw HTML, restricting links, and parsing ANSI escape sequences. Callers must supply localized labels, and the components rely only on React and `--dsw-*` design tokens. +Use `dsh-client-ui-primitives` to build web-client controls and render agent output with shared React UI. It includes standard controls, icons, anchored overlays, and renderers for Markdown with TeX, terminal output, file reads, diffs, search, web retrieval, and JSON. The renderers handle untrusted model output by dropping raw HTML, restricting links, and parsing ANSI escape sequences. The components import no Cordis runtime; callers supply localized labels, and theme-facing colors use `--dsw-*` design tokens. ## Table of Contents @@ -54,8 +54,8 @@ Check this table before writing a control in a feature package. A plugin cannot | `JsonTree`, `JsonBlock` | Read-only JSON inspection. | | `MarkdownText`, `CodeBlock` | Untrusted GFM with TeX math, and highlighted code. `CodeBlock` accepts opt-in `lineNumbers`; copied source excludes the gutter. | | `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, `WebBlock` | The agent-output card matching each tool-result intent. | -| `icons/*`, `FishLogo`, `BrandWordmark`, `ReferenceIcon`, `LinkIcon`, `DocumentFileIcon` | Glyphs and brand marks, all riding `currentColor`. | -| `FileTypeIcon` | The coloured file-type sheet (code, html, image, markdown, pdf, sheet, slides, document, other); `classifyFileType` picks the kind from a path's extension. | +| `icons/*`, `FishLogo`, `BrandWordmark`, `ReferenceIcon`, `LinkIcon`, `DocumentFileIcon` | Glyphs and brand marks. Use `LinkIcon` only for 14px clickable-link categories and `DocumentFileIcon` only when no file path exists to classify. | +| `FileTypeIcon`, `classifyFileType`, `fileExtension` | A category-colored 28px file or folder glyph and the shared case-insensitive filename mapping behind it. Code and configuration files use detailed full-color technology glyphs; use `LinkIcon` for link-leading glyphs and image previews for image content. | Three pairs are easy to confuse: @@ -67,7 +67,7 @@ Writing your own component in your own package is fine when the need is genuinel ### Controls and icons -The catalog above lists what each export is for; this section covers the behavior that props alone do not show. The `ic_ds_*` icon set and `FishLogo`/`BrandWordmark` marks fill brand and inline-icon slots. `LinkIcon` draws the leading category glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, all riding `currentColor` — and `classifyLinkPath` derives a file path's category from its extension. `FileTypeIcon` is the one glyph that does not ride `currentColor`: its sheet colour is the type's identity, so a consumer that wants it muted (an empty state) applies `filter: grayscale(1)` itself. `classifyFileType` and `classifyLinkPath` read one extension vocabulary (`file-extensions.ts`), so a path classifies consistently on a link and on a sheet; the sheet is the finer of the two. `ConnectionIndicator` renders a warning-colored disconnected action, a connecting label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Hover or keyboard focus shows only the reconnect action label, including while the connecting dots animate. Every state reserves the widest supplied label and uses fixed icon and text columns, so copy changes do not move or resize the control. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. +The catalog above lists what each export is for; this section covers the behavior that props alone do not show. The `ic_ds_*` icon set and `FishLogo`/`BrandWordmark` marks fill brand and inline-icon slots. `FileTypeIcon` renders the traditional 28px Excel, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the full-color square technology artwork for recognized code and configuration files. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIcon` remains the smaller leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action, a connecting label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Hover or keyboard focus shows only the reconnect action label, including while the connecting dots animate. Every state reserves the widest supplied label and uses fixed icon and text columns, so copy changes do not move or resize the control. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. ### Rendering agent output diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 2478330825..a446ab71fb 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -9,7 +9,7 @@ kind: "package-library" ## 概述 -使用 `dsh-client-ui-primitives`,通过共享 React UI 构建 Web 客户端控件并渲染 agent 输出。它提供标准控件、图标、锚定浮层,以及用于带 TeX 公式的 Markdown、终端输出、文件读取、差异、搜索、网页检索和 JSON 的渲染器。这些渲染器会丢弃原始 HTML、限制链接并解析 ANSI 转义序列,以处理不受信任的模型输出。调用方必须提供本地化 label;这些组件仅依赖 React 和 `--dsw-*` 设计 token。 +使用 `dsh-client-ui-primitives`,通过共享 React UI 构建 Web 客户端控件并渲染 agent 输出。它提供标准控件、图标、锚定浮层,以及用于带 TeX 公式的 Markdown、终端输出、文件读取、差异、搜索、网页检索和 JSON 的渲染器。这些渲染器会丢弃原始 HTML、限制链接并解析 ANSI 转义序列,以处理不受信任的模型输出。组件不 import Cordis 运行时;调用方提供本地化 label,主题相关颜色使用 `--dsw-*` 设计 token。 ## 目录 @@ -54,8 +54,8 @@ kind: "package-library" | `JsonTree`、`JsonBlock` | 只读 JSON 查看。 | | `MarkdownText`、`CodeBlock` | 不可信 GFM 与 TeX 数学,以及高亮代码。`CodeBlock` 可通过 `lineNumbers` 开启行号;复制的源码不含行号栏。 | | `TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock`、`WebBlock` | 与各类工具结果意图对应的 agent 输出卡片。 | -| `icons/*`、`FishLogo`、`BrandWordmark`、`ReferenceIcon`、`LinkIcon`、`DocumentFileIcon` | 字形与品牌标识,全部随 `currentColor`。 | -| `FileTypeIcon` | 彩色文件类型纸片(code、html、image、markdown、pdf、sheet、slides、document、other);`classifyFileType` 按路径扩展名选出类型。 | +| `icons/*`、`FishLogo`、`BrandWordmark`、`ReferenceIcon`、`LinkIcon`、`DocumentFileIcon` | 字形与品牌标识。`LinkIcon` 只用于 14px 的可点击链接分类,`DocumentFileIcon` 只用于没有文件路径可供分类的场景。 | +| `FileTypeIcon`、`classifyFileType`、`fileExtension` | 按类别着色的 28px 文件或文件夹图形,以及它背后共享的不区分大小写文件名映射。代码与配置文件使用细分的全彩技术图形;链接前置图形使用 `LinkIcon`,图片内容使用图片预览。 | 有三组容易混淆: @@ -67,7 +67,7 @@ kind: "package-library" ### 控件与图标 -上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。`ic_ds_*` 图标集与 `FishLogo`/`BrandWordmark` 标记填充品牌与行内图标 slot。`LinkIcon` 为可点击产物链接绘制前置分类图形——地球、文件夹、代码、图片、文档或纸张,全部随 `currentColor`——`classifyLinkPath` 按扩展名推导文件路径的类别。`FileTypeIcon` 是唯一不随 `currentColor` 的字形:纸片颜色就是类型的身份,需要压灰的使用方(空状态)自行加 `filter: grayscale(1)`。`classifyFileType` 与 `classifyLinkPath` 读同一份扩展名词表(`file-extensions.ts`),一条路径在链接上与纸片上的归类一致;纸片分得更细。`ConnectionIndicator` 可渲染警告色的断联操作、以独立于 retry 时序的 500ms 节奏推进一至三个点的连接中状态,或成功色的恢复状态。悬停或键盘聚焦时只显示重连操作文案,连接中的圆点动画也保持隐藏。所有状态都为最长的输入 label 预留空间,并使用固定的图标列和文字列,因此文案变化不会移动控件或改变其宽度。它的 owner 提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。 `Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。 `rankByName` 是 `/` 菜单命令源与 skill 源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 +上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。`ic_ds_*` 图标集与 `FishLogo`/`BrandWordmark` 标记填充品牌与行内图标 slot。`FileTypeIcon` 渲染传统的 28px Excel、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为已识别的代码和配置文件使用全彩方形技术图形。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIcon` 仍是可点击产物链接较小的前置分类图形——地球、文件夹、代码、图片、文档或纸张——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作、以独立于 retry 时序的 500ms 节奏推进一至三个点的连接中状态,或成功色的恢复状态。悬停或键盘聚焦时只显示重连操作文案,连接中的圆点动画也保持隐藏。所有状态都为最长的输入 label 预留空间,并使用固定的图标列和文字列,因此文案变化不会移动控件或改变其宽度。它的 owner 提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。 `Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。 `rankByName` 是 `/` 菜单命令源与 skill 源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 ### 渲染 agent 输出 diff --git a/packages/client/ui-primitives/src/CodeFileIcon.tsx b/packages/client/ui-primitives/src/CodeFileIcon.tsx new file mode 100644 index 0000000000..2e32bee8b0 --- /dev/null +++ b/packages/client/ui-primitives/src/CodeFileIcon.tsx @@ -0,0 +1,471 @@ +import { useId } from 'react' +import type { ReactNode } from 'react' +import type { IconProps } from './icons/props.ts' +import type { CodeFileType } from './code-file-types.ts' + +/** + * Render one full-color square code-file glyph from the embedded icon set. + * @param props - Detailed code type, optional size, and optional CSS class. + * @returns The selected decorative SVG with its identifying palette intact. + */ +export function CodeFileIcon({ type, size = 20, className }: IconProps & { readonly type: CodeFileType }): ReactNode { + const gradientId = useId() + return ( + + {codeFileArtwork(type, gradientId)} + + ) +} + +function codeFileArtwork(type: CodeFileType, gradientId: string): ReactNode { + switch (type) { + case 'angular': + return ( + <> + + + + + ) + case 'c': + return ( + <> + + + + {'C'} + + + ) + case 'clojure': + return ( + <> + + + + + + + ) + case 'cmake': + return ( + <> + + + + + + + ) + case 'cpp': + return ( + <> + + + + {'C'} + + + + ) + case 'csharp': + return ( + <> + + + + {'C'} + + + + ) + case 'css': + return ( + <> + + + {'CSS'} + + + ) + case 'dart': + return ( + <> + + + + + ) + case 'docker': + return ( + <> + + + + + + + ) + case 'elixir': + return ( + <> + + + + + + + + + + + ) + case 'env': + return ( + <> + + + {'ENV'} + + + ) + case 'erlang': + return ( + <> + + + + {'E'} + + + ) + case 'flutter': + return ( + <> + + + + + ) + case 'git': + return ( + <> + + + + + + + + ) + case 'go': + return ( + <> + + + + ) + case 'graphql': + return ( + <> + + + + + + + + + + + + ) + case 'haskell': + return ( + <> + + + + ) + case 'ini': + return ( + <> + + + {'INI'} + + + ) + case 'java': + return ( + <> + + + + + ) + case 'javascript': + return ( + <> + + + {'JS'} + + + ) + case 'json': + return ( + <> + + + {'{ }'} + + + ) + case 'kotlin': + return ( + <> + + + + + + + + + + + ) + case 'lua': + return ( + <> + + + + + + + ) + case 'makefile': + return ( + <> + + + + ) + case 'node': + return ( + <> + + + + + ) + case 'objective-c': + return ( + <> + + + {'OC'} + + + ) + case 'perl': + return ( + <> + + + {'PL'} + + + ) + case 'php': + return ( + <> + + + + {'PHP'} + + + ) + case 'powershell': + return ( + <> + + + + + ) + case 'protobuf': + return ( + <> + + + + + ) + case 'python': + return ( + <> + + + + + + + ) + case 'r': + return ( + <> + + + + + + ) + case 'react': + return ( + <> + + + + + + + + + ) + case 'ruby': + return ( + <> + + + + + ) + case 'rust': + return ( + <> + + + + + {'R'} + + + ) + case 'scala': + return ( + <> + + + + ) + case 'shell': + return ( + <> + + + + ) + case 'solidity': + return ( + <> + + + + ) + case 'sql': + return ( + <> + + + + + + ) + case 'svelte': + return ( + <> + + + + + ) + case 'swift': + return ( + <> + + + + ) + case 'toml': + return ( + <> + + + {'TOML'} + + + ) + case 'typescript': + return ( + <> + + + {'TS'} + + + ) + case 'vue': + return ( + <> + + + + + ) + case 'wasm': + return ( + <> + + + + ) + case 'xml': + return ( + <> + + + + ) + case 'yaml': + return ( + <> + + + {'YAML'} + + + ) + case 'zig': + return ( + <> + + + + {'ZIG'} + + + ) + /* v8 ignore next -- closed-union backstop; only reached if a type is forged */ + default: return assertNever(type) + } +} + +/** Closed-union exhaustiveness guard for the embedded artwork table. */ +/* v8 ignore next 3 -- only reachable when an untyped caller forges a code file type */ +function assertNever(value: never): never { + throw new Error(`unreachable code file type: ${String(value)}`) +} diff --git a/packages/client/ui-primitives/src/DocumentFileIcon.tsx b/packages/client/ui-primitives/src/DocumentFileIcon.tsx index 743a01a3ff..21aac0409d 100644 --- a/packages/client/ui-primitives/src/DocumentFileIcon.tsx +++ b/packages/client/ui-primitives/src/DocumentFileIcon.tsx @@ -1,7 +1,7 @@ import { useId } from 'react' /** - * Render the DeepSeek Web document glyph used by generic-file cards. + * Render a generic document glyph when the caller intentionally has no file path to classify. * @param props - optional CSS class for sizing and placement. * @returns a decorative document SVG with an instance-safe gradient id. */ diff --git a/packages/client/ui-primitives/src/FileTypeIcon.module.css b/packages/client/ui-primitives/src/FileTypeIcon.module.css new file mode 100644 index 0000000000..4de4d946e0 --- /dev/null +++ b/packages/client/ui-primitives/src/FileTypeIcon.module.css @@ -0,0 +1,50 @@ +.icon { + /* The design platform has no violet token matching the supplied image/video artwork. */ + --dsh-file-type-violet: rgb(139, 118, 246); + + color: var(--dsh-file-type-icon-color, var(--dsh-file-type-default-color)); +} + +.code { + --dsh-file-type-default-color: var(--dsw-static-deepseek-500); +} + +.excel { + --dsh-file-type-default-color: var(--dsw-static-green-500); +} + +.folder { + --dsh-file-type-default-color: var(--dsw-static-amber-400); +} + +.html { + --dsh-file-type-default-color: var(--dsw-static-deepseek-500); +} + +.image { + --dsh-file-type-default-color: var(--dsh-file-type-violet); +} + +.markdown { + --dsh-file-type-default-color: var(--dsw-static-deepseek-500); +} + +.other { + --dsh-file-type-default-color: var(--dsw-static-neutral-bluish-300); +} + +.pdf { + --dsh-file-type-default-color: var(--dsw-static-red-600); +} + +.ppt { + --dsh-file-type-default-color: var(--dsw-static-amber-500); +} + +.video { + --dsh-file-type-default-color: var(--dsh-file-type-violet); +} + +.word { + --dsh-file-type-default-color: var(--dsw-static-deepseek-450); +} diff --git a/packages/client/ui-primitives/src/FileTypeIcon.tsx b/packages/client/ui-primitives/src/FileTypeIcon.tsx index f03c01d4a7..6df0cdbe7b 100644 --- a/packages/client/ui-primitives/src/FileTypeIcon.tsx +++ b/packages/client/ui-primitives/src/FileTypeIcon.tsx @@ -1,167 +1,263 @@ -/** - * The coloured file-type sheet: a 28×28 paper with a folded corner, filled in - * the type's colour, carrying a white mark for the type. Unlike the `ic_ds_*` - * glyphs it does not ride `currentColor` — the colour is the type's identity — - * so a consumer that wants it muted applies a CSS filter. Design sources: - * DSHFiles iconCode, iconHTML, iconImage, iconMD, iconPDF, iconExcel, - * iconPPT, iconWord, iconOthers. - * - * TODO: interim. The sheets and `classifyFileType` are drawn for the Sidebar - * only; the product-wide file-type standard that will replace them is described - * in `file-extensions.ts`. - */ import type { ReactNode } from 'react' +import clsx from 'clsx' +import { CodeFileIcon } from './CodeFileIcon.tsx' import { - CODE_EXTENSIONS, HTML_EXTENSIONS, IMAGE_EXTENSIONS, MARKDOWN_EXTENSIONS, PDF_EXTENSIONS, SHEET_EXTENSIONS, - SLIDES_EXTENSIONS, WORD_EXTENSIONS, fileExtension, -} from './file-extensions.ts' + classifyCodeFileType, isCodeFileType, type CodeFileType, type FileTypeProjectContext, +} from './code-file-types.ts' import type { IconProps } from './icons/props.ts' +import css from './FileTypeIcon.module.css' -/** File types with their own sheet colour and mark; `other` is the grey sheet with text lines. */ -export type FileTypeKind = 'code' | 'html' | 'image' | 'markdown' | 'pdf' | 'sheet' | 'slides' | 'document' | 'other' +/** File categories with distinct 28px glyphs. */ +export type FileType = + | CodeFileType + | 'code' + | 'excel' + | 'folder' + | 'html' + | 'image' + | 'markdown' + | 'other' + | 'pdf' + | 'ppt' + | 'video' + | 'word' -/** Props for {@link FileTypeIcon}: the type plus the shared icon sizing seat. */ -export interface FileTypeIconProps extends IconProps { - kind: FileTypeKind +/** Compatibility name for consumers that pass an already resolved category. */ +export type FileTypeKind = FileType + +type ClassifiedFileType = Exclude +type TraditionalFileType = Exclude + +export type { CodeFileType, FileTypeProjectContext } from './code-file-types.ts' + +/** Props for {@link FileTypeIcon}: either a path to classify or an already resolved kind. */ +export type FileTypeIconProps = IconProps & ( + | { + /** File path or name to classify. */ + readonly path: string + /** Optional project-file snapshot for context-sensitive code icons such as Flutter. */ + readonly context?: FileTypeProjectContext | undefined + } + | { + /** Explicit category for callers that already resolved the file type. */ + readonly kind: FileTypeKind + } +) + +const EXTENSION_TYPES: Readonly> = { + scss: 'code', + sass: 'code', + less: 'code', + vue: 'code', + svelte: 'code', + astro: 'code', + env: 'code', + bat: 'code', + cmd: 'code', + csv: 'code', + tsv: 'code', + html: 'html', + htm: 'html', + png: 'image', + jpg: 'image', + jpeg: 'image', + gif: 'image', + svg: 'image', + webp: 'image', + avif: 'image', + bmp: 'image', + ico: 'image', + tif: 'image', + tiff: 'image', + heic: 'image', + heif: 'image', + md: 'markdown', + mdx: 'markdown', + markdown: 'markdown', + pdf: 'pdf', + ppt: 'ppt', + pptx: 'ppt', + mp4: 'video', + mov: 'video', + m4v: 'video', + webm: 'video', + mkv: 'video', + avi: 'video', + mpg: 'video', + mpeg: 'video', + doc: 'word', + docx: 'word', + xls: 'excel', + xlsx: 'excel', } -/** Extension sets in precedence order: a specific type wins over the code set it may also sit in. */ -const KIND_SETS: readonly (readonly [FileTypeKind, ReadonlySet])[] = [ - ['markdown', MARKDOWN_EXTENSIONS], - ['html', HTML_EXTENSIONS], - ['pdf', PDF_EXTENSIONS], - ['sheet', SHEET_EXTENSIONS], - ['slides', SLIDES_EXTENSIONS], - ['document', WORD_EXTENSIONS], - ['image', IMAGE_EXTENSIONS], - ['code', CODE_EXTENSIONS], -] - -/** - * Derive a file path's type glyph from its extension. Unknown and missing - * extensions fall to `other`. - * @param path - File path or bare name, with either separator. - * @returns The file's type. - */ -export function classifyFileType(path: string): FileTypeKind { - const extension = fileExtension(path) - return KIND_SETS.find(([, set]) => set.has(extension))?.[0] ?? 'other' +const NAME_TYPES: Readonly> = { + changelog: 'markdown', + contributing: 'markdown', + readme: 'markdown', } -const SHEET_PATH = 'M8.48949 28H19.511C21.6482 28 22.7167 28 23.5596 27.6509C24.6835 27.1853 25.5764 26.2924 26.042 25.1685C26.3911 24.3256 26.3911 23.257 26.3911 21.1199V8.79443C26.3911 8.32877 26.3911 8.09593 26.3473 7.87507C26.2889 7.58058 26.1733 7.30042 26.007 7.05048C25.8822 6.86303 25.718 6.69799 25.3895 6.36792L20.0613 1.01354C19.7307 0.681235 19.5653 0.515081 19.3771 0.38885C19.1263 0.220541 18.8446 0.103463 18.5483 0.0443412C18.3261 0 18.0917 0 17.6229 0H8.48949C6.35233 0 5.28376 0 4.44085 0.349145C3.31697 0.814671 2.42405 1.70759 1.95852 2.83147C1.60938 3.67438 1.60938 4.74296 1.60938 6.88011V21.1199C1.60938 23.257 1.60938 24.3256 1.95852 25.1685C2.42405 26.2924 3.31697 27.1853 4.44085 27.6509C5.28376 28 6.35233 28 8.48949 28Z' - -const CORNER_PATH = 'M26.3911 7.37445L19.0527 0V3.77445C19.0527 4.89271 19.0527 5.45184 19.2354 5.89289C19.479 6.48096 19.9462 6.94818 20.5343 7.19176C20.9753 7.37445 21.5345 7.37445 22.6527 7.37445H26.3911Z' - -/** One type's sheet colour and its white mark. */ -interface Face { - readonly fill: string - readonly mark: ReactNode -} - -const FACES: Readonly> = { - code: { - fill: '#4176E6', - mark: ( - <> - - - - ), - }, - html: { - fill: '#4176E6', - mark: ( - - ), - }, - image: { - fill: '#8B76F6', - mark: ( - <> - - - - - ), - }, - markdown: { - fill: '#4176E6', - mark: ( - - ), - }, - pdf: { - fill: '#EC1313', - mark: ( - - ), - }, - sheet: { - fill: '#22C55E', - mark: ( - - ), - }, - slides: { - fill: '#F59E0B', - mark: ( - - ), - }, - document: { - fill: '#5686FE', - mark: ( - - ), - }, - other: { - fill: '#CFD3D6', - mark: ( - <> - - - - - ), - }, +function basename(path: string): string { + return path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1) } /** - * Render one file type's coloured sheet. - * @param props - The type, optional size (default 28px, the drawn size), and - * optional CSS class. - * @returns The type's SVG, `aria-hidden`; the adjacent name carries the meaning. + * Extract the final suffix from a file path without changing its case. + * A leading dot starts a suffix, while a missing or trailing dot returns an empty string. + * @param path - File path or basename using either path separator. + * @returns The characters after the basename's final dot. */ -export function FileTypeIcon({ kind, size = 28, className }: FileTypeIconProps): ReactNode { - const face = FACES[kind] +export function fileExtension(path: string): string { + const name = basename(path) + const dot = name.lastIndexOf('.') + return dot < 0 ? '' : name.slice(dot + 1) +} + +/** + * Classify a file path or name for file-card presentation. + * Matching is case-insensitive and applies code filename rules before extension rules; + * unknown names fall back to `other`. + * @param path - File path or basename using either path separator. + * @param context - Optional project files used by context-sensitive code mappings. + * @returns The file's closed presentation category. + */ +export function classifyFileType( + path: string, + context?: FileTypeProjectContext, +): ClassifiedFileType { + const name = basename(path).toLowerCase() + const extension = fileExtension(name).toLowerCase() + return classifyCodeFileType(name, extension, context) + ?? NAME_TYPES[name] + ?? EXTENSION_TYPES[extension] + ?? 'other' +} + +const FILE_BODY = 'M8.48924 28H19.5108C21.6479 28 22.7165 28 23.5594 27.6509C24.6833 27.1853 25.5762 26.2924 26.0417 25.1685C26.3909 24.3256 26.3909 23.257 26.3909 21.1199V8.79443C26.3909 8.32877 26.3909 8.09593 26.3471 7.87507C26.2887 7.58058 26.173 7.30042 26.0067 7.05048C25.882 6.86303 25.7177 6.69799 25.3893 6.36792L20.0611 1.01354C19.7304 0.681235 19.5651 0.515081 19.3769 0.38885C19.126 0.220541 18.8443 0.103463 18.5481 0.0443412C18.3259 0 18.0915 0 17.6226 0H8.48924C6.35209 0 5.28351 0 4.4406 0.349145C3.31672 0.814671 2.4238 1.70759 1.95828 2.83147C1.60913 3.67438 1.60913 4.74296 1.60913 6.88011V21.1199C1.60913 23.257 1.60913 24.3256 1.95828 25.1685C2.4238 26.2924 3.31672 27.1853 4.4406 27.6509C5.28351 28 6.35209 28 8.48924 28Z' +const FILE_FOLD = 'M26.3909 7.37445L19.0525 0V3.77445C19.0525 4.89271 19.0525 5.45184 19.2352 5.89289C19.4788 6.48096 19.946 6.94818 20.5341 7.19176C20.9751 7.37445 21.5342 7.37445 22.6525 7.37445H26.3909Z' +const FILE_MARK_TRANSFORM = 'translate(14 16) scale(1.12) translate(-14 -16)' +const LARGE_FILE_MARK_TRANSFORM = 'translate(14 16) scale(1.22) translate(-14 -16)' +const FOLDER_MARK_TRANSFORM = 'translate(14 13.0693) scale(1.12) translate(-14 -13.0693)' + +function FileGlyph({ + size, className, children, markTransform = FILE_MARK_TRANSFORM, muted = false, +}: IconProps & { children?: ReactNode; markTransform?: string; muted?: boolean }): ReactNode { return ( - - {face.mark} - {/* The grey sheet folds a darker corner; every coloured sheet folds a translucent white one. */} - {kind === 'other' - ? - : } + + {muted + ? + : } + {children !== undefined && ( + {children} + )} ) } + +function FolderGlyph({ size, className }: IconProps): ReactNode { + return ( + + + + + + + ) +} + +function glyph(type: TraditionalFileType, size: number, className: string | undefined): ReactNode { + switch (type) { + case 'code': + return ( + + + + + ) + case 'excel': + return ( + + + + ) + case 'folder': return + case 'html': + return ( + + + + ) + case 'image': + return ( + + + + + + ) + case 'markdown': + return ( + + + + ) + case 'other': return + case 'pdf': + return ( + + + + ) + case 'ppt': + return ( + + + + ) + case 'video': + return ( + + + + ) + case 'word': + return ( + + + + ) + /* v8 ignore next -- closed-union backstop; only reached if a type is forged */ + default: return assertNever(type) + } +} + +/** Closed-union exhaustiveness guard for traditional file artwork. */ +/* v8 ignore next 3 -- only reachable when an untyped caller forges a traditional file type */ +function assertNever(value: never): never { + throw new Error(`unreachable traditional file type: ${String(value)}`) +} + +/** + * Render a decorative file-type glyph for a path or an explicitly resolved kind. + * @param props - Path or kind selection, optional project context, size, and CSS class. + * @returns The category-colored SVG; the caller owns the accessible name and may override + * the color through `--dsh-file-type-icon-color`. + */ +export function FileTypeIcon(props: FileTypeIconProps): ReactNode { + const { size = 28, className } = props + const resolvedType = 'path' in props + ? classifyFileType(props.path, props.context) + : props.kind + return isCodeFileType(resolvedType) + ? + : glyph(resolvedType, size, clsx(css.icon, css[resolvedType], className)) +} diff --git a/packages/client/ui-primitives/src/LinkIcon.tsx b/packages/client/ui-primitives/src/LinkIcon.tsx index 37dade0529..9e5612fd22 100644 --- a/packages/client/ui-primitives/src/LinkIcon.tsx +++ b/packages/client/ui-primitives/src/LinkIcon.tsx @@ -9,10 +9,8 @@ * ic_photo_outline_20, ic_paper_doc_outline_20, ic_paper_outline_20. */ import type { ReactNode } from 'react' -import { - CODE_EXTENSIONS, IMAGE_EXTENSIONS, PDF_EXTENSIONS, SHEET_EXTENSIONS, SLIDES_EXTENSIONS, WORD_EXTENSIONS, - fileExtension, -} from './file-extensions.ts' +import { classifyFileType, fileExtension } from './FileTypeIcon.tsx' +import { isCodeFileType, isLinkCodeExtension } from './code-file-types.ts' import type { IconProps } from './icons/props.ts' /** @@ -28,22 +26,29 @@ export interface LinkIconProps extends IconProps { kind: LinkIconKind } -/** Office-style documents share one paper-doc glyph on a link. */ -const DOCUMENT_SETS = [PDF_EXTENSIONS, SHEET_EXTENSIONS, SLIDES_EXTENSIONS, WORD_EXTENSIONS] - /** - * Derive a file path's link-icon category from its extension. Code, web, and - * data files share the code glyph; unknown and missing extensions fall to - * `other` (the plain-paper glyph). + * Derive a file path's link-icon category from its extension. Unknown and + * missing extensions fall to `other` (the plain-paper glyph). * @param path - File path as the producing tool spelled it (either separator). * @returns The file's glyph category; never `url` or `folder`. */ export function classifyLinkPath(path: string): LinkIconKind { + const type = classifyFileType(path) const extension = fileExtension(path) + if (isCodeFileType(type)) return isLinkCodeExtension(extension) ? 'code' : 'other' if (extension === '') return 'other' - if (CODE_EXTENSIONS.has(extension)) return 'code' - if (IMAGE_EXTENSIONS.has(extension)) return 'image' - return DOCUMENT_SETS.some(set => set.has(extension)) ? 'document' : 'other' + switch (type) { + case 'code': + case 'html': return 'code' + case 'image': return 'image' + case 'excel': + case 'pdf': + case 'ppt': + case 'word': return 'document' + case 'markdown': + case 'other': + case 'video': return 'other' + } } const GlobeGlyph = ({ size, className }: IconProps) => ( diff --git a/packages/client/ui-primitives/src/code-file-types.ts b/packages/client/ui-primitives/src/code-file-types.ts new file mode 100644 index 0000000000..f82aa24370 --- /dev/null +++ b/packages/client/ui-primitives/src/code-file-types.ts @@ -0,0 +1,339 @@ +/** Full-color code and configuration glyphs supplied by square-code-icons v1. */ +export const CODE_FILE_TYPES = [ + 'angular', + 'c', + 'clojure', + 'cmake', + 'cpp', + 'csharp', + 'css', + 'dart', + 'docker', + 'elixir', + 'env', + 'erlang', + 'flutter', + 'git', + 'go', + 'graphql', + 'haskell', + 'ini', + 'java', + 'javascript', + 'json', + 'kotlin', + 'lua', + 'makefile', + 'node', + 'objective-c', + 'perl', + 'php', + 'powershell', + 'protobuf', + 'python', + 'r', + 'react', + 'ruby', + 'rust', + 'scala', + 'shell', + 'solidity', + 'sql', + 'svelte', + 'swift', + 'toml', + 'typescript', + 'vue', + 'wasm', + 'xml', + 'yaml', + 'zig', +] as const + +/** A code or configuration file category with its own full-color square glyph. */ +export type CodeFileType = typeof CODE_FILE_TYPES[number] + +/** Optional project files used by context-sensitive code-icon rules. */ +export interface FileTypeProjectContext { + /** Text content keyed by project-relative or basename path. */ + readonly files: Readonly> +} + +const CODE_FILE_TYPE_SET = new Set(CODE_FILE_TYPES) + +const FILE_NAME_TYPES: Readonly> = { + '.bash_profile': 'shell', + '.bashrc': 'shell', + '.env': 'env', + '.gitattributes': 'git', + '.gitconfig': 'git', + '.gitignore': 'git', + '.gitmodules': 'git', + '.mailmap': 'git', + '.profile': 'shell', + '.zprofile': 'shell', + '.zshrc': 'shell', + 'bsdmakefile': 'makefile', + 'cmakelists.txt': 'cmake', + 'commit_editmsg': 'git', + 'compose.yaml': 'docker', + 'compose.yml': 'docker', + 'docker-compose.yaml': 'docker', + 'docker-compose.yml': 'docker', + 'dockerfile': 'docker', + 'gemfile': 'ruby', + 'gnumakefile': 'makefile', + 'guardfile': 'ruby', + 'makefile': 'makefile', + 'npm-shrinkwrap.json': 'node', + 'package-lock.json': 'node', + 'package.json': 'node', + 'podfile': 'ruby', + 'rakefile': 'ruby', +} + +const FILE_NAME_PREFIX_TYPES: readonly (readonly [string, CodeFileType])[] = [ + ['dockerfile.', 'docker'], + ['.env.', 'env'], +] + +const FILE_NAME_SUFFIX_TYPES: readonly (readonly [string, CodeFileType])[] = [ + ['.component.ts', 'angular'], + ['.component.html', 'angular'], + ['.directive.ts', 'angular'], + ['.service.ts', 'angular'], + ['.module.ts', 'angular'], + ['.pipe.ts', 'angular'], + ['.guard.ts', 'angular'], + ['.interceptor.ts', 'angular'], + ['.dockerfile', 'docker'], +] + +const EXTENSION_TYPES: Readonly> = { + 'bash': 'shell', + 'c': 'c', + 'c++': 'cpp', + 'cc': 'cpp', + 'cfg': 'ini', + 'cjs': 'javascript', + 'clj': 'clojure', + 'cljc': 'clojure', + 'cljs': 'clojure', + 'cmake': 'cmake', + 'cpp': 'cpp', + 'cs': 'csharp', + 'csh': 'shell', + 'css': 'css', + 'csx': 'csharp', + 'cts': 'typescript', + 'cxx': 'cpp', + 'dart': 'dart', + 'dtd': 'xml', + 'edn': 'clojure', + 'erl': 'erlang', + 'es6': 'javascript', + 'escript': 'erlang', + 'ex': 'elixir', + 'exs': 'elixir', + 'fish': 'shell', + 'gemspec': 'ruby', + 'go': 'go', + 'gql': 'graphql', + 'graphql': 'graphql', + 'h': 'c', + 'h++': 'cpp', + 'hh': 'cpp', + 'hpp': 'cpp', + 'hrl': 'erlang', + 'hs': 'haskell', + 'hxx': 'cpp', + 'ini': 'ini', + 'ipp': 'cpp', + 'java': 'java', + 'js': 'javascript', + 'json': 'json', + 'json5': 'json', + 'jsonc': 'json', + 'jsx': 'react', + 'ksh': 'shell', + 'kt': 'kotlin', + 'kts': 'kotlin', + 'lhs': 'haskell', + 'lua': 'lua', + 'm': 'objective-c', + 'mak': 'makefile', + 'mjs': 'javascript', + 'mk': 'makefile', + 'mm': 'objective-c', + 'mts': 'typescript', + 'node': 'node', + 'pch': 'objective-c', + 'php': 'php', + 'php3': 'php', + 'php4': 'php', + 'php5': 'php', + 'phps': 'php', + 'phtml': 'php', + 'pl': 'perl', + 'plist': 'xml', + 'pm': 'perl', + 'pod': 'perl', + 'proto': 'protobuf', + 'ps1': 'powershell', + 'psd1': 'powershell', + 'psm1': 'powershell', + 'py': 'python', + 'pyi': 'python', + 'pyw': 'python', + 'pyx': 'python', + 'r': 'r', + 'rake': 'ruby', + 'rb': 'ruby', + 'rmd': 'r', + 'rs': 'rust', + 'sc': 'scala', + 'scala': 'scala', + 'sh': 'shell', + 'sol': 'solidity', + 'sql': 'sql', + 'svelte': 'svelte', + 'swift': 'swift', + 't': 'perl', + 'tcsh': 'shell', + 'toml': 'toml', + 'tpp': 'cpp', + 'ts': 'typescript', + 'tsx': 'react', + 'vue': 'vue', + 'wasm': 'wasm', + 'wast': 'wasm', + 'wat': 'wasm', + 'xml': 'xml', + 'xsd': 'xml', + 'xsl': 'xml', + 'xslt': 'xml', + 'yaml': 'yaml', + 'yml': 'yaml', + 'zig': 'zig', + 'zsh': 'shell', +} + +const LINK_CODE_EXTENSIONS = new Set([ + 'ts', + 'tsx', + 'js', + 'jsx', + 'mjs', + 'cjs', + 'cts', + 'mts', + 'css', + 'scss', + 'sass', + 'less', + 'html', + 'htm', + 'vue', + 'svelte', + 'astro', + 'json', + 'jsonc', + 'json5', + 'yaml', + 'yml', + 'toml', + 'xml', + 'ini', + 'env', + 'sh', + 'bash', + 'zsh', + 'fish', + 'ps1', + 'bat', + 'cmd', + 'py', + 'pyi', + 'rb', + 'rs', + 'go', + 'java', + 'kt', + 'kts', + 'c', + 'cc', + 'cpp', + 'cxx', + 'h', + 'hh', + 'hpp', + 'cs', + 'php', + 'swift', + 'sql', + 'csv', + 'tsv', + 'proto', + 'graphql', + 'gql', + 'lua', + 'r', + 'pl', + 'scala', + 'clj', + 'cljs', + 'ex', + 'exs', + 'erl', + 'hs', + 'dart', +]) + +function basename(path: string): string { + return path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1) +} + +/** + * Test whether a resolved file type uses the full-color code-icon set. + * @param type - Resolved file-type string. + * @returns Whether the value is a detailed code-file type. + */ +export function isCodeFileType(type: string): type is CodeFileType { + return CODE_FILE_TYPE_SET.has(type) +} + +/** + * Test whether an extension belonged to the established coarse LinkIcon code category. + * @param extension - Extension without a leading dot. + * @returns Whether clickable links keep the code glyph for this extension. + */ +export function isLinkCodeExtension(extension: string): boolean { + return LINK_CODE_EXTENSIONS.has(extension.toLowerCase()) +} + +/** + * Resolve the most specific code/configuration icon according to the supplied map priority. + * @param name - Lowercase basename. + * @param extension - Lowercase extension without the leading dot. + * @param context - Optional project-file snapshot for context-sensitive matches. + * @returns The detailed code type, or null when the traditional file classifier owns the path. + */ +export function classifyCodeFileType( + name: string, + extension: string, + context?: FileTypeProjectContext, +): CodeFileType | null { + const exact = FILE_NAME_TYPES[name] + if (exact !== undefined) return exact + for (const [prefix, type] of FILE_NAME_PREFIX_TYPES) { + if (name.startsWith(prefix)) return type + } + for (const [suffix, type] of FILE_NAME_SUFFIX_TYPES) { + if (name.endsWith(suffix)) return type + } + if (extension === 'dart' && context !== undefined) { + const pubspec = Object.entries(context.files).find(([path]) => basename(path).toLowerCase() === 'pubspec.yaml')?.[1] + if (pubspec?.includes('flutter:') === true) return 'flutter' + } + return EXTENSION_TYPES[extension] ?? null +} diff --git a/packages/client/ui-primitives/src/file-extensions.ts b/packages/client/ui-primitives/src/file-extensions.ts deleted file mode 100644 index 8adbf67af2..0000000000 --- a/packages/client/ui-primitives/src/file-extensions.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * File-extension vocabularies shared by the glyph pickers (`classifyLinkPath`, - * `classifyFileType`), so a path classifies the same way on a link and on a - * file-type icon. - * - * TODO: this is an interim rule. The product will settle one extension - * standard for every place a file is drawn — an upload in the input bar, a - * sent attachment, an artifact, the Sidebar's tree rows, the Sidebar's tab - * chips and pane headers — and these sets, the glyph pickers over them, and - * the sheets in `FileTypeIcon` are to be replaced by it. Until then the - * surfaces differ: the Sidebar classifies by these sets, the conversation's - * links fold the four office types into `document` (`LinkIcon`), and the - * attachment cards (`ui-attachment` `FileCard`) draw one generic glyph with no - * classification at all. - */ - -/** Code, web, and data extensions. */ -export const CODE_EXTENSIONS: ReadonlySet = new Set([ - 'ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'cts', 'mts', 'css', 'scss', 'sass', 'less', - 'html', 'htm', 'vue', 'svelte', 'astro', 'json', 'jsonc', 'json5', 'yaml', 'yml', - 'toml', 'xml', 'ini', 'env', 'sh', 'bash', 'zsh', 'fish', 'ps1', 'bat', 'cmd', - 'py', 'pyi', 'rb', 'rs', 'go', 'java', 'kt', 'kts', 'c', 'cc', 'cpp', 'cxx', - 'h', 'hh', 'hpp', 'cs', 'php', 'swift', 'sql', 'csv', 'tsv', 'proto', 'graphql', - 'gql', 'lua', 'r', 'pl', 'scala', 'clj', 'cljs', 'ex', 'exs', 'erl', 'hs', 'dart', -]) - -/** Raster and vector image extensions. */ -export const IMAGE_EXTENSIONS: ReadonlySet = new Set([ - 'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'avif', 'bmp', 'ico', 'tif', 'tiff', 'heic', 'heif', -]) - -/** Web page extensions; a subset of {@link CODE_EXTENSIONS} with its own file-type glyph. */ -export const HTML_EXTENSIONS: ReadonlySet = new Set(['html', 'htm']) - -/** Markdown extensions. */ -export const MARKDOWN_EXTENSIONS: ReadonlySet = new Set(['md', 'mdx', 'markdown']) - -/** PDF. */ -export const PDF_EXTENSIONS: ReadonlySet = new Set(['pdf']) - -/** Spreadsheet extensions; `csv`/`tsv` also sit in {@link CODE_EXTENSIONS}. */ -export const SHEET_EXTENSIONS: ReadonlySet = new Set(['xls', 'xlsx', 'xlsm', 'csv', 'tsv', 'numbers']) - -/** Slide-deck extensions. */ -export const SLIDES_EXTENSIONS: ReadonlySet = new Set(['ppt', 'pptx', 'key']) - -/** Word-processor document extensions. */ -export const WORD_EXTENSIONS: ReadonlySet = new Set(['doc', 'docx', 'rtf', 'odt', 'pages']) - -/** - * A path's lowercase extension without the dot, taken from its last segment. - * @param path - File path with either separator. - * @returns The extension, or `''` for a name without a dot. - */ -export function fileExtension(path: string): string { - const name = path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1) - const dot = name.lastIndexOf('.') - return dot < 0 ? '' : name.slice(dot + 1).toLowerCase() -} diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index d9b12eda0e..a483fee247 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -33,8 +33,10 @@ export { ReferenceIcon } from './ReferenceIcon.tsx' export type { ReferenceIconKind, ReferenceIconProps } from './ReferenceIcon.tsx' export { LinkIcon, classifyLinkPath } from './LinkIcon.tsx' export type { LinkIconKind, LinkIconProps } from './LinkIcon.tsx' -export { FileTypeIcon, classifyFileType } from './FileTypeIcon.tsx' -export type { FileTypeKind, FileTypeIconProps } from './FileTypeIcon.tsx' +export { FileTypeIcon, classifyFileType, fileExtension } from './FileTypeIcon.tsx' +export type { + CodeFileType, FileType, FileTypeIconProps, FileTypeKind, FileTypeProjectContext, +} from './FileTypeIcon.tsx' export { projectUserText } from './user-text.tsx' export { Tooltip } from './Tooltip.tsx' export type { TooltipSide } from './Tooltip.tsx' diff --git a/packages/client/ui-primitives/tests/code-file-icon.client.spec.tsx b/packages/client/ui-primitives/tests/code-file-icon.client.spec.tsx new file mode 100644 index 0000000000..f8b155e871 --- /dev/null +++ b/packages/client/ui-primitives/tests/code-file-icon.client.spec.tsx @@ -0,0 +1,123 @@ +// @vitest-environment jsdom +import { cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { + classifyFileType, FileTypeIcon, type CodeFileType, type FileTypeProjectContext, +} from '@deepseek-ai/dsh-client-ui-primitives' +import { CODE_FILE_TYPES } from '../src/code-file-types.ts' + +afterEach(cleanup) + +const flutter: FileTypeProjectContext = { + files: { 'config/pubspec.yaml': 'name: demo\ndependencies:\n flutter: sdk' }, +} + +describe('code-file classification', () => { + it.each([ + ['app.component.ts', 'angular'], + ['file.c', 'c'], + ['file.clj', 'clojure'], + ['CMakeLists.txt', 'cmake'], + ['file.cc', 'cpp'], + ['file.cs', 'csharp'], + ['file.css', 'css'], + ['file.dart', 'dart'], + ['Dockerfile', 'docker'], + ['file.ex', 'elixir'], + ['.env', 'env'], + ['file.erl', 'erlang'], + ['.gitignore', 'git'], + ['file.go', 'go'], + ['file.graphql', 'graphql'], + ['file.hs', 'haskell'], + ['file.ini', 'ini'], + ['file.java', 'java'], + ['file.js', 'javascript'], + ['file.json', 'json'], + ['file.kt', 'kotlin'], + ['file.lua', 'lua'], + ['Makefile', 'makefile'], + ['package.json', 'node'], + ['file.m', 'objective-c'], + ['file.pl', 'perl'], + ['file.php', 'php'], + ['file.ps1', 'powershell'], + ['file.proto', 'protobuf'], + ['file.py', 'python'], + ['file.r', 'r'], + ['file.jsx', 'react'], + ['Gemfile', 'ruby'], + ['file.rs', 'rust'], + ['file.scala', 'scala'], + ['.bashrc', 'shell'], + ['file.sol', 'solidity'], + ['file.sql', 'sql'], + ['file.svelte', 'svelte'], + ['file.swift', 'swift'], + ['file.toml', 'toml'], + ['file.ts', 'typescript'], + ['file.vue', 'vue'], + ['file.wasm', 'wasm'], + ['file.xml', 'xml'], + ['file.yaml', 'yaml'], + ['file.zig', 'zig'], + ] as [string, CodeFileType][])('%s → %s', (path, type) => { + expect(classifyFileType(path)).toBe(type) + }) + + it('applies filename priority before generic extensions', () => { + expect(classifyFileType('package.json')).toBe('node') + expect(classifyFileType('docker-compose.yaml')).toBe('docker') + expect(classifyFileType('feature.component.ts')).toBe('angular') + expect(classifyFileType('Dockerfile.local')).toBe('docker') + expect(classifyFileType('.env.production')).toBe('env') + }) + + it('selects Flutter only when the supplied project snapshot identifies it', () => { + expect(classifyFileType('lib/main.dart')).toBe('dart') + expect(classifyFileType('lib/main.dart', { files: { 'pubspec.yaml': 'name: demo' } })).toBe('dart') + expect(classifyFileType('lib/main.dart', flutter)).toBe('flutter') + + const view = render() + const explicit = render() + expect(view.container.innerHTML).toBe(explicit.container.innerHTML) + }) + + it('keeps Markdown and SVG on the traditional file artwork', () => { + expect(classifyFileType('README.md')).toBe('markdown') + expect(classifyFileType('logo.svg')).toBe('image') + }) +}) + +describe('full-color code-file artwork', () => { + it.each(CODE_FILE_TYPES)('%s renders its supplied square SVG', (type) => { + const { container } = render() + const svg = container.querySelector('svg')! + expect(svg.getAttribute('viewBox')).toBe('0 0 20 20') + expect(svg.getAttribute('width')).toBe('28') + expect(svg.getAttribute('aria-hidden')).toBe('true') + expect(container.innerHTML).toMatch(/#[0-9a-fA-F]{3,8}/) + expect(container.innerHTML).not.toContain('currentColor') + }) + + it('keeps every code category visually distinct', () => { + const artwork = CODE_FILE_TYPES.map((type) => { + const { container } = render() + return container.querySelector('svg')!.innerHTML + }) + expect(new Set(artwork).size).toBe(CODE_FILE_TYPES.length) + }) + + it('uses instance-safe gradient ids and forwards sizing classes', () => { + const { container } = render(<>) + const ids = [...container.querySelectorAll('linearGradient')].map(gradient => gradient.id) + expect(new Set(ids).size).toBe(2) + expect([...container.querySelectorAll('rect')].map(rect => rect.getAttribute('fill'))).toEqual(ids.map(id => `url(#${id})`)) + + const sized = render() + const svg = sized.container.querySelector('svg')! + expect(svg.getAttribute('width')).toBe('16') + expect(svg.getAttribute('height')).toBe('16') + expect(svg.classList.contains('x')).toBe(true) + }) +}) diff --git a/packages/client/ui-primitives/tests/file-type-icon-styles.client.spec.ts b/packages/client/ui-primitives/tests/file-type-icon-styles.client.spec.ts new file mode 100644 index 0000000000..347241675d --- /dev/null +++ b/packages/client/ui-primitives/tests/file-type-icon-styles.client.spec.ts @@ -0,0 +1,38 @@ +/** FileTypeIcon's default per-category palette as CSS text. */ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import type { CodeFileType, FileType } from '@deepseek-ai/dsh-client-ui-primitives' + +const css = readFileSync(fileURLToPath(new URL('../src/FileTypeIcon.module.css', import.meta.url)), 'utf8') + +type TraditionalFileType = Exclude + +const TYPE_COLORS: Readonly> = { + code: 'var(--dsw-static-deepseek-500)', + excel: 'var(--dsw-static-green-500)', + folder: 'var(--dsw-static-amber-400)', + html: 'var(--dsw-static-deepseek-500)', + image: 'var(--dsh-file-type-violet)', + markdown: 'var(--dsw-static-deepseek-500)', + other: 'var(--dsw-static-neutral-bluish-300)', + pdf: 'var(--dsw-static-red-600)', + ppt: 'var(--dsw-static-amber-500)', + video: 'var(--dsh-file-type-violet)', + word: 'var(--dsw-static-deepseek-450)', +} + +describe('FileTypeIcon.module.css', () => { + it.each(Object.entries(TYPE_COLORS) as [TraditionalFileType, string][])( + '%s has its own default color', + (type, color) => { + const rule = css.match(new RegExp(`\\.${type}\\s*\\{([^}]*)\\}`))?.[1] + expect(rule).toContain(`--dsh-file-type-default-color: ${color}`) + }, + ) + + it('keeps one caller override and the supplied violet in named variables', () => { + expect(css).toContain('color: var(--dsh-file-type-icon-color, var(--dsh-file-type-default-color))') + expect(css).toContain('--dsh-file-type-violet: rgb(139, 118, 246)') + }) +}) diff --git a/packages/client/ui-primitives/tests/file-type-icon.client.spec.tsx b/packages/client/ui-primitives/tests/file-type-icon.client.spec.tsx index 306a9e7941..65f9dbaecb 100644 --- a/packages/client/ui-primitives/tests/file-type-icon.client.spec.tsx +++ b/packages/client/ui-primitives/tests/file-type-icon.client.spec.tsx @@ -1,61 +1,133 @@ // @vitest-environment jsdom import { cleanup, render } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' -import { FileTypeIcon, classifyFileType, type FileTypeKind } from '@deepseek-ai/dsh-client-ui-primitives' +import { + classifyFileType, fileExtension, FileTypeIcon, type FileTypeKind, +} from '@deepseek-ai/dsh-client-ui-primitives' +import css from '../src/FileTypeIcon.module.css' afterEach(cleanup) -const KINDS: FileTypeKind[] = ['code', 'html', 'image', 'markdown', 'pdf', 'sheet', 'slides', 'document', 'other'] +describe('fileExtension', () => { + it.each([ + ['src/README.md', 'md'], + ['C:\\work\\REPORT.PDF', 'PDF'], + ['.env', 'env'], + ['archive.tar.gz', 'gz'], + ['Makefile', ''], + ['trailing.', ''], + ])('%s → %s', (path, extension) => { + expect(fileExtension(path)).toBe(extension) + }) +}) describe('classifyFileType', () => { it.each([ - ['src/render.tsx', 'code'], - ['site/index.HTML', 'html'], - ['README.md', 'markdown'], - ['data/export.csv', 'sheet'], - ['book.xlsx', 'sheet'], + ['src/index.TSX', 'react'], + ['site/index.html', 'html'], + ['data/export.CSV', 'code'], ['shots/hero.png', 'image'], + ['notes/README.MDX', 'markdown'], ['report.pdf', 'pdf'], - ['deck.pptx', 'slides'], - ['C:\\work\\summary.docx', 'document'], + ['deck.pptx', 'ppt'], + ['clip.webm', 'video'], + ['letter.docx', 'word'], + ['budget.xlsx', 'excel'], + ['Makefile', 'makefile'], + ['README', 'markdown'], + ['.env', 'env'], + ['.hidden', 'other'], ['notes.unknownext', 'other'], - ['Makefile', 'other'], + ['extensionless', 'other'], ['archive.tar/.hidden', 'other'], - ] as [string, FileTypeKind][])('%s → %s', (path, kind) => { - expect(classifyFileType(path)).toBe(kind) + ] as [string, Exclude][])('%s → %s', (path, type) => { + expect(classifyFileType(path)).toBe(type) }) }) describe('FileTypeIcon', () => { - it.each(KINDS)('%s draws the shared sheet in its own colour, aria-hidden', (kind) => { - const { container } = render() + const types: FileTypeKind[] = [ + 'code', 'excel', 'folder', 'html', 'image', 'markdown', 'other', 'pdf', 'ppt', 'video', 'word', + ] + + it.each(types)('%s renders a distinct aria-hidden svg without literal colors', (type) => { + const { container } = render() const svg = container.querySelector('svg')! + expect(svg).not.toBeNull() expect(svg.getAttribute('aria-hidden')).toBe('true') - expect(svg.getAttribute('width')).toBe('28') - expect(svg.querySelector('path')!.getAttribute('fill')).toMatch(/^#[0-9A-F]{6}$/) + expect(svg.classList.contains(css.icon!)).toBe(true) + expect(svg.classList.contains(css[type]!)).toBe(true) + const markup = container.innerHTML + expect(markup).not.toMatch(/#[0-9a-fA-F]{3,8}/) + const paints = [...container.querySelectorAll('[fill], [stroke]')].flatMap(node => + ['fill', 'stroke'].map(attribute => node.getAttribute(attribute)).filter(value => value !== null), + ) + expect(paints).toContain('currentColor') + expect(paints.every(value => [ + 'currentColor', 'none', 'var(--dsw-static-neutral-00)', 'var(--dsw-static-neutral-400)', + ].includes(value))).toBe(true) }) - it('every kind carries a distinct mark', () => { - const marks = KINDS.map((kind) => { - const { container } = render() - const [, mark] = Array.from(container.querySelectorAll('path, rect')) - return `${mark!.getAttribute('d') ?? ''}${mark!.getAttribute('y') ?? ''}` + it.each(types)('%s uses a solid sheet fill', (type) => { + const { container } = render() + const sheet = container.querySelector('svg > path')! + expect(sheet.getAttribute('fill')).toBe('currentColor') + expect(sheet.getAttribute('fill-opacity')).toBeNull() + }) + + it('uses white marks and fold for coloured sheets, with a darker grey fold for other', () => { + const coloured = render().container + expect(coloured.querySelector('[data-file-type-mark]')?.getAttribute('color')) + .toBe('var(--dsw-static-neutral-00)') + expect(coloured.querySelector('svg > path:nth-of-type(2)')?.getAttribute('fill')) + .toBe('var(--dsw-static-neutral-00)') + + const other = render().container + expect(other.querySelector('svg > path:nth-of-type(2)')?.getAttribute('fill')) + .toBe('var(--dsw-static-neutral-400)') + }) + + it('draws one distinct glyph for every category', () => { + const paths = types.map((type) => { + const { container } = render() + return [...container.querySelectorAll('path')].map(path => path.getAttribute('d')).join('|') }) - expect(new Set(marks).size).toBe(KINDS.length) + expect(new Set(paths).size).toBe(types.length) }) - it('folds a darker corner on the grey sheet and a translucent one elsewhere', () => { - const grey = render().container.querySelectorAll('path') - expect(grey[grey.length - 1]!.getAttribute('fill')).toBe('#A2A4A6') - const blue = render().container.querySelectorAll('path') - expect(blue[blue.length - 1]!.getAttribute('fill-opacity')).toBe('0.7') + it.each(['code', 'folder', 'html', 'image', 'video'] as const)( + 'enlarges the %s center mark without scaling the file shell', + (type) => { + const { container } = render() + expect(container.querySelector('[data-file-type-mark]')?.getAttribute('transform')).toContain('scale(1.12)') + expect(container.querySelector('svg > path')?.getAttribute('transform')).toBeNull() + }, + ) + + it.each(['excel', 'markdown', 'pdf', 'ppt', 'word'] as const)( + 'gives the %s center mark the larger emphasis scale', + (type) => { + const { container } = render() + expect(container.querySelector('[data-file-type-mark]')?.getAttribute('transform')).toContain('scale(1.22)') + expect(container.querySelector('svg > path')?.getAttribute('transform')).toBeNull() + }, + ) + + it('keeps the generic file glyph without an invented center mark', () => { + const { container } = render() + expect(container.querySelector('[data-file-type-mark]')).toBeNull() }) - it('size and className land on the svg', () => { - const { container } = render() - const svg = container.querySelector('svg')! - expect(svg.getAttribute('width')).toBe('16') - expect(svg.getAttribute('height')).toBe('16') + it('classifies the path by default and forwards size and className', () => { + const inferred = render() + const explicit = render() + expect(inferred.container.innerHTML).toBe(explicit.container.innerHTML) + expect(inferred.container.querySelector('svg')!.getAttribute('width')).toBe('28') + + const sized = render() + const svg = sized.container.querySelector('svg')! + expect(svg.getAttribute('width')).toBe('20') + expect(svg.getAttribute('height')).toBe('20') expect(svg.classList.contains('x')).toBe(true) }) }) diff --git a/packages/client/ui-primitives/tests/link-icon.client.spec.tsx b/packages/client/ui-primitives/tests/link-icon.client.spec.tsx index 170ba6f726..8b1152a65e 100644 --- a/packages/client/ui-primitives/tests/link-icon.client.spec.tsx +++ b/packages/client/ui-primitives/tests/link-icon.client.spec.tsx @@ -12,9 +12,13 @@ describe('classifyLinkPath', () => { ['data/export.CSV', 'code'], ['shots/hero.png', 'image'], ['report.pdf', 'document'], + ['budget.xlsx', 'document'], ['deck.pptx', 'document'], + ['notes.md', 'other'], + ['clip.mp4', 'other'], ['notes.unknownext', 'other'], ['Makefile', 'other'], + ['README', 'other'], ['C:\\work\\summary.docx', 'document'], ['archive.tar/.hidden', 'other'], ] as [string, LinkIconKind][])('%s → %s', (path, kind) => { diff --git a/packages/client/ui-sidebar-documentpreview/tests/text-title.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/text-title.client.spec.tsx index 564a491c9e..8b3913ebeb 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/text-title.client.spec.tsx +++ b/packages/client/ui-sidebar-documentpreview/tests/text-title.client.spec.tsx @@ -21,8 +21,8 @@ describe('TextTitle', () => { }) it('picks the sheet from the title\'s extension', () => { - const markdown = render().container.querySelector('path')?.getAttribute('fill') - const pdf = render().container.querySelector('path')?.getAttribute('fill') + const markdown = render().container.querySelector('svg')?.innerHTML + const pdf = render().container.querySelector('svg')?.innerHTML expect(markdown).not.toBe(pdf) }) }) diff --git a/packages/client/ui-sidebar-files/tests/files-body.client.spec.tsx b/packages/client/ui-sidebar-files/tests/files-body.client.spec.tsx index d9c8309481..51bf217312 100644 --- a/packages/client/ui-sidebar-files/tests/files-body.client.spec.tsx +++ b/packages/client/ui-sidebar-files/tests/files-body.client.spec.tsx @@ -53,6 +53,9 @@ describe('FilesBody', () => { expect(path?.getAttribute('title')).toBe(ROOT) expect([...path?.querySelectorAll('span > span') ?? []].map(span => span.textContent)).toEqual(['/work/', 'app']) expect(names(view.container)).toEqual([`${ROOT}/src`, `${ROOT}/.env`, `${ROOT}/pipe`, `${ROOT}/README.md`]) + const envIcon = view.container.querySelector(`[data-files-path="${ROOT}/.env"] svg`)?.innerHTML + const readmeIcon = view.container.querySelector(`[data-files-path="${ROOT}/README.md"] svg`)?.innerHTML + expect(envIcon).not.toBe(readmeIcon) }) it('heads a separator-only root by the root itself, since it has no final segment', async () => { From 1eee75b424835dfb5d2766a953e88013a513ac14 Mon Sep 17 00:00:00 2001 From: yixiangihsiang Date: Wed, 9 Sep 2026 16:37:44 +0800 Subject: [PATCH 09/20] fix(client): satisfy icon validation gates --- .../client/ui-primitives/src/CodeFileIcon.tsx | 52 +++++++++++++------ .../ui-primitives/tests/icons.client.spec.tsx | 15 +++++- 2 files changed, 50 insertions(+), 17 deletions(-) diff --git a/packages/client/ui-primitives/src/CodeFileIcon.tsx b/packages/client/ui-primitives/src/CodeFileIcon.tsx index 2e32bee8b0..a6f5608153 100644 --- a/packages/client/ui-primitives/src/CodeFileIcon.tsx +++ b/packages/client/ui-primitives/src/CodeFileIcon.tsx @@ -3,6 +3,26 @@ import type { ReactNode } from 'react' import type { IconProps } from './icons/props.ts' import type { CodeFileType } from './code-file-types.ts' +/** Non-localizable letterforms that are part of the source-format artwork. */ +const CODE_MARKS = { + c: 'C', + cpp: 'C', + csharp: 'C', + css: 'CSS', + env: 'ENV', + erlang: 'E', + ini: 'INI', + javascript: 'JS', + objectiveC: 'OC', + perl: 'PL', + php: 'PHP', + r: 'R', + toml: 'TOML', + typescript: 'TS', + yaml: 'YAML', + zig: 'ZIG', +} as const + /** * Render one full-color square code-file glyph from the embedded icon set. * @param props - Detailed code type, optional size, and optional CSS class. @@ -33,7 +53,7 @@ function codeFileArtwork(type: CodeFileType, gradientId: string): ReactNode { - {'C'} + {CODE_MARKS.c} ) @@ -63,7 +83,7 @@ function codeFileArtwork(type: CodeFileType, gradientId: string): ReactNode { - {'C'} + {CODE_MARKS.cpp} @@ -74,7 +94,7 @@ function codeFileArtwork(type: CodeFileType, gradientId: string): ReactNode { - {'C'} + {CODE_MARKS.csharp} @@ -84,7 +104,7 @@ function codeFileArtwork(type: CodeFileType, gradientId: string): ReactNode { <> - {'CSS'} + {CODE_MARKS.css} ) @@ -125,7 +145,7 @@ function codeFileArtwork(type: CodeFileType, gradientId: string): ReactNode { <> - {'ENV'} + {CODE_MARKS.env} ) @@ -135,7 +155,7 @@ function codeFileArtwork(type: CodeFileType, gradientId: string): ReactNode { - {'E'} + {CODE_MARKS.erlang} ) @@ -192,7 +212,7 @@ function codeFileArtwork(type: CodeFileType, gradientId: string): ReactNode { <> - {'INI'} + {CODE_MARKS.ini} ) @@ -209,7 +229,7 @@ function codeFileArtwork(type: CodeFileType, gradientId: string): ReactNode { <> - {'JS'} + {CODE_MARKS.javascript} ) @@ -266,7 +286,7 @@ function codeFileArtwork(type: CodeFileType, gradientId: string): ReactNode { <> - {'OC'} + {CODE_MARKS.objectiveC} ) @@ -275,7 +295,7 @@ function codeFileArtwork(type: CodeFileType, gradientId: string): ReactNode { <> - {'PL'} + {CODE_MARKS.perl} ) @@ -285,7 +305,7 @@ function codeFileArtwork(type: CodeFileType, gradientId: string): ReactNode { - {'PHP'} + {CODE_MARKS.php} ) @@ -351,7 +371,7 @@ function codeFileArtwork(type: CodeFileType, gradientId: string): ReactNode { - {'R'} + {CODE_MARKS.r} ) @@ -405,7 +425,7 @@ function codeFileArtwork(type: CodeFileType, gradientId: string): ReactNode { <> - {'TOML'} + {CODE_MARKS.toml} ) @@ -414,7 +434,7 @@ function codeFileArtwork(type: CodeFileType, gradientId: string): ReactNode { <> - {'TS'} + {CODE_MARKS.typescript} ) @@ -445,7 +465,7 @@ function codeFileArtwork(type: CodeFileType, gradientId: string): ReactNode { <> - {'YAML'} + {CODE_MARKS.yaml} ) @@ -455,7 +475,7 @@ function codeFileArtwork(type: CodeFileType, gradientId: string): ReactNode { - {'ZIG'} + {CODE_MARKS.zig} ) diff --git a/packages/client/ui-primitives/tests/icons.client.spec.tsx b/packages/client/ui-primitives/tests/icons.client.spec.tsx index 1b9a9c7773..62cced1199 100644 --- a/packages/client/ui-primitives/tests/icons.client.spec.tsx +++ b/packages/client/ui-primitives/tests/icons.client.spec.tsx @@ -3,7 +3,7 @@ import { cleanup, render } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' import * as primitives from '@deepseek-ai/dsh-client-ui-primitives' import { - IconAlarmClockOutline16, IconApiOutline14, IconArchiveOutline20, IconFolderClose16, + DocumentFileIcon, IconAlarmClockOutline16, IconApiOutline14, IconArchiveOutline20, IconFolderClose16, IconGoalOutline16, IconSendOutline16, } from '@deepseek-ai/dsh-client-ui-primitives' @@ -70,6 +70,19 @@ describe('FishLogo', () => { }) }) +describe('DocumentFileIcon', () => { + it('renders the generic document artwork with instance-safe gradients and its class', () => { + const { container } = render(<>) + const svgs = [...container.querySelectorAll('svg')] + const ids = [...container.querySelectorAll('linearGradient')].map(gradient => gradient.id) + expect(svgs).toHaveLength(2) + expect(svgs[0]?.classList.contains('document')).toBe(true) + expect(new Set(ids).size).toBe(2) + expect([...container.querySelectorAll('svg > path:first-child')].map(path => path.getAttribute('fill'))) + .toEqual(ids.map(id => `url(#${id})`)) + }) +}) + describe('BrandWordmark', () => { it('can render the name artwork with or without its leading mark', () => { const view = render() From 00bfdfbce633076dd5510c91f3d328ccf6cfcdfc Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 9 Sep 2026 16:44:51 +0800 Subject: [PATCH 10/20] fix(present): follow session filesystem access for deliverables --- ...8-present-workspace-source-files.i18n.yaml | 4 +- ...26-09-08-present-workspace-source-files.md | 10 +-- ...09-08-present-workspace-source-files.zh.md | 8 +-- ...-09-09-present-filesystem-access.i18n.yaml | 6 ++ .../2026-09-09-present-filesystem-access.md | 27 ++++++++ ...2026-09-09-present-filesystem-access.zh.md | 27 ++++++++ docs/config-catalog.md | 2 +- docs/persistence-catalog.md | 2 +- docs/tool-catalog.md | 4 +- .../client/ui-deliverables/README.i18n.yaml | 4 +- packages/client/ui-deliverables/README.md | 8 +-- packages/client/ui-deliverables/README.zh.md | 8 +-- packages/client/ui-deliverables/package.json | 7 +- .../src/client/PresentedFileCard.tsx | 2 +- .../ui-deliverables/src/client/locales.ts | 2 + .../src/client/present-open.ts | 6 +- packages/client/ui-deliverables/src/index.ts | 6 +- .../ui-deliverables/src/present-open.ts | 31 +++++---- .../tests/present-open.client.spec.ts | 9 +++ .../tests/present-open.host.spec.ts | 68 +++++++++++++++---- .../ui-deliverables/tests/prompt.host.spec.ts | 2 + .../client/ui-deliverables/tsconfig.host.json | 9 +++ packages/fs/tool-present/README.i18n.yaml | 4 +- packages/fs/tool-present/README.md | 12 ++-- packages/fs/tool-present/README.zh.md | 12 ++-- packages/fs/tool-present/src/index.ts | 10 +-- packages/fs/tool-present/src/types.ts | 6 +- .../fs/tool-present/tests/present.spec.ts | 32 ++++++++- pnpm-lock.yaml | 15 ++++ 29 files changed, 258 insertions(+), 85 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-09-09-present-filesystem-access.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-09-09-present-filesystem-access.md create mode 100644 .agents/notes/implemented/feature/2026-09-09-present-filesystem-access.zh.md diff --git a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml index 715341ab78..9d3acb0e07 100644 --- a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.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 .agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md -2026-09-08-present-workspace-source-files.md: b8956b17a20090847da6de3c301254a73701e6f2 -2026-09-08-present-workspace-source-files.zh.md: 2e82f4e27a99f484005243a4769f3aadad290666 +2026-09-08-present-workspace-source-files.md: 650c16fe5a614ce1ccf118744a5f0e8cb5d19f4b +2026-09-08-present-workspace-source-files.zh.md: 61bca82c83ff42600a7017039a6be4c3a4605769 diff --git a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md index b8956b17a2..650c16fe5a 100644 --- a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md +++ b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md @@ -10,15 +10,15 @@ Users need to open and edit the files produced in their workspace, including she ## Decision -The [present tool](../../../../packages/fs/tool-present/README.md) declares existing regular files inside the calling Session's workspace. It records paths and optional descriptions without reading or copying contents. The [deliverables plugin](../../../../packages/client/ui-deliverables/README.md) opens current workspace sources in the Host's default application. Edits are visible on the next open; deletion or movement makes the declaration unavailable. File-content preservation and copy-on-write storage are deferred until a persistence design owns them. +The [present tool](../../../../packages/fs/tool-present/README.md) declares existing regular source files under the [Session filesystem access policy](2026-09-09-present-filesystem-access.md). It records paths and optional descriptions without reading or copying contents. The [deliverables plugin](../../../../packages/client/ui-deliverables/README.md) opens current workspace sources in the Host's default application. Edits are visible on the next open; deletion or movement makes the declaration unavailable. File-content preservation and copy-on-write storage are deferred until a persistence design owns them. The tool description requires `present` after writing a file the user asked to receive and before the final response, including files created through Bash or code execution. A prose path reference does not replace the call. The recorded [SVG delivery scenario](../../../../snapshots/web/present-svg/snapshot.yml) uses a user request that does not name `present`, and checks the resulting file, delivery event, and card. Its UI snapshot covers the expanded Chat transcript; navigation and composer controls belong to their own scenarios, so unrelated chrome changes cannot invalidate file-delivery expectations. The tool remains an ordinary package with shared filesystem and tool error classes. Its pure type entry owns the delivery event without importing Host code into the browser. The `standard`, `ptc`, and `cordis` presets mount it; `minimal` retains its two tools. Each plugin instance correlates its executions with successful final `tools/result` notifications before appending `deliverables/presented`. Native and nested calls share this rule. A later enclosing program failure does not revoke a completed nested declaration; blocked results publish none, and same-name scoped replacements cannot publish another instance's results. -An authenticated POST selects a declaration by viewed Session, event sequence, and original file index. The event carries no owning Session ID; relative paths in inherited history resolve against the viewed Session's workspace. The Host rechecks canonical workspace containment and regular-file existence before native opening. Route disposal cancels and awaits pending commands. The “Files changed” row lists successful file-tool mutations and retains its separate text-preview behavior. Its Chinese label is “本轮文件改动”; neither label implies final delivery. +An authenticated POST selects a declaration by viewed Session, event sequence, and original file index. The event carries no owning Session ID; relative paths in inherited history resolve against the viewed Session's workspace. The Host verifies regular-file existence and Host-path mapping before native opening. Route disposal cancels and awaits pending commands. The “Files changed” row lists successful file-tool mutations and retains its separate text-preview behavior. Its Chinese label is “本轮文件改动”; neither label implies final delivery. -File cards use the same split-control pattern as the Session header. The card and the left Open segment preview the source in the right Sidebar; the chevron opens the standard menu for default-app and file-manager actions. The Host selects the file in Finder or Explorer, or opens its containing folder through the default Linux file manager. Both native actions resolve the same saved declaration and recheck workspace containment; neither accepts a browser-supplied replacement path. Host-derived desktop metadata keeps remote-browser labels and availability honest, and the route enforces the configured availability on each native gesture. One delivery spans the row; multiple deliveries use at most two columns, retain every declaration, and collapse after the first four cards until the user expands the list. Desktop metadata is invalidated with the connection generation so an old Host cannot keep native actions disabled or supply the wrong file-manager labels. Old metadata requests are cancelled and cannot replace the new generation’s response. +File cards use the same split-control pattern as the Session header. The card and the left Open segment preview the source in the right Sidebar; the chevron opens the standard menu for default-app and file-manager actions. The Host selects the file in Finder or Explorer, or opens its containing folder through the default Linux file manager. Both native actions resolve the same saved declaration and verify the Session filesystem and Host path; neither accepts a browser-supplied replacement path. Host-derived desktop metadata keeps remote-browser labels and availability honest, and the route enforces the configured availability on each native gesture. One delivery spans the row; multiple deliveries use at most two columns, retain every declaration, and collapse after the first four cards until the user expands the list. Desktop metadata is invalidated with the connection generation so an old Host cannot keep native actions disabled or supply the wrong file-manager labels. Old metadata requests are cancelled and cannot replace the new generation’s response. ## Alternatives considered @@ -30,7 +30,7 @@ File cards use the same split-control pattern as the Session header. The card an **Tool text as the durable index** cannot survive post-processing or result spill reliably. Execution identity and final successful results retain declaration ownership independently of displayed tool text. -**Descriptor-bound filesystem extensions** would change every provider without making an external desktop application's later path lookup atomic. Current checks reject ordinary escapes; concurrent swap-and-restore remains outside the path API's guarantees. +**Descriptor-bound filesystem extensions** would change every provider without making an external desktop application's later path lookup atomic. Current checks verify file metadata and path mapping; concurrent swap-and-restore remains outside the path API's guarantees. ## Consequences @@ -38,4 +38,4 @@ The Session log persists declarations but no attachment references or file conte The removed file-size cap has no role in a metadata-only declaration; the configurable file-count limit still bounds result size. Cards show file names and descriptions, falling back to file types, without stale byte-size metadata. No artifact service or speculative storage fallback is introduced. -Focused tests cover content-free declarations, invalid inputs, blocked results, source-path identity, current bytes after edits, missing files, workspace escapes, fork-relative paths, retry, cancellation, and disposal. The recorded Web scenario covers nested completion followed by enclosing failure, source edits, reload, deletion errors, card and prose opens without browser downloads, and content-free Session export. +Focused tests cover content-free declarations, invalid inputs, blocked results, source-path identity, current bytes after edits, missing files, external paths and unavailable Host mappings, fork-relative paths, retry, cancellation, and disposal. The recorded Web scenario covers nested completion followed by enclosing failure, source edits, reload, deletion errors, card and prose opens without browser downloads, and content-free Session export. diff --git a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md index 2e82f4e27a..61bca82c83 100644 --- a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md +++ b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md @@ -10,15 +10,15 @@ Status: implemented ## 决策 -[present 工具](../../../../packages/fs/tool-present/README.zh.md)声明交付调用方 Session 工作区中已存在的普通文件。它记录路径和可选说明,不读取或复制内容。[交付插件](../../../../packages/client/ui-deliverables/README.zh.md)使用 Host 默认应用打开当前工作区源文件。下次打开会看到编辑后的内容;删除或移动文件会使声明不可用。文件内容保留与写时复制存储延期到有持久化设计负责时实现。 +[present 工具](../../../../packages/fs/tool-present/README.zh.md)声明交付[Session 文件系统访问策略](2026-09-09-present-filesystem-access.zh.md)允许的已有普通源文件。它记录路径和可选说明,不读取或复制内容。[交付插件](../../../../packages/client/ui-deliverables/README.zh.md)使用 Host 默认应用打开当前工作区源文件。下次打开会看到编辑后的内容;删除或移动文件会使声明不可用。文件内容保留与写时复制存储延期到有持久化设计负责时实现。 工具说明要求在写好用户要求接收的文件后、最终回复前调用 `present`,包括通过 Bash 或代码执行创建的文件。正文中的路径引用不能替代调用。录制的 [SVG 交付场景](../../../../snapshots/web/present-svg/snapshot.yml)使用未提及 `present` 的用户请求,检查生成文件、交付事件和卡片。其 UI 快照覆盖展开后的 Chat 对话内容;导航和输入框控件由各自场景负责,避免无关界面改动使文件交付预期失效。 工具保持为普通包,共享文件系统和工具错误类型。其纯类型入口拥有交付事件,不向浏览器导入 Host 代码。`standard`、`ptc` 与 `cordis` preset 挂载工具;`minimal` 保持两个工具。每个插件实例将其执行与成功的最终 `tools/result` 通知关联,再追加 `deliverables/presented`。原生与嵌套调用遵循同一规则。外层程序随后失败不会撤销已完成的嵌套声明;被阻止的结果不发布声明,同名作用域替换也不能发布其他实例的结果。 -经过认证的 POST 按当前查看的 Session、事件序号和原始文件索引选择声明。事件不携带所属 Session ID;继承历史中的相对路径按当前查看的 Session 工作区解析。Host 在原生打开前重新检查规范路径的工作区包含关系和普通文件是否存在。路由释放时取消并等待进行中的命令。“本轮文件改动”行列出成功的文件工具修改,并保留独立的文本预览行为。其英文标签为“Files changed”;两个标签均不表示最终交付。 +经过认证的 POST 按当前查看的 Session、事件序号和原始文件索引选择声明。事件不携带所属 Session ID;继承历史中的相对路径按当前查看的 Session 工作区解析。Host 在原生打开前检查普通文件是否存在,并验证 Host 路径映射。路由释放时取消并等待进行中的命令。“本轮文件改动”行列出成功的文件工具修改,并保留独立的文本预览行为。其英文标签为“Files changed”;两个标签均不表示最终交付。 -文件卡片采用与 Session 顶栏相同的分段控件。点击卡片或左侧“打开”区域会在右侧 Sidebar 预览源文件;右侧箭头打开包含默认应用与文件管理器操作的标准菜单。Host 在 Finder 或文件资源管理器中选中文件,或通过 Linux 默认文件管理器打开所在文件夹。两个原生操作都解析同一份已保存声明并重新检查工作区包含关系;均不接受浏览器提供的替代路径。来自 Host 的桌面信息使远程浏览器中的文案和可用性保持准确,路由在每次原生操作时执行配置的可用性检查。单个交付占满整行;多个交付每行最多两列,并保留所有声明,前四张卡片之后的内容在用户展开列表前保持收起。 桌面元数据随连接代次失效,避免旧主机信息让原生操作持续禁用或显示错误的文件管理器名称。旧元数据请求会被取消,不能覆盖新代次的响应。 +文件卡片采用与 Session 顶栏相同的分段控件。点击卡片或左侧“打开”区域会在右侧 Sidebar 预览源文件;右侧箭头打开包含默认应用与文件管理器操作的标准菜单。Host 在 Finder 或文件资源管理器中选中文件,或通过 Linux 默认文件管理器打开所在文件夹。两个原生操作都解析同一份已保存声明并验证 Session 文件系统与 Host 路径;均不接受浏览器提供的替代路径。来自 Host 的桌面信息使远程浏览器中的文案和可用性保持准确,路由在每次原生操作时执行配置的可用性检查。单个交付占满整行;多个交付每行最多两列,并保留所有声明,前四张卡片之后的内容在用户展开列表前保持收起。 桌面元数据随连接代次失效,避免旧主机信息让原生操作持续禁用或显示错误的文件管理器名称。旧元数据请求会被取消,不能覆盖新代次的响应。 ## 考虑过的替代方案 @@ -30,7 +30,7 @@ Status: implemented **以工具文本作为持久索引**无法可靠应对后处理或结果溢出。执行身份与最终成功结果使声明归属独立于展示的工具文本。 -**绑定文件描述符的文件系统扩展**会改动所有提供方,却无法使外部桌面应用随后按路径打开的动作原子化。当前检查拒绝普通越界;并发替换后复原仍不在路径 API 的保证范围内。 +**绑定文件描述符的文件系统扩展**会改动所有提供方,却无法使外部桌面应用随后按路径打开的动作原子化。当前检查验证文件元数据和路径映射;并发替换后复原仍不在路径 API 的保证范围内。 ## 影响 diff --git a/.agents/notes/implemented/feature/2026-09-09-present-filesystem-access.i18n.yaml b/.agents/notes/implemented/feature/2026-09-09-present-filesystem-access.i18n.yaml new file mode 100644 index 0000000000..4a87c67631 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-09-present-filesystem-access.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-09-09-present-filesystem-access.md +2026-09-09-present-filesystem-access.md: 24b0e767d96261a95e449278d0919c381141f50d +2026-09-09-present-filesystem-access.zh.md: 2f4dc550823bd6c8de6ad61c113d58e3f238bbb5 diff --git a/.agents/notes/implemented/feature/2026-09-09-present-filesystem-access.md b/.agents/notes/implemented/feature/2026-09-09-present-filesystem-access.md new file mode 100644 index 0000000000..24b0e767d9 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-09-present-filesystem-access.md @@ -0,0 +1,27 @@ +# Agent Note: Present follows Session filesystem access + +Status: implemented + +English | [中文](2026-09-09-present-filesystem-access.zh.md) + +## Problem + +Generated files commonly live outside the workspace, especially in `/tmp`. Workspace containment rejects files that the Session filesystem and Sidebar already allow. A provider process path may also name a remote file rather than a file on the serving Host. + +## Decision + +`present` accepts existing regular files accessible through its composed `ctx.fs`, with relative paths resolved against the Session working directory. There is no workspace containment check or special temporary-directory allowlist. Missing files, directories, final symbolic links, and provider failures reject the declaration. A sandbox's private temporary files remain unavailable when the filesystem provider cannot see them. + +Native actions resolve or resume the viewed Session's Agent and use its `workspaceFiles.stat`. The resulting canonical process path must map from a Host path back to the same process path through that Agent's filesystem. Absent or different mappings produce 422 and a localized Sidebar-preview suggestion. This conservatively supports Host paths that share their canonical process spelling; providers with only a nonidentity Host mapping can still serve previews. A same-named local file never substitutes for an unmapped provider file. + +This replaces the workspace-only access rule in the [source-file delivery decision](2026-09-08-present-workspace-source-files.md). That note continues to own content-free declarations, Session events, and editing current sources. The request still selects only saved Session/event/file coordinates, never an arbitrary browser-supplied path. + +## Alternatives considered + +A `/tmp` allowlist excludes other readable output locations and duplicates filesystem policy. Treating every provider process path as a Host path can open an unrelated local file. Adding a generic inverse path-mapping API or copying remote files expands provider and retention responsibilities beyond source-file delivery. + +## Consequences + +Workspace files, accessible temporary files, Downloads, and files in another project use the same declaration rules. Native opening requires both a serving desktop and a verified Host path. Metadata checks do not make a desktop application's later path lookup atomic. + +Focused tests cover external absolute and relative paths, final symbolic links, missing files, Session lookup failures, absent and mismatched Host mappings, and localized native-unavailable state. Existing recorded Web scenarios retain declaration, preview, native action, and content-free export coverage. diff --git a/.agents/notes/implemented/feature/2026-09-09-present-filesystem-access.zh.md b/.agents/notes/implemented/feature/2026-09-09-present-filesystem-access.zh.md new file mode 100644 index 0000000000..2f4dc55082 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-09-present-filesystem-access.zh.md @@ -0,0 +1,27 @@ +# Agent Note:Present 遵循 Session 文件系统访问规则 + +Status: implemented + +[English](2026-09-09-present-filesystem-access.md) | 中文 + +## 问题 + +生成文件经常位于工作区外,尤其是 `/tmp`。工作区包含检查会拒绝 Session 文件系统和侧栏已经允许访问的文件。提供方的进程路径也可能指向远端文件,而非服务 Host 上的文件。 + +## 决策 + +`present` 接受所组合的 `ctx.fs` 可访问的已有普通文件,相对路径按 Session 工作目录解析。不检查工作区包含关系,也不另设临时目录白名单。文件缺失、为目录、最终路径为符号链接或提供方出错时,声明失败。文件系统提供方不可见的沙箱私有临时文件仍不可用。 + +原生操作查找或恢复当前查看的 Session 的 Agent,并调用其 `workspaceFiles.stat`。得到的规范化进程路径必须能通过该 Agent 的文件系统从 Host 路径映射回同一进程路径。映射缺失或不同会返回 422,并显示使用侧栏预览的本地化提示。这只支持规范化后与进程路径写法相同的 Host 路径;仅支持不同写法的 Host 映射的提供方仍可提供预览。没有映射时,本机同名文件不能替代提供方文件。 + +本决策替代[源文件交付决策](2026-09-08-present-workspace-source-files.zh.md)中只允许工作区文件的访问规则。原说明继续负责不保存内容的声明、Session 事件和编辑当前源文件。请求仍只按已保存的 Session、事件和文件索引选择文件,不接受浏览器提供的任意路径。 + +## 考虑过的替代方案 + +`/tmp` 白名单会排除其他可读的输出位置,并重复文件系统策略。把所有提供方进程路径都视作 Host 路径可能打开不相关的本机文件。增加通用反向路径映射 API 或复制远端文件,会让提供方与文件保留承担源文件交付之外的职责。 + +## 影响 + +工作区文件、可访问的临时文件、Downloads 和其他项目中的文件使用相同的声明规则。原生打开同时要求服务主机有可用桌面和经过验证的 Host 路径。元数据检查不能使桌面应用随后按路径打开文件的操作具有原子性。 + +定向测试覆盖工作区外的绝对和相对路径、最终符号链接、文件缺失、Session 查询失败、Host 映射缺失或不一致,以及本地化的原生操作不可用状态。已有的 Web 录制场景继续覆盖声明、预览、原生操作和不含文件内容的导出。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 7b677ae08d..e187952809 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3459,7 +3459,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-commands` ([`packages/client/ui-commands/src/index.ts`](../packages/client/ui-commands/src/index.ts)) - `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts)) - `@deepseek-ai/dsh-client-ui-cordis` ([`packages/extensions/ui-cordis/src/index.ts`](../packages/extensions/ui-cordis/src/index.ts)) -- `@deepseek-ai/dsh-client-ui-deliverables` — requires `systemPrompt` · `connection` · `sessionQuery` · `sessionController` ([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-deliverables` — requires `systemPrompt` · `connection` · `sessionQuery` · `sessionController` · `workspaceFiles` · `fs` ([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts)) - `@deepseek-ai/dsh-client-ui-directory-picker-browse` ([`packages/client/ui-directory-picker-browse/src/index.ts`](../packages/client/ui-directory-picker-browse/src/index.ts)) - `@deepseek-ai/dsh-client-ui-directory-picker-native` ([`packages/client/ui-directory-picker-native/src/index.ts`](../packages/client/ui-directory-picker-native/src/index.ts)) - `@deepseek-ai/dsh-client-ui-goal` ([`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts)) diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index bf70879f80..46e91f23a7 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -403,7 +403,7 @@ Source: [`packages/compaction/compaction/src/types.ts:34`](../packages/compactio #### `deliverables/presented` — log-only ```ts persistence-catalog -/** Declared workspace files from a successful final present result, including nested calls. */ +/** Declared filesystem files from a successful final present result, including nested calls. */ 'deliverables/presented': { turn: number; callId: ToolCallId; files: PresentedFile[] } ``` diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 928f7537ab..c6c09aee8e 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -225,7 +225,7 @@ The bash tool is the model-facing consumer of the bash executor seam. A `run_in_ ### `present` -Declare existing workspace files as final deliverables. When a file you create or update is an output the user asked to receive, you must call present after writing it and before your final response, including files created through Bash or code execution. Mentioning its path in your reply does not replace this call. The files must already exist. The user opens the current source files; their contents are not copied or preserved. +Declare existing files accessible through the Session filesystem as final deliverables. When a file you create or update is an output the user asked to receive, you must call present after writing it and before your final response, including files created through Bash or code execution. Mentioning its path in your reply does not replace this call. The files must already exist. The user opens the current source files; their contents are not copied or preserved. ```json { @@ -239,7 +239,7 @@ Declare existing workspace files as final deliverables. When a file you create o "properties": { "path": { "type": "string", - "description": "Path of an existing file inside the workspace." + "description": "Path of an existing regular file. Relative paths use the Session working directory." }, "description": { "type": "string", diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index ab6fce907a..12e097c4de 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/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-deliverables/README.md -README.md: 9faea88696f3d6a7d39429e82449978647330a5f -README.zh.md: 8b2c846dffe674d2112f9b7680040a762d8b0c60 +README.md: 50677e0cfb260c7b9bd31ed9599a2d0214dda201 +README.zh.md: 2196be4fed14cb269056ce4fd83960ca4024f63a diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index 9faea88696..50677e0cfb 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -30,7 +30,7 @@ Mount this plugin alongside `ui-conversation`; a finished turn then ends with th ### Explicit deliveries -The Web `standard`, `ptc`, and `cordis` presets expose `present` for final workspace files, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../fs/tool-present/README.md) owns file-count limits and Session declarations. The closing turn shows one delivery as a full-width card and multiple deliveries in a grid of at most two cards per row. A list longer than four files starts collapsed and provides a control that reveals or hides the complete list. Each card shows the basename and description, or the file type when no description exists; a trailing parenthesized suffix in the description is omitted, and hovering the card replaces that line with the Sidebar-preview action. Clicking the card or the left side of its split Open control previews the file in the right Sidebar. The chevron opens the standard menu for the Host default application plus Show in Finder on macOS, Show in File Explorer on Windows and WSL, or Open containing folder through the default Linux file manager. Matching inline-code references open the same source files without starting a browser download. Repeated declaration of a path selects its latest description before the closing reply. +The Web `standard`, `ptc`, and `cordis` presets expose `present` for final files accessible through the Session filesystem, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../fs/tool-present/README.md) owns file-count limits and Session declarations. The closing turn shows one delivery as a full-width card and multiple deliveries in a grid of at most two cards per row. A list longer than four files starts collapsed and provides a control that reveals or hides the complete list. Each card shows the basename and description, or the file type when no description exists; a trailing parenthesized suffix in the description is omitted, and hovering the card replaces that line with the Sidebar-preview action. Clicking the card or the left side of its split Open control previews the file in the right Sidebar. The chevron opens the standard menu for the Host default application plus Show in Finder on macOS, Show in File Explorer on Windows and WSL, or Open containing folder through the default Linux file manager. Matching inline-code references open the same source files without starting a browser download. Repeated declaration of a path selects its latest description before the closing reply. The `present` tool row shows running, delivered, failed, or interrupted status; expanding a settled row reveals its recorded result. The collapsible card grid retains every delivered file. Both menu actions share pending state and show progress, acknowledgement, or an action-specific retryable error. Desktop information is read when delivery cards appear and invalidated on connection replacement; responses from a replaced connection cannot publish metadata. Selecting a native menu action returns keyboard focus to the available Sidebar Open button. Pending actions close the menu until another explicit gesture. A missing desktop disables the Open menu; a failed desktop-information read offers Retry. It requires a desktop and a suitable default application on the serving Host; a remote browser does not open applications on its own device. @@ -52,7 +52,7 @@ The closing prose carries the same vocabulary: an inline-code token resolves by The Node half registers the static `ui:deliverable-file-references` system-prompt section asking the model to mention primary files from successful creation or modification calls and to write those and any other changed-file references as Markdown inline code. The browser half registers a wrapper around `ProducedFiles` and explicit deliveries into the chat view's `conversation.chat.turnTail` hole. `deliverablesDefinition` folds each Turn's successful first-party mutation calls into `DeliverablesTurnData` from the validated raw arguments of `write`, `edit`, and mutating `str_replace_editor` commands. Reads, deletes, unsupported tools, malformed calls, and failed results contribute nothing. A new mutation tool needs an explicit Client contribution before it joins the list. The package also provides the `chatFileMentions` service the chat view consults per closing message; composing the plugin out removes both surfaces and leaves the view's empty chain at zero cost. -Native opening uses an authenticated POST addressed by the viewed Session, event sequence, and original file index. The Host resolves the declaration against that Session’s workspace and checks the current file exists within it before launching either native action. The same configured desktop availability governs metadata and execution. Edits affect subsequent opens; deletion returns an error. No file-content copy or attachment is created. Plugin disposal cancels and awaits pending native-open requests. +Native opening uses an authenticated POST addressed by the viewed Session, event sequence, and original file index. The Host resolves or resumes that Session’s Agent and checks the declaration through its `workspaceFiles.stat`, using the same filesystem as Sidebar previews. Native actions require the canonical process path to map from a Host path back to that same process path. Providers without this mapping return 422 and the card directs the user to Sidebar preview; a same-named Host file is insufficient. The same configured desktop availability governs metadata and execution. Edits affect subsequent opens; deletion returns an error. No file-content copy or attachment is created. Plugin disposal cancels and awaits pending native-open requests. @@ -96,7 +96,7 @@ These limits define the current deliverables vocabulary. They are current packag - **Mention matching is exact path or unique basename only** — a suffix mention stays inert; widening the matcher is deferred until a real closing-message shape needs it. - **Terminal-created files require explicit delivery** — call `present` to declare them for native opening. -- **Declarations do not preserve file contents** — reopening or transferring a Session requires the source files in the viewed Session’s workspace. Missing files return 404; paths resolving outside the workspace return 403. +- **Declarations do not preserve file contents** — reopening or transferring a Session requires source files accessible through the viewed Session’s filesystem. Missing files, directories, and final symbolic links return 404. - **Directories have no destination** — chips open files in the right Sidebar's text preview, which shows files only; the former native folder handoff is gone rather than replaced. @@ -109,4 +109,4 @@ None. -**Runtime invariant:** No companion is published. Prompt, slot, dictionary, file-action route, and optional service registrations are effect-owned; the Session log owns declarations and the workspace owns file contents. +**Runtime invariant:** No companion is published. Prompt, slot, dictionary, file-action route, and optional service registrations are effect-owned; the Session log owns declarations and the filesystem owns file contents. diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 8b2c846dff..2196be4fed 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -30,7 +30,7 @@ kind: "package-reference" ### 显式交付 -Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交付最终工作区文件,包括通过 Bash 创建的文件。创建文件后,以 `files: [{ path, description? }]` 调用。[present 工具](../../fs/tool-present/README.zh.md)拥有文件数量限制和 Session 声明。收尾 turn 把单个交付显示为横向占满内容区的卡片,把多个交付显示为每行最多两张卡片的网格。文件超过四个时,列表默认收起,并提供显示或隐藏完整列表的控件。每张卡片显示 basename 与说明;没有说明时显示文件类型,说明末尾的括号后缀会被省略,悬停卡片时该行切换为侧栏预览提示。点击卡片或分段“打开”控件的左侧会在右侧 Sidebar 中预览文件;右侧箭头打开标准菜单,其中提供 Host 默认应用,以及 macOS 上的“在 Finder 中显示”、Windows 和 WSL 上的“在文件资源管理器中显示”或 Linux 默认文件管理器的“打开所在文件夹”。匹配的行内代码引用打开相同源文件,不触发浏览器下载。同一路径重复声明时,选择收尾回复之前最近一次的说明。 +Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交付Session 文件系统可访问的最终文件,包括通过 Bash 创建的文件。创建文件后,以 `files: [{ path, description? }]` 调用。[present 工具](../../fs/tool-present/README.zh.md)拥有文件数量限制和 Session 声明。收尾 turn 把单个交付显示为横向占满内容区的卡片,把多个交付显示为每行最多两张卡片的网格。文件超过四个时,列表默认收起,并提供显示或隐藏完整列表的控件。每张卡片显示 basename 与说明;没有说明时显示文件类型,说明末尾的括号后缀会被省略,悬停卡片时该行切换为侧栏预览提示。点击卡片或分段“打开”控件的左侧会在右侧 Sidebar 中预览文件;右侧箭头打开标准菜单,其中提供 Host 默认应用,以及 macOS 上的“在 Finder 中显示”、Windows 和 WSL 上的“在文件资源管理器中显示”或 Linux 默认文件管理器的“打开所在文件夹”。匹配的行内代码引用打开相同源文件,不触发浏览器下载。同一路径重复声明时,选择收尾回复之前最近一次的说明。 `present` 工具行显示正在交付、已交付、失败或中断状态;展开已结束的调用可查看其记录的结果。可折叠卡片网格保留全部交付文件。菜单中的两个操作共享等待状态,并显示进度、请求确认或各自可重试的错误。交付卡片出现时读取桌面信息,连接更换时清除缓存,旧连接的响应不能更新元数据。选择原生菜单操作后,键盘焦点回到仍可用的侧边栏“打开”按钮。等待操作完成时关闭菜单,用户再次点击才会打开。Host 没有桌面时禁用“打开”菜单;桌面信息读取失败时提供“重试”。服务 Host 必须具备桌面和合适的默认应用;远程浏览器不会打开其所在设备上的应用。 @@ -52,7 +52,7 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交 Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,要求模型点名成功创建或修改的主要文件,并把这些文件以及正文中提到的其他本轮变更文件写成 Markdown 行内代码。浏览器半部把组合 `ProducedFiles` 与显式交付的包装组件注册进 chat 视图的 `conversation.chat.turnTail` 洞。`deliverablesDefinition` 根据 `write`、`edit` 和有修改作用的 `str_replace_editor` 命令中经过校验的原始参数,把每个轮次成功的第一方修改调用折叠进 `DeliverablesTurnData`。读取、删除、不受支持的工具、格式错误的调用和失败结果不贡献任何条目。新的修改工具必须增加显式 Client contribution 才能加入列表。本包还提供 chat 视图按收尾消息查询的 `chatFileMentions` 服务;把插件组合出去会同时移除两个表面,视图的空链以零成本留下。 -原生打开使用经过认证的 POST,通过当前查看的 Session、事件序号和原始文件索引定位声明。Host 按该 Session 的工作区解析路径,检查当前文件存在且位于工作区内,再启动所选原生操作。同一份桌面可用性配置同时约束信息查询和实际执行。编辑会影响后续打开的内容;删除后返回错误。不创建文件内容副本或附件。插件释放时取消并等待进行中的原生打开请求。 +原生打开使用经过认证的 POST,通过当前查看的 Session、事件序号和原始文件索引定位声明。Host 查找或恢复该 Session 的 Agent,通过其 `workspaceFiles.stat` 检查声明,与侧栏预览使用同一文件系统。原生操作要求规范化的进程路径能从 Host 路径映射回同一进程路径。提供方没有这种映射时返回 422,卡片提示使用侧栏预览;Host 上存在同名文件并不足够。同一份桌面可用性配置同时约束信息查询和实际执行。编辑会影响后续打开的内容;删除后返回错误。不创建文件内容副本或附件。插件释放时取消并等待进行中的原生打开请求。 @@ -96,7 +96,7 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段, - **提及匹配只认精确路径或唯一 basename**——后缀式提及保持惰性;等真实的收尾消息形态产生需求后再放宽匹配规则。 - **终端创建的文件需要显式交付**——调用 `present` 声明文件,以便原生打开。 -- **声明不保存文件内容**——重新打开或转移 Session 后,需要当前查看的 Session 工作区中仍有源文件。文件缺失返回 404;解析到工作区外的路径返回 403。 +- **声明不保存文件内容**:重新打开或转移 Session 后,源文件仍需能被当前查看的 Session 文件系统访问。文件缺失、为目录或最终路径为符号链接时返回 404。 - **目录没有打开目标**——标签项在右侧 Sidebar 的文本预览中打开文件,该预览仅支持文件,不提供原生文件夹打开动作。 @@ -109,4 +109,4 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段, -**运行时不变式:** 不发布伴生入口。提示词、slot、dictionary、文件操作路由与可选 service 注册归 effect 所有;Session 日志拥有声明,工作区拥有文件内容。 +**运行时不变式:** 不发布伴生入口。提示词、slot、dictionary、文件操作路由与可选 service 注册归 effect 所有;Session 日志拥有声明,文件系统拥有文件内容。 diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json index ca55e77f16..cd71933d2f 100644 --- a/packages/client/ui-deliverables/package.json +++ b/packages/client/ui-deliverables/package.json @@ -68,7 +68,12 @@ "@deepseek-ai/dsh-client-ui-tool": "workspace:^", "@deepseek-ai/dsh-tool-present": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", - "@deepseek-ai/dsh-util-workspace-path": "workspace:^" + "@deepseek-ai/dsh-util-workspace-path": "workspace:^", + "@deepseek-ai/dsh-api-workspace-files": "workspace:^", + "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-fs-local": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx index 73e29f5523..51946bf65a 100644 --- a/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx +++ b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx @@ -56,7 +56,7 @@ export function PresentedFileCard({ file, cwd, phase, host, onPreview, onAction,
{name} + data-error={phase === 'error' || phase === 'revealError' || phase === 'nativeUnavailable' ? true : undefined}> {status} {t('presented.preview')} diff --git a/packages/client/ui-deliverables/src/client/locales.ts b/packages/client/ui-deliverables/src/client/locales.ts index 5b9fa7781d..1e4d78f8c1 100644 --- a/packages/client/ui-deliverables/src/client/locales.ts +++ b/packages/client/ui-deliverables/src/client/locales.ts @@ -5,6 +5,7 @@ export const NS = 'deliverables' /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { + 'presented.nativeUnavailable': '此文件没有可用的主机路径,请在侧边栏预览', 'presented.revealError': '无法在文件管理器中显示,请重试', 'presented.directoryError': '无法打开所在文件夹,请重试', 'presented.directoryOpening': '正在打开所在文件夹…', @@ -46,6 +47,7 @@ export const zh = { /** English dictionary (same key set). */ export const en: Record = { + 'presented.nativeUnavailable': 'This file has no available Host path. Preview it in the sidebar.', 'presented.revealError': 'Could not show in file manager. Try again.', 'presented.directoryError': 'Could not open containing folder. Try again.', 'presented.directoryOpening': 'Opening containing folder…', diff --git a/packages/client/ui-deliverables/src/client/present-open.ts b/packages/client/ui-deliverables/src/client/present-open.ts index bb47634011..8e66b380c0 100644 --- a/packages/client/ui-deliverables/src/client/present-open.ts +++ b/packages/client/ui-deliverables/src/client/present-open.ts @@ -4,7 +4,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types' import { presentedFileUrl, PRESENT_HOST_PATH, isPresentedHost, type PresentedAction, type PresentedHost } from '../presented.ts' /** State of the latest explicit open gesture for one saved file. */ -export type PresentedOpenPhase = 'opening' | 'opened' | 'revealing' | 'revealed' | 'error' | 'revealError' +export type PresentedOpenPhase = 'opening' | 'opened' | 'revealing' | 'revealed' | 'error' | 'revealError' | 'nativeUnavailable' /** One browser plugin's file-open requests, cancelled when that plugin is disposed. */ export class PresentedOpenController { @@ -18,7 +18,7 @@ export class PresentedOpenController { private readonly pending = new Set>() /** - * Open a declared workspace file once while a request for the same coordinates is pending. + * Open a declared file once while a request for the same coordinates is pending. * Failures remain visible on the card and a later gesture retries them. * @param sessionId - viewed Session, including a fork's own identity. * @param seq - durable delivery event sequence. @@ -93,7 +93,7 @@ export class PresentedOpenController { let phase: PresentedOpenPhase = action === 'open' ? 'opened' : 'revealed' try { const response = await fetch(action === 'open' ? url : `${url}&action=reveal`, { method: 'POST', signal: this.lifetime.signal }) - if (!response.ok) phase = failure + if (!response.ok) phase = response.status === 422 ? 'nativeUnavailable' : failure } catch { // Transport failures share the retryable card state with Host open failures. phase = failure diff --git a/packages/client/ui-deliverables/src/index.ts b/packages/client/ui-deliverables/src/index.ts index 4239299e63..ee4ef63a40 100644 --- a/packages/client/ui-deliverables/src/index.ts +++ b/packages/client/ui-deliverables/src/index.ts @@ -1,7 +1,7 @@ /** * Deliverables plugin, node half. Registers the response-format guidance that * lets the browser half recognize final-response file references and serves - * authenticated native opens of workspace files. The browser + * authenticated native opens of declared files. The browser * half ships via exports["./client"], discovered through the package.json * dsh.client declaration. */ @@ -10,8 +10,8 @@ import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-system-prompt' import { registerPresentOpen } from './present-open.ts' -/** Services required for file-reference guidance and authenticated native opens of workspace files. */ -export const inject = ['systemPrompt', 'connection', 'sessionQuery', 'sessionController'] +/** Services required for file-reference guidance and authenticated native opens of declared files. */ +export const inject = ['systemPrompt', 'connection', 'sessionQuery', 'sessionController', 'workspaceFiles', 'fs'] /** Stable final-response guidance owned by the matching renderer. */ const FILE_REFERENCE_PROMPT = 'When you successfully create or modify files, mention the primary outputs in your final response. ' diff --git a/packages/client/ui-deliverables/src/present-open.ts b/packages/client/ui-deliverables/src/present-open.ts index ba5880a0e9..97aab3242a 100644 --- a/packages/client/ui-deliverables/src/present-open.ts +++ b/packages/client/ui-deliverables/src/present-open.ts @@ -1,8 +1,9 @@ -/** Open declared source files inside the viewed Session's workspace. */ -import { realpath, stat } from 'node:fs/promises' -import { isAbsolute, relative, resolve, sep } from 'node:path' +/** Open declared source files verified by the viewed Session's filesystem. */ import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-api-session-controller' +import type {} from '@deepseek-ai/dsh-api-workspace-files' +import type {} from '@deepseek-ai/dsh-fs' +import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' import type {} from '@deepseek-ai/dsh-client-connection' import type {} from '@deepseek-ai/dsh-session-query' import type { SessionId, SessionSeq } from '@deepseek-ai/dsh-session' @@ -53,27 +54,31 @@ async function handlePresentOpen(ctx: Context, request: Request): Promise { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 422 }))) + const controller = new PresentedOpenController() + await controller.open(id, 2, 1, action) + expect(controller.state.getSnapshot()[url]).toBe('nativeUnavailable') + await controller.dispose() +}) diff --git a/packages/client/ui-deliverables/tests/present-open.host.spec.ts b/packages/client/ui-deliverables/tests/present-open.host.spec.ts index fc12bc71d3..346ce60279 100644 --- a/packages/client/ui-deliverables/tests/present-open.host.spec.ts +++ b/packages/client/ui-deliverables/tests/present-open.host.spec.ts @@ -2,6 +2,10 @@ import { mkdtemp, rm, readFile, writeFile, mkdir, realpath, symlink, unlink } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local' +import { WorkspaceFiles } from '@deepseek-ai/dsh-api-workspace-files' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import { Context } from '@deepseek-ai/cordis' import { HostConnectionService } from '@deepseek-ai/dsh-client-connection' import type { BrowserAuth } from '@deepseek-ai/dsh-client-connection/src/browser-auth.ts' @@ -30,6 +34,11 @@ async function fixture() { const ctx = new Context() cleanups.push(() => ctx.fiber.dispose()) const session: { cwd?: string } = { cwd } + await ctx.plugin(LocalFileSystem, { cwd }) + ctx.provide('sandboxPolicy', { resolve: () => ({ workspaceRoot: session.cwd ?? cwd }) } as never) + new WorkspaceFiles(ctx, { maxBytes: 1024, maxFileBytes: 1024, maxLines: 100, maxEntries: 100 }) + const agent = { ctx, session: { header: session } } as unknown as Agent + const resolveAgent = vi.fn(async () => ({ agent })) const readEvent = vi.fn(async (request: SessionEventReadRequest) => { if (request.sessionId !== 'owner') throw new SessionQueryError('missing', 'SESSION_QUERY_SESSION_NOT_FOUND') if (request.seq !== 7) throw new SessionQueryError('missing', 'SESSION_QUERY_EVENT_NOT_FOUND') @@ -37,15 +46,15 @@ async function fixture() { }) ctx.provide('sessionQuery', { readEvent } as never) const opener = vi.fn(async (_request: { path: string; action?: 'reveal' }, _signal: AbortSignal) => ({ opened: true as const })) - ctx.provide('sessionController', { openWorkspacePath: opener, workspaceDesktop: () => ({ name: 'desktop', available: true, fileManager: 'finder' }) } as never) + ctx.provide('sessionController', { resolveAgent, openWorkspacePath: opener, workspaceDesktop: () => ({ name: 'desktop', available: true, fileManager: 'finder' }) } as never) const connection = new HostConnectionService(ctx, [], {} as BrowserAuth) - const fiber = ctx.plugin({ inject: ['connection', 'sessionQuery', 'sessionController'], apply: registerPresentOpen }) + const fiber = ctx.plugin({ inject: ['connection', 'sessionQuery', 'sessionController', 'workspaceFiles', 'fs'], apply: registerPresentOpen }) await fiber const handler = connection.createSharedFetchHandler('/api') const open = (query = '?sessionId=owner&seq=7&index=0', signal?: AbortSignal) => handler.fetch(new Request( `http://localhost${PRESENT_OPEN_PATH}${query}`, { method: 'POST', signal: signal ?? null }, )) - return { root, cwd, ctx, fiber, file, session, readEvent, open, opener, handler } + return { root, cwd, ctx, fiber, file, session, readEvent, open, opener, handler, resolveAgent } } describe('Presented workspace file native open route', () => { @@ -75,7 +84,8 @@ describe('Presented workspace file native open route', () => { const fork = join(root, 'fork') await mkdir(fork) await writeFile(join(fork, file.path), 'child source') - readEvent.mockResolvedValueOnce({ session: { ...session, cwd: fork }, target: { type: 'deliverables/presented', data: { turn: 1, callId: 'inherited', files: [file] } } as SessionEvent }) + session.cwd = fork + readEvent.mockResolvedValueOnce({ session, target: { type: 'deliverables/presented', data: { turn: 1, callId: 'inherited', files: [file] } } as SessionEvent }) expect((await open('?sessionId=fork&seq=7&index=0')).status).toBe(204) expect(opener.mock.lastCall?.[0].path).toBe(await realpath(join(fork, file.path))) }) @@ -109,30 +119,29 @@ describe('Presented workspace file native open route', () => { expect(opener).not.toHaveBeenCalled() }) - it('reports removed files, directories, and absent workspaces without launching', async () => { - const { cwd, file, session, open, opener } = await fixture() + it('reports removed files and directories without launching', async () => { + const { cwd, file, open, opener } = await fixture() await unlink(join(cwd, file.path)) expect((await open()).status).toBe(404) file.path = '.' expect((await open()).status).toBe(404) - delete session.cwd - expect((await open()).status).toBe(404) expect(opener).not.toHaveBeenCalled() }) - it('refuses traversal and a source replaced by a symlink outside the workspace', async () => { + it('opens external regular files through absolute and relative paths but refuses final symlinks', async () => { const { root, cwd, file, open, opener } = await fixture() const outside = join(root, 'outside.txt') await writeFile(outside, 'outside') const source = join(cwd, file.path) await unlink(source) await symlink(outside, source) - expect((await open()).status).toBe(403) - file.path = '../outside.txt' - expect((await open()).status).toBe(403) - file.path = outside - expect((await open()).status).toBe(403) + expect((await open()).status).toBe(404) expect(opener).not.toHaveBeenCalled() + for (const path of ['../outside.txt', outside]) { + file.path = path + expect((await open()).status).toBe(204) + expect(opener.mock.lastCall?.[0].path).toBe(await realpath(outside)) + } }) it('reports query and launcher failures without leaking Host paths and allows retry', async () => { @@ -186,7 +195,7 @@ it('reports the serving desktop and reveals only an authorized declared source', expect(opener).toHaveBeenCalledWith({ path: await realpath(join(cwd, file.path)), action: 'reveal' }, expect.any(AbortSignal)) expect((await open('?sessionId=owner&seq=7&index=0&action=delete')).status).toBe(400) file.path = '..' - expect((await open('?sessionId=owner&seq=7&index=0&action=reveal')).status).toBe(403) + expect((await open('?sessionId=owner&seq=7&index=0&action=reveal')).status).toBe(404) expect(opener).toHaveBeenCalledOnce() }) @@ -199,3 +208,32 @@ it('refuses native actions when the configured Host desktop is unavailable', asy } expect(opener).not.toHaveBeenCalled() }) + + +it('refuses native opening without a matching Host mapping even when a same-name Host file exists', async () => { + const { ctx, open, opener } = await fixture() + const mapping = vi.spyOn(ctx.fs, 'processPathFromHostPath').mockReturnValue(undefined) + expect((await open()).status).toBe(422) + mapping.mockReturnValue('/another-filesystem/file') + expect((await open()).status).toBe(422) + expect(opener).not.toHaveBeenCalled() +}) + +it('reports Session resolution failures before checking files', async () => { + const { resolveAgent, open, opener } = await fixture() + resolveAgent.mockResolvedValueOnce({ error: new RemoteError('session/not-found', 'missing', { sessionId: SessionId('owner') }) }) + expect((await open()).status).toBe(404) + expect(opener).not.toHaveBeenCalled() +}) + + +it('uses the resolved Agent filesystem instead of the Host filesystem', async () => { + const { ctx, cwd, open, resolveAgent, opener } = await fixture() + const scoped = ctx.isolate('fs') + await scoped.plugin(LocalFileSystem, { cwd }) + vi.spyOn(scoped.fs, 'processPathFromHostPath').mockReturnValue(undefined) + resolveAgent.mockResolvedValueOnce({ agent: { ctx: scoped, session: { header: { cwd } } } as unknown as Agent }) + expect((await open()).status).toBe(422) + expect(opener).not.toHaveBeenCalled() + expect((await open()).status).toBe(204) +}) diff --git a/packages/client/ui-deliverables/tests/prompt.host.spec.ts b/packages/client/ui-deliverables/tests/prompt.host.spec.ts index 8f342099fd..e168303128 100644 --- a/packages/client/ui-deliverables/tests/prompt.host.spec.ts +++ b/packages/client/ui-deliverables/tests/prompt.host.spec.ts @@ -19,6 +19,8 @@ describe('ui-deliverables node plugin', () => { ctx.provide('connection', { fetch: { register: () => () => {} } } as never) ctx.provide('sessionQuery', {} as never) ctx.provide('sessionController', {} as never) + ctx.provide('workspaceFiles', {} as never) + ctx.provide('fs', {} as never) const mounted = ctx.plugin({ apply, inject }) await mounted.await() diff --git a/packages/client/ui-deliverables/tsconfig.host.json b/packages/client/ui-deliverables/tsconfig.host.json index 0123290583..ee8f384dd8 100644 --- a/packages/client/ui-deliverables/tsconfig.host.json +++ b/packages/client/ui-deliverables/tsconfig.host.json @@ -34,6 +34,15 @@ }, { "path": "../../api/session-controller/tsconfig.host.json" + }, + { + "path": "../../api/workspace-files/tsconfig.host.json" + }, + { + "path": "../../fs/fs" + }, + { + "path": "../../typert/protocol" } ] } diff --git a/packages/fs/tool-present/README.i18n.yaml b/packages/fs/tool-present/README.i18n.yaml index d1453cce74..22c9c3dd2d 100644 --- a/packages/fs/tool-present/README.i18n.yaml +++ b/packages/fs/tool-present/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/fs/tool-present/README.md -README.md: 77c1a83a30bdbe58b3be35d8bc0d879f1669b5a5 -README.zh.md: cec2d64b4e6f95a4e8210e7e24a9c796ed95d749 +README.md: 93003711545f678336a02c9bf8589176f844af65 +README.zh.md: 152ba39be7184ca536a56ed9b4cfb5dfa4a5fe82 diff --git a/packages/fs/tool-present/README.md b/packages/fs/tool-present/README.md index 77c1a83a30..9300371154 100644 --- a/packages/fs/tool-present/README.md +++ b/packages/fs/tool-present/README.md @@ -1,5 +1,5 @@ --- -description: "Declare workspace files as deliverables with present; configuration, Session ownership, and source-file opening." +description: "Declare accessible files as deliverables with present; configuration, Session ownership, and source-file opening." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Use `present` to declare final workspace files, including files created through shell commands. Users open the current source files in their default application. The tool records paths and optional descriptions without copying file contents. +Use `present` to declare final files accessible through the Session filesystem, including files created through shell commands. Users open the current source files in their default application. The tool records paths and optional descriptions without copying file contents. ## Table of Contents @@ -25,7 +25,7 @@ Use `present` to declare final workspace files, including files created through ## Use this package -The `standard`, `ptc`, and `cordis` agent presets mount this plugin. Call `present` with `files: [{ path, description? }]` after creating the files. Files must exist inside the Session workspace and be regular files. Missing files, directories, and paths outside the workspace fail the call. +The `standard`, `ptc`, and `cordis` agent presets mount this plugin. Call `present` with `files: [{ path, description? }]` after creating the files. Files must be regular files accessible through the Session filesystem. Relative paths resolve against the Session working directory; absolute paths may name files outside it, including `/tmp` or Downloads. Missing files, directories, final symbolic links, and provider-denied paths fail the call. Files in a shell sandbox’s private `/tmp` must first be written somewhere the Session filesystem can access. Mount it in an agent's Cordis composition with `tools`, `fs`, and the `turnBoundary` Session projection available: @@ -49,7 +49,7 @@ The file-count limit is validated at mount. The tool requires an agent Session w
Implementation internals — click to expand -The tool resolves paths through the configured filesystem provider and checks workspace containment and regular-file metadata without reading contents. Successful final `tools/result` notifications append `deliverables/presented`, including nested calls. A later enclosing program failure does not revoke an already completed declaration. Blocked results publish none. Each plugin instance records only calls it executed; scoped tools with the same name cannot publish through another instance. +The tool resolves paths through the configured filesystem provider and checks regular-file metadata without reading contents. Successful final `tools/result` notifications append `deliverables/presented`, including nested calls. A later enclosing program failure does not revoke an already completed declaration. Blocked results publish none. Each plugin instance records only calls it executed; scoped tools with the same name cannot publish through another instance. The pure `./types` entry declares `PresentedFile` and the Session event without importing Host runtime code. The Web consumer validates persisted declarations before displaying or opening them. The event stores no Session ID, so forked history resolves relative paths against the viewed Session's workspace. @@ -73,7 +73,7 @@ The pure `./types` entry declares `PresentedFile` and the Session event without #### What the model sees -The [present schema](../../../docs/tool-catalog.md#present) asks for existing workspace files: “Declare existing workspace files as final deliverables. When a file you create or update is an output the user asked to receive, you must call present after writing it and before your final response, including files created through Bash or code execution. Mentioning its path in your reply does not replace this call. The files must already exist. The user opens the current source files; their contents are not copied or preserved.” Results report `Presented ` for each file; the program result and durable event contain paths and optional descriptions. +The [present schema](../../../docs/tool-catalog.md#present) asks for existing accessible files: “Declare existing files accessible through the Session filesystem as final deliverables. When a file you create or update is an output the user asked to receive, you must call present after writing it and before your final response, including files created through Bash or code execution. Mentioning its path in your reply does not replace this call. The files must already exist. The user opens the current source files; their contents are not copied or preserved.” Results report `Presented ` for each file; the program result and durable event contain paths and optional descriptions. #### Token effect @@ -87,7 +87,7 @@ The tool schema is static for the mount lifetime. Delivery result text extends t -- Path containment checks are best effort; they cannot atomically defend against a concurrent symlink replacement before the desktop application opens the file. +- Metadata and Host-path checks cannot atomically prevent replacement before a desktop application opens a file. - Edits change what opens. Deleted or moved source files cannot be opened from their declarations. - Session ZIP exports contain declarations, not file contents. Persistent delivery versions and copy-on-write storage are deferred. diff --git a/packages/fs/tool-present/README.zh.md b/packages/fs/tool-present/README.zh.md index cec2d64b4e..152ba39be7 100644 --- a/packages/fs/tool-present/README.zh.md +++ b/packages/fs/tool-present/README.zh.md @@ -1,5 +1,5 @@ --- -description: "通过 present 声明交付工作区文件;配置、Session 归属与源文件打开。" +description: "通过 present 声明交付可访问的文件;配置、Session 归属与源文件打开。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -使用 `present` 声明交付最终工作区文件,包括通过 shell 命令创建的文件。用户使用默认应用打开当前源文件。工具记录路径和可选说明,不复制文件内容。 +使用 `present` 声明交付Session 文件系统可访问的最终文件,包括通过 shell 命令创建的文件。用户使用默认应用打开当前源文件。工具记录路径和可选说明,不复制文件内容。 ## 目录 @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -`standard`、`ptc` 与 `cordis` Agent preset 挂载本插件。创建文件后,以 `files: [{ path, description? }]` 调用 `present`。文件必须存在于 Session 工作区内,且为普通文件。文件缺失、为目录或路径位于工作区外时,调用失败。 +`standard`、`ptc` 与 `cordis` Agent preset 挂载本插件。创建文件后,以 `files: [{ path, description? }]` 调用 `present`。文件必须是 Session 文件系统可访问的普通文件。相对路径按 Session 工作目录解析;绝对路径可以指向工作区外的文件,包括 `/tmp` 或 Downloads。文件缺失、为目录、最终路径为符号链接或提供方拒绝访问时,调用失败。Shell 沙箱私有 `/tmp` 中的文件需要先写入 Session 文件系统可访问的位置。 在 Agent 的 Cordis 组合中挂载,并提供 `tools`、`fs` 和 `turnBoundary` Session 投影: @@ -49,7 +49,7 @@ kind: "package-reference"
实现细节——点击展开 -工具通过配置的文件系统提供方解析路径,检查工作区包含关系和普通文件元数据,不读取内容。成功的最终 `tools/result` 通知追加 `deliverables/presented`,嵌套调用也适用。外层程序随后失败不会撤销已完成的声明。被阻止的结果不发布声明。每个插件实例只记录其实际执行的调用;同名作用域工具不能通过其他实例发布交付。 +工具通过配置的文件系统提供方解析路径,检查普通文件元数据,不读取内容。成功的最终 `tools/result` 通知追加 `deliverables/presented`,嵌套调用也适用。外层程序随后失败不会撤销已完成的声明。被阻止的结果不发布声明。每个插件实例只记录其实际执行的调用;同名作用域工具不能通过其他实例发布交付。 纯 `./types` 入口声明 `PresentedFile` 与 Session 事件,不导入 Host 运行时代码。Web 消费方在展示或打开文件前校验持久声明。事件不保存 Session ID,因此 fork 历史中的相对路径按当前查看的 Session 工作区解析。 @@ -73,7 +73,7 @@ kind: "package-reference" #### 模型看到的内容 -[present schema](../../../docs/tool-catalog.zh.md#present)要求已有的工作区文件:“Declare existing workspace files as final deliverables. When a file you create or update is an output the user asked to receive, you must call present after writing it and before your final response, including files created through Bash or code execution. Mentioning its path in your reply does not replace this call. The files must already exist. The user opens the current source files; their contents are not copied or preserved.” 每个文件的结果为 `Presented `;程序结果和持久事件包含路径及可选说明。 +[present schema](../../../docs/tool-catalog.zh.md#present)要求已有且可访问的文件:“Declare existing files accessible through the Session filesystem as final deliverables. When a file you create or update is an output the user asked to receive, you must call present after writing it and before your final response, including files created through Bash or code execution. Mentioning its path in your reply does not replace this call. The files must already exist. The user opens the current source files; their contents are not copied or preserved.” 每个文件的结果为 `Presented `;程序结果和持久事件包含路径及可选说明。 #### Token 影响 @@ -87,7 +87,7 @@ kind: "package-reference" -- 路径包含关系检查是尽力而为的;无法原子防御桌面应用打开文件前发生的并发符号链接替换。 +- 元数据和 Host 路径检查无法原子性地阻止桌面应用打开文件前发生的路径替换。 - 编辑会改变打开的内容。源文件删除或移动后,无法通过原声明打开。 - Session ZIP 导出包含声明,不包含文件内容。交付版本持久化和写时复制存储延期实现。 diff --git a/packages/fs/tool-present/src/index.ts b/packages/fs/tool-present/src/index.ts index b898c5671a..626006e17b 100644 --- a/packages/fs/tool-present/src/index.ts +++ b/packages/fs/tool-present/src/index.ts @@ -1,4 +1,4 @@ -/** Scoped tool that declares workspace file deliveries in their owning Session. */ +/** Scoped tool that declares filesystem deliveries in their owning Session. */ import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { FsError } from '@deepseek-ai/dsh-fs' @@ -37,7 +37,7 @@ export function apply(ctx: Context, config: Config): void { const pending = new WeakMap() ctx.tools.register(defineTool({ name: 'present', - description: 'Declare existing workspace files as final deliverables. ' + description: 'Declare existing files accessible through the Session filesystem as final deliverables. ' + 'When a file you create or update is an output the user asked to receive, you must call present after writing it and before your final response, including files created through Bash or code execution. ' + 'Mentioning its path in your reply does not replace this call. The files must already exist. ' + 'The user opens the current source files; their contents are not copied or preserved.', @@ -47,7 +47,7 @@ export function apply(ctx: Context, config: Config): void { items: { type: 'object', additionalProperties: false, properties: { - path: { type: 'string', required: true, description: 'Path of an existing file inside the workspace.' }, + path: { type: 'string', required: true, description: 'Path of an existing regular file. Relative paths use the Session working directory.' }, description: { type: 'string', description: 'Brief description for the user.' }, }, }, @@ -80,12 +80,12 @@ export function apply(ctx: Context, config: Config): void { const cwd = exec.agent.session.header.cwd if (cwd === undefined) throw new Error('present requires a workspace') const options = { cwd, signal: exec.signal } - const root = await ctx.fs.resolve('.', options) const files: PresentedFile[] = [] for (const file of args.files) { if (file.path.trim().length === 0) throw new Error('present requires a non-empty file path') + const entry = await ctx.fs.lstat(file.path, { cwd }, exec.signal) + if (entry !== undefined && entry.type !== 'file') throw new Error(`Cannot present ${file.path}: not a regular file`) const target = await ctx.fs.resolve(file.path, options) - if (!ctx.fs.contains(root, target)) throw new Error(`Cannot present ${file.path}: outside the workspace`) const info = await ctx.fs.stat(target, exec.signal) if (info === undefined) throw new FsError(`Cannot present ${file.path}: file not found. Check the path, create the file if needed, and retry.`, 'FS_NOT_FOUND') if (info.type !== 'file') throw new Error(`Cannot present ${file.path}: not a regular file`) diff --git a/packages/fs/tool-present/src/types.ts b/packages/fs/tool-present/src/types.ts index 796f0104ab..04d8aba451 100644 --- a/packages/fs/tool-present/src/types.ts +++ b/packages/fs/tool-present/src/types.ts @@ -1,9 +1,9 @@ /** Durable file deliveries produced by the present tool. */ import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand' -/** A declared workspace file whose current contents remain at its source path. */ +/** A declared filesystem file whose current contents remain at its source path. */ export interface PresentedFile { - /** Original workspace path. */ + /** Original absolute path or path relative to the Session working directory. */ path: string /** Optional description supplied by the model. */ description?: string @@ -11,7 +11,7 @@ export interface PresentedFile { declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { - /** Declared workspace files from a successful final present result, including nested calls. */ + /** Declared filesystem files from a successful final present result, including nested calls. */ 'deliverables/presented': { turn: number; callId: ToolCallId; files: PresentedFile[] } } } diff --git a/packages/fs/tool-present/tests/present.spec.ts b/packages/fs/tool-present/tests/present.spec.ts index 95606e09dc..0f219f7b14 100644 --- a/packages/fs/tool-present/tests/present.spec.ts +++ b/packages/fs/tool-present/tests/present.spec.ts @@ -1,7 +1,7 @@ /** Explicit deliveries commit only after a successful final tool result. */ import { mkdtemp, rm, writeFile, symlink } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { join, relative } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' @@ -133,7 +133,7 @@ describe('present', () => { expect(owner.session.snapshotEvents().some(event => event.type === 'deliverables/presented')).toBe(false) }) - it('rejects missing, non-file, outside-workspace, empty, and excessive inputs', async () => { + it('rejects missing, non-file, empty, and excessive inputs', async () => { const { root, owner, execute } = await setup() await writeFile(join(root, 'large'), 'four') await symlink(tmpdir(), join(root, 'outside')) @@ -165,3 +165,31 @@ it('requires an agent, an open turn, and a workspace', async () => { const absent = await ctx.tools.execute({ signal: new AbortController().signal, callId: ToolCallId('no-workspace'), name: 'present', arguments: { files: [{ path: 'a' }] }, agent: noWorkspace }) expect(absent.isError).toBe(true) }) + + +it('declares readable files outside the Session directory using absolute and relative paths', async () => { + const { root, execute, owner } = await setup() + const outside = await mkdtemp(join(tmpdir(), 'dsh-present-external-')) + cleanups.push(() => rm(outside, { recursive: true, force: true })) + const file = join(outside, 'report.txt') + await writeFile(file, 'external report') + const files = [{ path: file }, { path: relative(root, file) }] + expect((await execute(files)).isError).toBe(false) + expect(owner.session.snapshotEvents().find(event => event.type === 'deliverables/presented')?.data.files).toEqual(files) +}) + +it('refuses a final symlink to an ordinary file', async () => { + const { root, execute } = await setup() + await writeFile(join(root, 'source'), 'source') + await symlink(join(root, 'source'), join(root, 'link')) + expect((await execute([{ path: 'link' }])).isError).toBe(true) +}) + + +it('refuses a file replaced by a directory after inspecting its final component', async () => { + const { ctx, root, execute } = await setup() + await writeFile(join(root, 'source'), 'source') + const directory = await ctx.fs.stat(await ctx.fs.resolve(root)) + vi.spyOn(ctx.fs, 'stat').mockResolvedValueOnce(directory) + expect((await execute([{ path: 'source' }])).isError).toBe(true) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b94d4efb64..6cfebbe630 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2687,12 +2687,18 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes '@deepseek-ai/dsh-api-session-controller': specifier: workspace:^ version: link:../../api/session-controller + '@deepseek-ai/dsh-api-workspace-files': + specifier: workspace:^ + version: link:../../api/workspace-files '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection @@ -2723,6 +2729,12 @@ importers: '@deepseek-ai/dsh-client-ui-tool': specifier: workspace:^ version: link:../ui-tool + '@deepseek-ai/dsh-fs': + specifier: workspace:^ + version: link:../../fs/fs + '@deepseek-ai/dsh-fs-local': + specifier: workspace:^ + version: link:../../fs/fs-local '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -2738,6 +2750,9 @@ importers: '@deepseek-ai/dsh-tool-present': specifier: workspace:^ version: link:../../fs/tool-present + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol '@deepseek-ai/dsh-util-workspace-path': specifier: workspace:^ version: link:../../util/workspace-path From b443c4d2468f4240f21601930baa44078833a582 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 9 Sep 2026 16:48:39 +0800 Subject: [PATCH 11/20] fix(present): resolve file services in live agent scope and refresh snapshots --- docs/config-catalog.md | 2 +- docs/persistence-catalog.i18n.yaml | 4 ++-- docs/persistence-catalog.zh.md | 2 +- docs/tool-catalog.i18n.yaml | 4 ++-- docs/tool-catalog.zh.md | 4 ++-- packages/client/ui-deliverables/src/index.ts | 2 +- packages/client/ui-deliverables/src/present-open.ts | 6 ++++-- .../ui-deliverables/tests/present-open.host.spec.ts | 8 ++++++++ packages/client/ui-deliverables/tests/prompt.host.spec.ts | 2 -- .../web/cordis-tool-round/tool-schemas.expected.json | 4 ++-- snapshots/web/fresh-round-trip/tool-schemas.expected.json | 4 ++-- snapshots/web/ptc-round/system-prompt.expected.md | 4 ++-- 12 files changed, 27 insertions(+), 19 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index e187952809..7b677ae08d 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3459,7 +3459,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-commands` ([`packages/client/ui-commands/src/index.ts`](../packages/client/ui-commands/src/index.ts)) - `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts)) - `@deepseek-ai/dsh-client-ui-cordis` ([`packages/extensions/ui-cordis/src/index.ts`](../packages/extensions/ui-cordis/src/index.ts)) -- `@deepseek-ai/dsh-client-ui-deliverables` — requires `systemPrompt` · `connection` · `sessionQuery` · `sessionController` · `workspaceFiles` · `fs` ([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-deliverables` — requires `systemPrompt` · `connection` · `sessionQuery` · `sessionController` ([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts)) - `@deepseek-ai/dsh-client-ui-directory-picker-browse` ([`packages/client/ui-directory-picker-browse/src/index.ts`](../packages/client/ui-directory-picker-browse/src/index.ts)) - `@deepseek-ai/dsh-client-ui-directory-picker-native` ([`packages/client/ui-directory-picker-native/src/index.ts`](../packages/client/ui-directory-picker-native/src/index.ts)) - `@deepseek-ai/dsh-client-ui-goal` ([`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts)) diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index d3294070f9..389c95a40b 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.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 docs/persistence-catalog.md -persistence-catalog.md: bf70879f80f959c865eca17efbdbce3ef78fda2e -persistence-catalog.zh.md: 1332dfdc8778dde651eeedefab2f87b09d67c06e +persistence-catalog.md: 46e91f23a7aad053791df190f769ad911f334b68 +persistence-catalog.zh.md: 087915b83d4dfd5c45a1583658d6ebe091904a3f diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 1332dfdc87..087915b83d 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -405,7 +405,7 @@ export type SessionEvent = { #### `deliverables/presented` — 仅日志 ```ts persistence-catalog -/** Declared workspace files from a successful final present result, including nested calls. */ +/** Declared filesystem files from a successful final present result, including nested calls. */ 'deliverables/presented': { turn: number; callId: ToolCallId; files: PresentedFile[] } ``` diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index e5150a443c..a9fa6c7545 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.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 docs/tool-catalog.md -tool-catalog.md: 928f7537ab212edf6af1dec93077caed2bb003b7 -tool-catalog.zh.md: 5ac34f6188f2b0689e02b72595375f00b02d5f6f +tool-catalog.md: c6c09aee8ed1cac69558bb02ca3395eaebed86e5 +tool-catalog.zh.md: 2cd6715ff03752885da0bb91e7e9ed9796f9ede4 diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index 5ac34f6188..2cd6715ff0 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -229,7 +229,7 @@ bash 工具是 bash 执行器 seam 面向模型的消费方。使用 `run_in_bac ### `present` -声明交付已有的工作区文件。如果你创建或更新的文件是用户要求接收的成果,则必须在写入完成后、最终回复前调用 present,包括通过 Bash 或代码执行创建的文件。在回复中提到文件路径不能替代这次调用。文件必须已存在。用户打开当前源文件;不复制或保存其内容。 +声明交付 Session 文件系统可访问的已有文件。如果你创建或更新的文件是用户要求接收的成果,则必须在写入完成后、最终回复前调用 present,包括通过 Bash 或代码执行创建的文件。在回复中提到文件路径不能替代这次调用。文件必须已存在。用户打开当前源文件;不复制或保存其内容。 ```json { @@ -243,7 +243,7 @@ bash 工具是 bash 执行器 seam 面向模型的消费方。使用 `run_in_bac "properties": { "path": { "type": "string", - "description": "Path of an existing file inside the workspace." + "description": "Path of an existing regular file. Relative paths use the Session working directory." }, "description": { "type": "string", diff --git a/packages/client/ui-deliverables/src/index.ts b/packages/client/ui-deliverables/src/index.ts index ee4ef63a40..a6799274a3 100644 --- a/packages/client/ui-deliverables/src/index.ts +++ b/packages/client/ui-deliverables/src/index.ts @@ -11,7 +11,7 @@ import type {} from '@deepseek-ai/dsh-system-prompt' import { registerPresentOpen } from './present-open.ts' /** Services required for file-reference guidance and authenticated native opens of declared files. */ -export const inject = ['systemPrompt', 'connection', 'sessionQuery', 'sessionController', 'workspaceFiles', 'fs'] +export const inject = ['systemPrompt', 'connection', 'sessionQuery', 'sessionController'] /** Stable final-response guidance owned by the matching renderer. */ const FILE_REFERENCE_PROMPT = 'When you successfully create or modify files, mention the primary outputs in your final response. ' diff --git a/packages/client/ui-deliverables/src/present-open.ts b/packages/client/ui-deliverables/src/present-open.ts index 97aab3242a..88655ca325 100644 --- a/packages/client/ui-deliverables/src/present-open.ts +++ b/packages/client/ui-deliverables/src/present-open.ts @@ -63,8 +63,10 @@ async function handlePresentOpen(ctx: Context, request: Request): Promise { + const { ctx, open, resolveAgent, opener } = await fixture() + resolveAgent.mockResolvedValueOnce({ agent: { ctx: ctx.isolate(service) } as unknown as Agent }) + expect((await open()).status).toBe(500) + expect(opener).not.toHaveBeenCalled() +}) diff --git a/packages/client/ui-deliverables/tests/prompt.host.spec.ts b/packages/client/ui-deliverables/tests/prompt.host.spec.ts index e168303128..8f342099fd 100644 --- a/packages/client/ui-deliverables/tests/prompt.host.spec.ts +++ b/packages/client/ui-deliverables/tests/prompt.host.spec.ts @@ -19,8 +19,6 @@ describe('ui-deliverables node plugin', () => { ctx.provide('connection', { fetch: { register: () => () => {} } } as never) ctx.provide('sessionQuery', {} as never) ctx.provide('sessionController', {} as never) - ctx.provide('workspaceFiles', {} as never) - ctx.provide('fs', {} as never) const mounted = ctx.plugin({ apply, inject }) await mounted.await() diff --git a/snapshots/web/cordis-tool-round/tool-schemas.expected.json b/snapshots/web/cordis-tool-round/tool-schemas.expected.json index 8cbf16c112..3477af247d 100644 --- a/snapshots/web/cordis-tool-round/tool-schemas.expected.json +++ b/snapshots/web/cordis-tool-round/tool-schemas.expected.json @@ -522,7 +522,7 @@ }, { "name": "present", - "description": "Declare existing workspace files as final deliverables. When a file you create or update is an output the user asked to receive, you must call present after writing it and before your final response, including files created through Bash or code execution. Mentioning its path in your reply does not replace this call. The files must already exist. The user opens the current source files; their contents are not copied or preserved.", + "description": "Declare existing files accessible through the Session filesystem as final deliverables. When a file you create or update is an output the user asked to receive, you must call present after writing it and before your final response, including files created through Bash or code execution. Mentioning its path in your reply does not replace this call. The files must already exist. The user opens the current source files; their contents are not copied or preserved.", "parameters": { "type": "object", "properties": { @@ -534,7 +534,7 @@ "properties": { "path": { "type": "string", - "description": "Path of an existing file inside the workspace." + "description": "Path of an existing regular file. Relative paths use the Session working directory." }, "description": { "type": "string", diff --git a/snapshots/web/fresh-round-trip/tool-schemas.expected.json b/snapshots/web/fresh-round-trip/tool-schemas.expected.json index cb28c49747..d39c60d137 100644 --- a/snapshots/web/fresh-round-trip/tool-schemas.expected.json +++ b/snapshots/web/fresh-round-trip/tool-schemas.expected.json @@ -325,7 +325,7 @@ }, { "name": "present", - "description": "Declare existing workspace files as final deliverables. When a file you create or update is an output the user asked to receive, you must call present after writing it and before your final response, including files created through Bash or code execution. Mentioning its path in your reply does not replace this call. The files must already exist. The user opens the current source files; their contents are not copied or preserved.", + "description": "Declare existing files accessible through the Session filesystem as final deliverables. When a file you create or update is an output the user asked to receive, you must call present after writing it and before your final response, including files created through Bash or code execution. Mentioning its path in your reply does not replace this call. The files must already exist. The user opens the current source files; their contents are not copied or preserved.", "parameters": { "type": "object", "properties": { @@ -337,7 +337,7 @@ "properties": { "path": { "type": "string", - "description": "Path of an existing file inside the workspace." + "description": "Path of an existing regular file. Relative paths use the Session working directory." }, "description": { "type": "string", diff --git a/snapshots/web/ptc-round/system-prompt.expected.md b/snapshots/web/ptc-round/system-prompt.expected.md index ebdf046722..3708515cdb 100644 --- a/snapshots/web/ptc-round/system-prompt.expected.md +++ b/snapshots/web/ptc-round/system-prompt.expected.md @@ -162,10 +162,10 @@ interface ToolArgsMap { /** children (default) lists direct children only; descendants walks the complete tree below you. */ scope?: "children" | "descendants"; } & Record; - /** Declare existing workspace files as final deliverables. When a file you create or update is an output the user asked to receive, you must call present after writing it and before your final response, including files created through Bash or code execution. Mentioning its path in your reply does not replace this call. The files must already exist. The user opens the current source files; their contents are not copied or preserved. */ + /** Declare existing files accessible through the Session filesystem as final deliverables. When a file you create or update is an output the user asked to receive, you must call present after writing it and before your final response, including files created through Bash or code execution. Mentioning its path in your reply does not replace this call. The files must already exist. The user opens the current source files; their contents are not copied or preserved. */ present: { files: { - /** Path of an existing file inside the workspace. */ + /** Path of an existing regular file. Relative paths use the Session working directory. */ path: string; /** Brief description for the user. */ description?: string; From 99bffa7f9a16fd3e87569d275c91d8f2c403e911 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 9 Sep 2026 16:52:04 +0800 Subject: [PATCH 12/20] fix(web): update delivery card snapshot and remove unused button segments --- apps/web/tests/present-svg.e2e.ts | 2 +- packages/client/ui-primitives/README.i18n.yaml | 4 ++-- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- packages/client/ui-primitives/src/Button.module.css | 3 --- packages/client/ui-primitives/src/Button.tsx | 6 ++---- snapshots/web/present-svg/ui.expected.md | 8 ++++---- 7 files changed, 11 insertions(+), 16 deletions(-) diff --git a/apps/web/tests/present-svg.e2e.ts b/apps/web/tests/present-svg.e2e.ts index 4ff6a5b819..bb6bd2e0b5 100644 --- a/apps/web/tests/present-svg.e2e.ts +++ b/apps/web/tests/present-svg.e2e.ts @@ -101,7 +101,7 @@ describe('web e2e: requested SVG is explicitly delivered', () => { expect(events.some(event => event.type === 'assistant/message' && event.seq > delivery.seq && event.data.message.content.some(block => block.type === 'text'))).toBe(true) - const card = page.locator('[data-presented-files-row]').getByRole('button').filter({ hasText: FILE }) + const card = page.locator('[data-presented-file]').filter({ hasText: FILE }) await card.waitFor({ state: 'visible' }) expect(await card.count()).toBe(1) expect(await page.getByText('产物', { exact: true }).count()).toBe(0) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 361c454a4d..88739b753c 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/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-primitives/README.md -README.md: 1c3bb7a7d36884234340b29da9f8763e46796e61 -README.zh.md: a8bc94f4afeb5c13d95ccafea4ef1b926524abd0 +README.md: 8e1ee78d02ce0802d1a8353050de69e6a7ab7c65 +README.zh.md: 4c50570d917a081a886525cf6f9be0de2ccc7065 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 1c3bb7a7d3..8e1ee78d02 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -67,7 +67,7 @@ Writing your own component in your own package is fine when the need is genuinel ### Controls and icons -The catalog above lists what each export is for; this section covers the behavior that props alone do not show. The `ic_ds_*` icon set and `FishLogo`/`BrandWordmark` marks fill brand and inline-icon slots. `LinkIcon` draws the leading category glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, all riding `currentColor` — and `classifyLinkPath` derives a file path's category from its extension. `FileTypeIcon` is the one glyph that does not ride `currentColor`: its sheet colour is the type's identity, so a consumer that wants it muted (an empty state) applies `filter: grayscale(1)` itself. `classifyFileType` and `classifyLinkPath` read one extension vocabulary (`file-extensions.ts`), so a path classifies consistently on a link and on a sheet; the sheet is the finer of the two. `ConnectionIndicator` renders a warning-colored disconnected action, a connecting label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Hover or keyboard focus shows only the reconnect action label, including while the connecting dots animate. Every state reserves the widest supplied label and uses fixed icon and text columns, so copy changes do not move or resize the control. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. `Button.segment` joins the start and end controls of a split action without changing standalone button geometry. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus. +The catalog above lists what each export is for; this section covers the behavior that props alone do not show. The `ic_ds_*` icon set and `FishLogo`/`BrandWordmark` marks fill brand and inline-icon slots. `LinkIcon` draws the leading category glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, all riding `currentColor` — and `classifyLinkPath` derives a file path's category from its extension. `FileTypeIcon` is the one glyph that does not ride `currentColor`: its sheet colour is the type's identity, so a consumer that wants it muted (an empty state) applies `filter: grayscale(1)` itself. `classifyFileType` and `classifyLinkPath` read one extension vocabulary (`file-extensions.ts`), so a path classifies consistently on a link and on a sheet; the sheet is the finer of the two. `ConnectionIndicator` renders a warning-colored disconnected action, a connecting label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Hover or keyboard focus shows only the reconnect action label, including while the connecting dots animate. Every state reserves the widest supplied label and uses fixed icon and text columns, so copy changes do not move or resize the control. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus. ### Rendering agent output diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index a8bc94f4af..4c50570d91 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -67,7 +67,7 @@ kind: "package-library" ### 控件与图标 -上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。`ic_ds_*` 图标集与 `FishLogo`/`BrandWordmark` 标记填充品牌与行内图标 slot。`LinkIcon` 为可点击产物链接绘制前置分类图形——地球、文件夹、代码、图片、文档或纸张,全部随 `currentColor`——`classifyLinkPath` 按扩展名推导文件路径的类别。`FileTypeIcon` 是唯一不随 `currentColor` 的字形:纸片颜色就是类型的身份,需要压灰的使用方(空状态)自行加 `filter: grayscale(1)`。`classifyFileType` 与 `classifyLinkPath` 读同一份扩展名词表(`file-extensions.ts`),一条路径在链接上与纸片上的归类一致;纸片分得更细。`ConnectionIndicator` 可渲染警告色的断联操作、以独立于 retry 时序的 500ms 节奏推进一至三个点的连接中状态,或成功色的恢复状态。悬停或键盘聚焦时只显示重连操作文案,连接中的圆点动画也保持隐藏。所有状态都为最长的输入 label 预留空间,并使用固定的图标列和文字列,因此文案变化不会移动控件或改变其宽度。它的 owner 提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。 `Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。 `rankByName` 是 `/` 菜单命令源与 skill 源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 `Button.segment` 将分段操作的首尾按钮连接起来,不改变独立按钮的几何样式。`Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。 +上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。`ic_ds_*` 图标集与 `FishLogo`/`BrandWordmark` 标记填充品牌与行内图标 slot。`LinkIcon` 为可点击产物链接绘制前置分类图形——地球、文件夹、代码、图片、文档或纸张,全部随 `currentColor`——`classifyLinkPath` 按扩展名推导文件路径的类别。`FileTypeIcon` 是唯一不随 `currentColor` 的字形:纸片颜色就是类型的身份,需要压灰的使用方(空状态)自行加 `filter: grayscale(1)`。`classifyFileType` 与 `classifyLinkPath` 读同一份扩展名词表(`file-extensions.ts`),一条路径在链接上与纸片上的归类一致;纸片分得更细。`ConnectionIndicator` 可渲染警告色的断联操作、以独立于 retry 时序的 500ms 节奏推进一至三个点的连接中状态,或成功色的恢复状态。悬停或键盘聚焦时只显示重连操作文案,连接中的圆点动画也保持隐藏。所有状态都为最长的输入 label 预留空间,并使用固定的图标列和文字列,因此文案变化不会移动控件或改变其宽度。它的 owner 提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。 `Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。 `rankByName` 是 `/` 菜单命令源与 skill 源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 `Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。 ### 渲染 agent 输出 diff --git a/packages/client/ui-primitives/src/Button.module.css b/packages/client/ui-primitives/src/Button.module.css index b8efa4391d..9fa1712a66 100644 --- a/packages/client/ui-primitives/src/Button.module.css +++ b/packages/client/ui-primitives/src/Button.module.css @@ -77,6 +77,3 @@ align-items: center; justify-content: center; } - -.start { border-radius: 8px 0 0 8px; } -.end { border-radius: 0 8px 8px 0; } diff --git a/packages/client/ui-primitives/src/Button.tsx b/packages/client/ui-primitives/src/Button.tsx index 9519388d4d..d2e39dbf23 100644 --- a/packages/client/ui-primitives/src/Button.tsx +++ b/packages/client/ui-primitives/src/Button.tsx @@ -12,20 +12,18 @@ export type ButtonVariant = 'primary' | 'ghost' | 'outline' | 'toolbar' * Render a button. * @param props.variant - visual family (default 'ghost'). * @param props.size - 'md' 36px capsule (figma Button) or 'sm' 28px compact. - * @param props.segment - joined button edge within an owner-rendered split control. * @param props.icon - optional leading 16px icon node. * @returns the button element; native button attributes pass through. */ -export function Button({ variant = 'ghost', size = 'md', icon, segment, className, children, ...rest }: { +export function Button({ variant = 'ghost', size = 'md', icon, className, children, ...rest }: { variant?: ButtonVariant size?: 'md' | 'sm' - segment?: 'start' | 'end' icon?: ReactNode className?: string | undefined children?: ReactNode } & ButtonHTMLAttributes) { return ( - diff --git a/snapshots/web/present-svg/ui.expected.md b/snapshots/web/present-svg/ui.expected.md index 13cc9d46c6..1fc1597b18 100644 --- a/snapshots/web/present-svg/ui.expected.md +++ b/snapshots/web/present-svg/ui.expected.md @@ -51,11 +51,11 @@ - paragraph: 下方附一行核心思想说明:指令与数据以二进制统一存储在存储器中。用浏览器打开该 SVG 文件即可查看。 - text: 本轮文件改动 - button "打开 {{cwd}}/workspace/von-neumann.svg": von-neumann.svg -- text: 交付文件 -- button "在默认程序中打开 von-neumann.svg": - - text: von-neumann.svg SVG 冯诺依曼架构示意图 SVG +- button "在侧边栏预览 von-neumann.svg" +- text: von-neumann.svg 冯诺依曼架构示意图 SVG +- button "在侧边栏打开 von-neumann.svg": 打开 +- button "von-neumann.svg 的更多文件操作": - img - - text: 打开 - button "复制": - img - button "好的回答": From dfda1d6aafa19a698f67b92c53b8994375682a5f Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 9 Sep 2026 17:02:24 +0800 Subject: [PATCH 13/20] fix(web): ship protocol runtime dependency for delivered file actions --- packages/client/ui-deliverables/package.json | 6 ++++-- pnpm-lock.yaml | 7 ++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json index cd71933d2f..2e56c1b3b2 100644 --- a/packages/client/ui-deliverables/package.json +++ b/packages/client/ui-deliverables/package.json @@ -72,12 +72,14 @@ "@deepseek-ai/dsh-api-workspace-files": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^" }, "files": [ "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" - ] + ], + "dependencies": { + "@deepseek-ai/dsh-typert-protocol": "workspace:^" + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6cfebbe630..3785077c2f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2683,6 +2683,10 @@ importers: version: 4.4.3 packages/client/ui-deliverables: + dependencies: + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -2750,9 +2754,6 @@ importers: '@deepseek-ai/dsh-tool-present': specifier: workspace:^ version: link:../../fs/tool-present - '@deepseek-ai/dsh-typert-protocol': - specifier: workspace:^ - version: link:../../typert/protocol '@deepseek-ai/dsh-util-workspace-path': specifier: workspace:^ version: link:../../util/workspace-path From 1d55cab9e66108ba536961402d3b8e6a0caa5ec0 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 9 Sep 2026 17:23:07 +0800 Subject: [PATCH 14/20] fix(web): use shared file type icons in delivery cards --- .../2026-09-08-shared-file-type-icons.i18n.yaml | 4 ++-- .../feature/2026-09-08-shared-file-type-icons.md | 2 +- .../2026-09-08-shared-file-type-icons.zh.md | 2 +- packages/client/ui-deliverables/README.i18n.yaml | 4 ++-- packages/client/ui-deliverables/README.md | 2 +- packages/client/ui-deliverables/README.zh.md | 2 +- .../src/client/Deliverables.module.css | 1 - .../src/client/PresentedFileCard.tsx | 6 +++--- .../tests/produced-files.client.spec.tsx | 14 ++++++++++++++ 9 files changed, 25 insertions(+), 12 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml index e8a971188f..ce64968fbd 100644 --- a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.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 .agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md -2026-09-08-shared-file-type-icons.md: 36b4ff8f57dd810da3d2af94aac15ec5afab799e -2026-09-08-shared-file-type-icons.zh.md: 4b33a8ffe5934953b12e82750ce57febc6f054f4 +2026-09-08-shared-file-type-icons.md: 0844cb8828edafe135d4f35b6b942691998b8b97 +2026-09-08-shared-file-type-icons.zh.md: 6d10a5669a3c68cfa327997f6822547689e3df1a diff --git a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md index 36b4ff8f57..0844cb8828 100644 --- a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md +++ b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md @@ -18,7 +18,7 @@ Recognized code and configuration files render the corresponding 20px square art `LinkIcon` delegates extension classification to `classifyFileType` and folds the detailed result into its existing link vocabulary: code and HTML use `code`, images use `image`, PDF/Word/Excel/PPT use `document`, and Markdown/video/unknown files use `other`. Extensionless names remain `other` in link contexts, so the 14px clickable-link appearance defined by the [clickable-link decision](2026-09-04-web-clickable-link-styles.md) does not change. -Attachment upload cards, sent-message file cards, queued-file rows, and workspace file rows render `FileTypeIcon`. The two metadata rows use `fileExtension` rather than local parsers; a leading-dot basename such as `.env` therefore displays `ENV`, while an absent or trailing suffix displays no extension label. Image content continues to render as a preview rather than a file-type glyph, and produced-file links and Markdown file mentions continue to use `LinkIcon` because they are link surfaces. +Attachment upload cards, sent-message file cards, queued-file rows, and workspace file rows render `FileTypeIcon`. Explicit delivery cards also use `FileTypeIcon` at 28px and `fileExtension` for their fallback metadata. The two metadata rows use `fileExtension` rather than local parsers; a leading-dot basename such as `.env` therefore displays `ENV`, while an absent or trailing suffix displays no extension label. Image content continues to render as a preview rather than a file-type glyph, and produced-file links and Markdown file mentions continue to use `LinkIcon` because they are link surfaces. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md index 4b33a8ffe5..6d10a5669a 100644 --- a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md +++ b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md @@ -18,7 +18,7 @@ Status: implemented `LinkIcon` 委托 `classifyFileType` 做扩展名分类,再把精细结果折叠进原有链接词汇:code 与 HTML 使用 `code`,图片使用 `image`,PDF/Word/Excel/PPT 使用 `document`,Markdown、video 与未知文件使用 `other`。无扩展名文件在链接语境中仍是 `other`,因此[可点击链接决策](2026-09-04-web-clickable-link-styles.zh.md)定义的 14px 外观不变。 -附件上传卡片、已发送消息文件卡片、排队文件行和工作区文件行渲染 `FileTypeIcon`。两处元数据行使用 `fileExtension`,不再保留本地解析器;`.env` 这样的前导点 basename 会显示 `ENV`,无后缀或末尾点号则不显示扩展名 label。图片内容继续渲染为预览而不是文件类型图形,产物文件链接与 Markdown 文件提及继续使用 `LinkIcon`,因为它们属于链接表面。 +附件上传卡片、已发送消息文件卡片、排队文件行和工作区文件行渲染 `FileTypeIcon`。显式交付卡片也使用 28px 的 `FileTypeIcon`,并通过 `fileExtension` 提供默认元数据。两处元数据行使用 `fileExtension`,不再保留本地解析器;`.env` 这样的前导点 basename 会显示 `ENV`,无后缀或末尾点号则不显示扩展名 label。图片内容继续渲染为预览而不是文件类型图形,产物文件链接与 Markdown 文件提及继续使用 `LinkIcon`,因为它们属于链接表面。 ## 备选方案 diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index 12e097c4de..59ca1fefc1 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/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-deliverables/README.md -README.md: 50677e0cfb260c7b9bd31ed9599a2d0214dda201 -README.zh.md: 2196be4fed14cb269056ce4fd83960ca4024f63a +README.md: 5ce7eb51df13e23a01a9ca05291c30a93e6d6d0c +README.zh.md: 9d25bbe4f76b3bf14807ea741f4176ececbe94b7 diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index 50677e0cfb..5ce7eb51df 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -30,7 +30,7 @@ Mount this plugin alongside `ui-conversation`; a finished turn then ends with th ### Explicit deliveries -The Web `standard`, `ptc`, and `cordis` presets expose `present` for final files accessible through the Session filesystem, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../fs/tool-present/README.md) owns file-count limits and Session declarations. The closing turn shows one delivery as a full-width card and multiple deliveries in a grid of at most two cards per row. A list longer than four files starts collapsed and provides a control that reveals or hides the complete list. Each card shows the basename and description, or the file type when no description exists; a trailing parenthesized suffix in the description is omitted, and hovering the card replaces that line with the Sidebar-preview action. Clicking the card or the left side of its split Open control previews the file in the right Sidebar. The chevron opens the standard menu for the Host default application plus Show in Finder on macOS, Show in File Explorer on Windows and WSL, or Open containing folder through the default Linux file manager. Matching inline-code references open the same source files without starting a browser download. Repeated declaration of a path selects its latest description before the closing reply. +The Web `standard`, `ptc`, and `cordis` presets expose `present` for final files accessible through the Session filesystem, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../fs/tool-present/README.md) owns file-count limits and Session declarations. The closing turn shows one delivery as a full-width card and multiple deliveries in a grid of at most two cards per row. A list longer than four files starts collapsed and provides a control that reveals or hides the complete list. Each card uses the shared `FileTypeIcon` and shows the basename and description, or the file type when no description exists; a trailing parenthesized suffix in the description is omitted, and hovering the card replaces that line with the Sidebar-preview action. Clicking the card or the left side of its split Open control previews the file in the right Sidebar. The chevron opens the standard menu for the Host default application plus Show in Finder on macOS, Show in File Explorer on Windows and WSL, or Open containing folder through the default Linux file manager. Matching inline-code references open the same source files without starting a browser download. Repeated declaration of a path selects its latest description before the closing reply. The `present` tool row shows running, delivered, failed, or interrupted status; expanding a settled row reveals its recorded result. The collapsible card grid retains every delivered file. Both menu actions share pending state and show progress, acknowledgement, or an action-specific retryable error. Desktop information is read when delivery cards appear and invalidated on connection replacement; responses from a replaced connection cannot publish metadata. Selecting a native menu action returns keyboard focus to the available Sidebar Open button. Pending actions close the menu until another explicit gesture. A missing desktop disables the Open menu; a failed desktop-information read offers Retry. It requires a desktop and a suitable default application on the serving Host; a remote browser does not open applications on its own device. diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 2196be4fed..9d25bbe4f7 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -30,7 +30,7 @@ kind: "package-reference" ### 显式交付 -Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交付Session 文件系统可访问的最终文件,包括通过 Bash 创建的文件。创建文件后,以 `files: [{ path, description? }]` 调用。[present 工具](../../fs/tool-present/README.zh.md)拥有文件数量限制和 Session 声明。收尾 turn 把单个交付显示为横向占满内容区的卡片,把多个交付显示为每行最多两张卡片的网格。文件超过四个时,列表默认收起,并提供显示或隐藏完整列表的控件。每张卡片显示 basename 与说明;没有说明时显示文件类型,说明末尾的括号后缀会被省略,悬停卡片时该行切换为侧栏预览提示。点击卡片或分段“打开”控件的左侧会在右侧 Sidebar 中预览文件;右侧箭头打开标准菜单,其中提供 Host 默认应用,以及 macOS 上的“在 Finder 中显示”、Windows 和 WSL 上的“在文件资源管理器中显示”或 Linux 默认文件管理器的“打开所在文件夹”。匹配的行内代码引用打开相同源文件,不触发浏览器下载。同一路径重复声明时,选择收尾回复之前最近一次的说明。 +Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交付Session 文件系统可访问的最终文件,包括通过 Bash 创建的文件。创建文件后,以 `files: [{ path, description? }]` 调用。[present 工具](../../fs/tool-present/README.zh.md)拥有文件数量限制和 Session 声明。收尾 turn 把单个交付显示为横向占满内容区的卡片,把多个交付显示为每行最多两张卡片的网格。文件超过四个时,列表默认收起,并提供显示或隐藏完整列表的控件。每张卡片使用共享的 `FileTypeIcon`,显示 basename 与说明;没有说明时显示文件类型,说明末尾的括号后缀会被省略,悬停卡片时该行切换为侧栏预览提示。点击卡片或分段“打开”控件的左侧会在右侧 Sidebar 中预览文件;右侧箭头打开标准菜单,其中提供 Host 默认应用,以及 macOS 上的“在 Finder 中显示”、Windows 和 WSL 上的“在文件资源管理器中显示”或 Linux 默认文件管理器的“打开所在文件夹”。匹配的行内代码引用打开相同源文件,不触发浏览器下载。同一路径重复声明时,选择收尾回复之前最近一次的说明。 `present` 工具行显示正在交付、已交付、失败或中断状态;展开已结束的调用可查看其记录的结果。可折叠卡片网格保留全部交付文件。菜单中的两个操作共享等待状态,并显示进度、请求确认或各自可重试的错误。交付卡片出现时读取桌面信息,连接更换时清除缓存,旧连接的响应不能更新元数据。选择原生菜单操作后,键盘焦点回到仍可用的侧边栏“打开”按钮。等待操作完成时关闭菜单,用户再次点击才会打开。Host 没有桌面时禁用“打开”菜单;桌面信息读取失败时提供“重试”。服务 Host 必须具备桌面和合适的默认应用;远程浏览器不会打开其所在设备上的应用。 diff --git a/packages/client/ui-deliverables/src/client/Deliverables.module.css b/packages/client/ui-deliverables/src/client/Deliverables.module.css index cd503f8f79..5125edd381 100644 --- a/packages/client/ui-deliverables/src/client/Deliverables.module.css +++ b/packages/client/ui-deliverables/src/client/Deliverables.module.css @@ -9,7 +9,6 @@ .cardPreview { position: absolute; z-index: 1; inset: 0; width: 100%; padding: 0; border: 0; border-radius: inherit; background: transparent; cursor: pointer; } .cardPreview:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--dsw-alias-brand-primary); } .fileIcon { position: relative; z-index: 2; flex: 0 0 auto; display: grid; place-items: center; width: 48px; height: 48px; overflow: hidden; pointer-events: none; border: 0.5px solid var(--dsw-alias-border-l2); border-radius: 12px; background: var(--deliverable-fill); color: var(--dsw-alias-link); } -.fileIcon svg { width: 20px; height: 20px; } .fileBody { position: relative; z-index: 2; display: flex; flex: 1; align-items: center; justify-content: space-between; gap: 12px; min-width: 0; pointer-events: none; } .details { display: flex; flex: 1; flex-direction: column; justify-content: center; gap: 2px; min-width: 0; } .fileName { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; font-weight: 500; line-height: 22px; } diff --git a/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx index 51946bf65a..ef848827e8 100644 --- a/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx +++ b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx @@ -2,7 +2,7 @@ import { useRef, useState } from 'react' import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path' import { - Menu, LinkIcon, classifyLinkPath, IconRightUpOutline16, + Menu, FileTypeIcon, fileExtension, IconRightUpOutline16, IconChevronDownOutline14, IconFolderOpenOutline16, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' @@ -42,7 +42,7 @@ export function PresentedFileCard({ file, cwd, phase, host, onPreview, onAction, onAction(action) } const name = basename(file.path) - const metadata = name.match(/\.([^.]+)$/)?.[1]?.toUpperCase() ?? t('presented.file') + const metadata = fileExtension(name).toUpperCase() || t('presented.file') const status = phase === undefined ? cardDescription(file.description, metadata) : t(reveal === 'directory' && phase === 'revealed' ? 'presented.directoryOpened' @@ -51,7 +51,7 @@ export function PresentedFileCard({ file, cwd, phase, host, onPreview, onAction, return
diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 9d25bbe4f7..8e801a164c 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -52,7 +52,7 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交 Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,要求模型点名成功创建或修改的主要文件,并把这些文件以及正文中提到的其他本轮变更文件写成 Markdown 行内代码。浏览器半部把组合 `ProducedFiles` 与显式交付的包装组件注册进 chat 视图的 `conversation.chat.turnTail` 洞。`deliverablesDefinition` 根据 `write`、`edit` 和有修改作用的 `str_replace_editor` 命令中经过校验的原始参数,把每个轮次成功的第一方修改调用折叠进 `DeliverablesTurnData`。读取、删除、不受支持的工具、格式错误的调用和失败结果不贡献任何条目。新的修改工具必须增加显式 Client contribution 才能加入列表。本包还提供 chat 视图按收尾消息查询的 `chatFileMentions` 服务;把插件组合出去会同时移除两个表面,视图的空链以零成本留下。 -原生打开使用经过认证的 POST,通过当前查看的 Session、事件序号和原始文件索引定位声明。Host 查找或恢复该 Session 的 Agent,通过其 `workspaceFiles.stat` 检查声明,与侧栏预览使用同一文件系统。原生操作要求规范化的进程路径能从 Host 路径映射回同一进程路径。提供方没有这种映射时返回 422,卡片提示使用侧栏预览;Host 上存在同名文件并不足够。同一份桌面可用性配置同时约束信息查询和实际执行。编辑会影响后续打开的内容;删除后返回错误。不创建文件内容副本或附件。插件释放时取消并等待进行中的原生打开请求。 +原生打开使用经过认证的 POST,通过当前查看的 Session、事件序号和原始文件索引定位声明。Host 读取声明及当前查看的 Session header,将其中的 cwd 传给 `workspaceFiles.stat`;未记录 cwd 时使用部署的工作目录。它与侧栏预览使用同一组合文件系统,无需启动 Agent,子会话也适用。原生操作要求规范化的进程路径能从 Host 路径映射回同一进程路径。提供方没有这种映射时返回 422,卡片提示使用侧栏预览;Host 上存在同名文件并不足够。同一份桌面可用性配置同时约束信息查询和实际执行。编辑会影响后续打开的内容;删除后返回错误。不创建文件内容副本或附件。插件释放时取消并等待进行中的原生打开请求。
diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json index 2e56c1b3b2..16534d6f8d 100644 --- a/packages/client/ui-deliverables/package.json +++ b/packages/client/ui-deliverables/package.json @@ -72,7 +72,8 @@ "@deepseek-ai/dsh-api-workspace-files": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^" + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-deliverables/src/index.ts b/packages/client/ui-deliverables/src/index.ts index a6799274a3..04e5e7a8dd 100644 --- a/packages/client/ui-deliverables/src/index.ts +++ b/packages/client/ui-deliverables/src/index.ts @@ -11,7 +11,7 @@ import type {} from '@deepseek-ai/dsh-system-prompt' import { registerPresentOpen } from './present-open.ts' /** Services required for file-reference guidance and authenticated native opens of declared files. */ -export const inject = ['systemPrompt', 'connection', 'sessionQuery', 'sessionController'] +export const inject = ['systemPrompt', 'connection', 'sessionQuery', 'sessionController', 'workspaceFiles', 'fs', 'sandboxPolicy'] /** Stable final-response guidance owned by the matching renderer. */ const FILE_REFERENCE_PROMPT = 'When you successfully create or modify files, mention the primary outputs in your final response. ' diff --git a/packages/client/ui-deliverables/src/present-open.ts b/packages/client/ui-deliverables/src/present-open.ts index 88655ca325..9589ef0006 100644 --- a/packages/client/ui-deliverables/src/present-open.ts +++ b/packages/client/ui-deliverables/src/present-open.ts @@ -3,6 +3,7 @@ import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-api-session-controller' import type {} from '@deepseek-ai/dsh-api-workspace-files' import type {} from '@deepseek-ai/dsh-fs' +import type {} from '@deepseek-ai/dsh-sandbox-policy' import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' import type {} from '@deepseek-ai/dsh-client-connection' import type {} from '@deepseek-ai/dsh-session-query' @@ -54,19 +55,17 @@ async function handlePresentOpen(ctx: Context, request: Request): Promise ctx.fiber.dispose()) const session: { cwd?: string } = { cwd } await ctx.plugin(LocalFileSystem, { cwd }) - ctx.provide('sandboxPolicy', { resolve: () => ({ workspaceRoot: session.cwd ?? cwd }) } as never) - new WorkspaceFiles(ctx, { maxBytes: 1024, maxFileBytes: 1024, maxLines: 100, maxEntries: 100 }) - const agent = { ctx, session: { header: session } } as unknown as Agent - const resolveAgent = vi.fn(async () => ({ agent })) + ctx.provide('sandboxPolicy', { workspaceRoot: cwd } as never) + await ctx.plugin({ + inject: ['fs', 'sandboxPolicy'], + apply: (scope) => { new WorkspaceFiles(scope, { maxBytes: 1024, maxFileBytes: 1024, maxLines: 100, maxEntries: 100 }) }, + }) + const resolveAgent = vi.fn(() => { throw new Error('Agent activation is unavailable') }) const readEvent = vi.fn(async (request: SessionEventReadRequest) => { if (request.sessionId !== 'owner') throw new SessionQueryError('missing', 'SESSION_QUERY_SESSION_NOT_FOUND') if (request.seq !== 7) throw new SessionQueryError('missing', 'SESSION_QUERY_EVENT_NOT_FOUND') @@ -48,7 +48,7 @@ async function fixture() { const opener = vi.fn(async (_request: { path: string; action?: 'reveal' }, _signal: AbortSignal) => ({ opened: true as const })) ctx.provide('sessionController', { resolveAgent, openWorkspacePath: opener, workspaceDesktop: () => ({ name: 'desktop', available: true, fileManager: 'finder' }) } as never) const connection = new HostConnectionService(ctx, [], {} as BrowserAuth) - const fiber = ctx.plugin({ inject: ['connection', 'sessionQuery', 'sessionController', 'workspaceFiles', 'fs'], apply: registerPresentOpen }) + const fiber = ctx.plugin({ inject: ['connection', 'sessionQuery', 'sessionController', 'workspaceFiles', 'fs', 'sandboxPolicy'], apply: registerPresentOpen }) await fiber const handler = connection.createSharedFetchHandler('/api') const open = (query = '?sessionId=owner&seq=7&index=0', signal?: AbortSignal) => handler.fetch(new Request( @@ -219,29 +219,17 @@ it('refuses native opening without a matching Host mapping even when a same-name expect(opener).not.toHaveBeenCalled() }) -it('reports Session resolution failures before checking files', async () => { - const { resolveAgent, open, opener } = await fixture() - resolveAgent.mockResolvedValueOnce({ error: new RemoteError('session/not-found', 'missing', { sessionId: SessionId('owner') }) }) - expect((await open()).status).toBe(404) - expect(opener).not.toHaveBeenCalled() +it('opens a viewed child Session without activating an Agent', async () => { + const { session, readEvent, open, opener, resolveAgent } = await fixture() + readEvent.mockResolvedValueOnce({ session, target: { type: 'deliverables/presented', data: { turn: 1, callId: 'child', files: [{ path: '日记模板.docx' }] } } as SessionEvent }) + expect((await open('?sessionId=child&seq=7&index=0')).status).toBe(204) + expect(opener).toHaveBeenCalledOnce() + expect(resolveAgent).not.toHaveBeenCalled() }) - -it('uses the resolved Agent filesystem instead of the Host filesystem', async () => { - const { ctx, cwd, open, resolveAgent, opener } = await fixture() - const scoped = ctx.isolate('fs') - await scoped.plugin(LocalFileSystem, { cwd }) - vi.spyOn(scoped.fs, 'processPathFromHostPath').mockReturnValue(undefined) - resolveAgent.mockResolvedValueOnce({ agent: { ctx: scoped, session: { header: { cwd } } } as unknown as Agent }) - expect((await open()).status).toBe(422) - expect(opener).not.toHaveBeenCalled() +it('uses the deployment workspace root when the viewed Session has no cwd', async () => { + const { session, open, cwd, file, opener } = await fixture() + delete session.cwd expect((await open()).status).toBe(204) -}) - - -it.each(['workspaceFiles', 'fs'])('refuses an Agent missing its %s service', async (service) => { - const { ctx, open, resolveAgent, opener } = await fixture() - resolveAgent.mockResolvedValueOnce({ agent: { ctx: ctx.isolate(service) } as unknown as Agent }) - expect((await open()).status).toBe(500) - expect(opener).not.toHaveBeenCalled() + expect(opener.mock.lastCall?.[0].path).toBe(await realpath(join(cwd, file.path))) }) diff --git a/packages/client/ui-deliverables/tests/prompt.host.spec.ts b/packages/client/ui-deliverables/tests/prompt.host.spec.ts index 8f342099fd..b56bfa3b8e 100644 --- a/packages/client/ui-deliverables/tests/prompt.host.spec.ts +++ b/packages/client/ui-deliverables/tests/prompt.host.spec.ts @@ -19,6 +19,9 @@ describe('ui-deliverables node plugin', () => { ctx.provide('connection', { fetch: { register: () => () => {} } } as never) ctx.provide('sessionQuery', {} as never) ctx.provide('sessionController', {} as never) + ctx.provide('workspaceFiles', {} as never) + ctx.provide('fs', {} as never) + ctx.provide('sandboxPolicy', {} as never) const mounted = ctx.plugin({ apply, inject }) await mounted.await() diff --git a/packages/client/ui-deliverables/tsconfig.host.json b/packages/client/ui-deliverables/tsconfig.host.json index ee8f384dd8..289e7df535 100644 --- a/packages/client/ui-deliverables/tsconfig.host.json +++ b/packages/client/ui-deliverables/tsconfig.host.json @@ -43,6 +43,9 @@ }, { "path": "../../typert/protocol" + }, + { + "path": "../../sandbox/sandbox-policy" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5764c8c667..2d0f407fb4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2742,6 +2742,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session From 0b3248e67d44d2324b532815c2f5470160bbf6d8 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 9 Sep 2026 18:21:45 +0800 Subject: [PATCH 20/20] test(web): stabilize delivered-file snapshots across CI hosts --- apps/web/tests/navigation-panes.e2e.ts | 5 ++--- apps/web/tests/present-svg.e2e.ts | 2 ++ apps/web/tests/present-svg.overlay.yml | 4 ++++ packages/client/ui-primitives/src/LinkIcon.tsx | 1 + snapshots/web/present-svg/ui.expected.md | 3 ++- 5 files changed, 11 insertions(+), 4 deletions(-) create mode 100644 apps/web/tests/present-svg.overlay.yml diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index d51e08bb49..a73c9dd65b 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -403,14 +403,13 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { // the right column either — the expanded terminal card is read in place. await page.locator('[data-sample="bash"] ~ div [data-terminal] [class*="_copyButton_"]').first().click() await expect.poll(() => frame.getAttribute('data-rightbar-collapsed'), { timeout: 5_000 }).toBe('true') - // Read summaries are file links: one click opens the file as a text-preview - // tab in the right Sidebar, which expands to show it beside the guide tab. + // Opening a file into the empty column creates only its preview tab. const fileLink = page.locator('[data-variant="read"] button').first() await fileLink.waitFor({ timeout: 10_000 }) await fileLink.click() await expect.poll(() => frame.getAttribute('data-rightbar-collapsed'), { timeout: 5_000 }).toBe(null) const column = page.locator('[data-rightbar-col]') - await expect.poll(() => column.locator('[data-dockkit-tab-title]').count(), { timeout: 5_000 }).toBe(2) + await expect.poll(() => column.locator('[data-dockkit-tab-title]').allTextContents(), { timeout: 5_000 }).toEqual(['nav-a.md']) // Put the column back so later cases start from the default frame. await column.locator('[data-sidebar-right-toggle]').click() await expect.poll(() => frame.getAttribute('data-rightbar-collapsed'), { timeout: 5_000 }).toBe('true') diff --git a/apps/web/tests/present-svg.e2e.ts b/apps/web/tests/present-svg.e2e.ts index bb6bd2e0b5..9fc93f131b 100644 --- a/apps/web/tests/present-svg.e2e.ts +++ b/apps/web/tests/present-svg.e2e.ts @@ -40,6 +40,7 @@ describe('web e2e: requested SVG is explicitly delivered', () => { } scaffold = await launchWebScaffold({ compareReplaySession: true, + extraOverlayPath: fileURLToPath(new URL('./present-svg.overlay.yml', import.meta.url)), ...(replayOverride === undefined ? {} : { replayFixture: FIXTURE, replayOverride }), }) browser = await chromium.launch() @@ -114,6 +115,7 @@ describe('web e2e: requested SVG is explicitly delivered', () => { it.skipIf(MODE === 'record')('replays the delivered file and Chinese conversation', async () => { await assertFinalWorkspaceSnapshot(DIR, cwd) + await expect.poll(() => page.getByRole('button', { name: `${FILE} 的更多文件操作`, exact: true }).isDisabled()).toBe(true) // Delivery owns the transcript; navigation and composer chrome have separate scenarios. const aria = await captureExpandedTurnProcessAria(page, '[data-chat-flow]', scaffold.workspaceCwd) await compareOrRefreshGolden(join(DIR, 'ui.expected.md'), aria, MODE) diff --git a/apps/web/tests/present-svg.overlay.yml b/apps/web/tests/present-svg.overlay.yml new file mode 100644 index 0000000000..a575a00026 --- /dev/null +++ b/apps/web/tests/present-svg.overlay.yml @@ -0,0 +1,4 @@ +# This transcript includes the no-desktop state on every test platform. +- id: session-controller + config: + nativeOpen: false diff --git a/packages/client/ui-primitives/src/LinkIcon.tsx b/packages/client/ui-primitives/src/LinkIcon.tsx index 4f645f6514..d46f11c24d 100644 --- a/packages/client/ui-primitives/src/LinkIcon.tsx +++ b/packages/client/ui-primitives/src/LinkIcon.tsx @@ -48,6 +48,7 @@ export function classifyLinkPath(path: string): LinkIconKind { case 'markdown': case 'other': case 'video': return 'other' + /* v8 ignore next -- classifyFileType returns a closed union exhausted above */ default: return assertNever(type) } } diff --git a/snapshots/web/present-svg/ui.expected.md b/snapshots/web/present-svg/ui.expected.md index 1fc1597b18..bede988652 100644 --- a/snapshots/web/present-svg/ui.expected.md +++ b/snapshots/web/present-svg/ui.expected.md @@ -51,10 +51,11 @@ - paragraph: 下方附一行核心思想说明:指令与数据以二进制统一存储在存储器中。用浏览器打开该 SVG 文件即可查看。 - text: 本轮文件改动 - button "打开 {{cwd}}/workspace/von-neumann.svg": von-neumann.svg +- text: 此主机没有可用的桌面,无法打开文件或文件夹 - button "在侧边栏预览 von-neumann.svg" - text: von-neumann.svg 冯诺依曼架构示意图 SVG - button "在侧边栏打开 von-neumann.svg": 打开 -- button "von-neumann.svg 的更多文件操作": +- button "von-neumann.svg 的更多文件操作" [disabled]: - img - button "复制": - img