Преглед изворни кода

feat(web): add native file actions to artifact cards

yudshj пре 2 дана
родитељ
комит
4158a96e14
44 измењених фајлова са 698 додато и 91 уклоњено
  1. 2 2
      .agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml
  2. 2 0
      .agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md
  3. 2 0
      .agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md
  4. 15 5
      apps/web/tests/present.e2e.ts
  5. 4 0
      apps/web/tests/present.overlay.yml
  6. 2 2
      docs/subsystems/session.i18n.yaml
  7. 7 1
      docs/subsystems/session.md
  8. 7 1
      docs/subsystems/session.zh.md
  9. 2 2
      packages/api/session-controller/README.i18n.yaml
  10. 0 0
      packages/api/session-controller/README.md
  11. 0 1
      packages/api/session-controller/README.zh.md
  12. 17 2
      packages/api/session-controller/src/index.ts
  13. 2 0
      packages/api/session-controller/src/types.ts
  14. 32 0
      packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts
  15. 2 0
      packages/api/session-controller/tests/test-remote.ts
  16. 2 2
      packages/client/ui-deliverables/README.i18n.yaml
  17. 3 3
      packages/client/ui-deliverables/README.md
  18. 3 3
      packages/client/ui-deliverables/README.zh.md
  19. 19 9
      packages/client/ui-deliverables/src/client/Deliverables.module.css
  20. 23 20
      packages/client/ui-deliverables/src/client/Deliverables.tsx
  21. 71 0
      packages/client/ui-deliverables/src/client/PresentedFileCard.tsx
  22. 4 2
      packages/client/ui-deliverables/src/client/index.ts
  23. 34 0
      packages/client/ui-deliverables/src/client/locales.ts
  24. 46 11
      packages/client/ui-deliverables/src/client/present-open.ts
  25. 10 2
      packages/client/ui-deliverables/src/present-open.ts
  26. 26 0
      packages/client/ui-deliverables/src/presented.ts
  27. 55 0
      packages/client/ui-deliverables/tests/present-open.client.spec.ts
  28. 25 2
      packages/client/ui-deliverables/tests/present-open.host.spec.ts
  29. 95 0
      packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx
  30. 24 4
      packages/client/ui-deliverables/tests/produced-files.client.spec.tsx
  31. 2 2
      packages/client/ui-primitives/README.i18n.yaml
  32. 0 0
      packages/client/ui-primitives/README.md
  33. 0 0
      packages/client/ui-primitives/README.zh.md
  34. 3 0
      packages/client/ui-primitives/src/Button.module.css
  35. 4 2
      packages/client/ui-primitives/src/Button.tsx
  36. 20 3
      packages/client/ui-primitives/src/Menu.tsx
  37. 7 1
      packages/extensions/tool-cordis/src/api-catalog.ts
  38. 2 2
      packages/util/native-command/README.i18n.yaml
  39. 2 0
      packages/util/native-command/README.md
  40. 2 0
      packages/util/native-command/README.zh.md
  41. 3 0
      packages/util/native-command/src/index.ts
  42. 52 1
      packages/util/native-command/src/path-opener.ts
  43. 53 1
      packages/util/native-command/tests/path-opener.spec.ts
  44. 12 5
      snapshots/web/present/ui.expected.md

+ 2 - 2
.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

+ 2 - 0
.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.

+ 2 - 0
.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)。不保留下载端点或回退副本;两者都需要未来明确的产品决策。

+ 15 - 5
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<Array<{ path: string; content: string }>> => (await readFile(openLog, 'utf8')).split('\n').filter(Boolean).map(line => JSON.parse(line) as { path: string; content: string })
+  const opened = async (): Promise<Array<{ path: string; content: string | null; action: 'open' | 'reveal' }>> => (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)

+ 4 - 0
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

+ 2 - 2
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

+ 7 - 1
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.
 
 <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
 
@@ -754,6 +754,12 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<SessionInspectio
  */
 @Remote canOpenWorkspacePath(): boolean
 
+/**
+ * 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 }
+
 /**
  * Open one path prepared by a Session-aware caller on the Host desktop.
  * @param request - path after best-effort Session workspace resolution.

+ 7 - 1
docs/subsystems/session.zh.md

@@ -684,7 +684,7 @@ interface TurnEndReasonMap {
 
 `ModelCatalog` 是 `session/modelCatalog` 返回的 Host generation 模型目录:它携带部署默认值、可路由 provider id、成功的 provider 分组与相互隔离的 provider 失败。它不由某个 Session 派生,因此与 Session projection 分开保存。
 
-`SessionOpenWorkspacePathRequest` 携带绝对路径或已按 workspace 解析的 `path`。`SessionOpenWorkspacePathValue` 确认 Host 已接受原生交接。Session-aware Client 会在已知当前 Session cwd 时据此解析相对路径;controller 将路径原样交给打开器,并通过 Session Remote 错误词汇表报告无效请求、取消与打开器失败。
+`SessionOpenWorkspacePathRequest` 携带绝对路径或已按 workspace 解析的 `path`。`SessionOpenWorkspacePathValue` 确认 Host 已接受原生交接。Session-aware Client 会在已知当前 Session cwd 时据此解析相对路径;controller 将路径原样交给打开器,并通过 Session Remote 错误词汇表报告无效请求、取消与打开器失败。 可选的 `action: "reveal"` 选择文件管理器导航;省略时使用默认应用打开。
 
 <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
 
@@ -758,6 +758,12 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<SessionInspectio
  */
 @Remote canOpenWorkspacePath(): boolean
 
+/**
+ * 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 }
+
 /**
  * Open one path prepared by a Session-aware caller on the Host desktop.
  * @param request - path after best-effort Session workspace resolution.

+ 2 - 2
packages/api/session-controller/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/api/session-controller/README.md
-README.md: ef6ffb35636f9f1a87c3b30832f68540d5dcd0ea
-README.zh.md: 97949e01379446e6171debb9fb1250e8088413f8
+README.md: 24cfbba626ddec510452e3b6c8bc4333c03876bd
+README.zh.md: c90357c60eab9427f5656b48c4ea99252d87e2bf

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
packages/api/session-controller/README.md


Разлика између датотеке није приказан због своје велике величине
+ 0 - 1
packages/api/session-controller/README.zh.md


+ 17 - 2
packages/api/session-controller/src/index.ts

@@ -1,10 +1,11 @@
 /** Session Remote owner: cold reads, explicit Agent commands, and live control state. */
 
+import { hostname } from 'node:os'
 import { Context } from '@deepseek-ai/cordis'
 import z from '@deepseek-ai/schemastery'
 import { errorChain } from '@deepseek-ai/dsh-llm'
 import type {} from '@deepseek-ai/dsh-client-file-upload'
-import { canOpenNativePath, openNativePath } from '@deepseek-ai/dsh-native-command'
+import { canOpenNativePath, nativeFileManager, openNativePath, revealNativePath } from '@deepseek-ai/dsh-native-command'
 import type { SessionId } from '@deepseek-ai/dsh-session'
 import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence'
 import type { SessionObservation } from '@deepseek-ai/dsh-session-query'
@@ -76,6 +77,8 @@ export interface Config {
 export interface SessionControllerInternals {
   /** Native default-application handoff. */
   readonly openPath?: (path: string, signal: AbortSignal) => Promise<void>
+  /** Native file-manager handoff. */
+  readonly revealPath?: (path: string, signal: AbortSignal) => Promise<void>
   /** 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<void>
+  private readonly revealPath: (path: string, signal: AbortSignal) => Promise<void>
   private readonly canOpenPath: () => boolean
   private readonly promotions = new Set<Promise<void>>()
 
@@ -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', {})

+ 2 - 0
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
 }

+ 32 - 0
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() }
+})

+ 2 - 0
packages/api/session-controller/tests/test-remote.ts

@@ -91,6 +91,7 @@ export interface TestSessionRemoteDefaults {
   readonly nativeOpen?: boolean
   readonly saveDefaultModelSelection?: (selection: AgentModelSelection) => void | Promise<void>
   readonly openPath?: (path: string, signal: AbortSignal) => Promise<void>
+  readonly revealPath?: (path: string, signal: AbortSignal) => Promise<void>
   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 },
       },
     )

+ 2 - 2
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

+ 3 - 3
packages/client/ui-deliverables/README.md

@@ -30,9 +30,9 @@ Mount this plugin alongside `ui-conversation`; a finished turn then ends with th
 <a id="explicit-deliveries"></a>
 ### 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.
 
 </details>
 

+ 3 - 3
packages/client/ui-deliverables/README.zh.md

@@ -30,9 +30,9 @@ kind: "package-reference"
 <a id="explicit-deliveries"></a>
 ### 显式交付
 
-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 的工作区解析路径,检查当前文件存在且位于工作区内,再启动所选原生操作。同一份桌面可用性配置同时约束信息查询和实际执行。编辑会影响后续打开的内容;删除后返回错误。不创建文件内容副本或附件。插件释放时取消并等待进行中的原生打开请求。
 
 </details>
 

+ 19 - 9
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; } }

+ 23 - 20
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<ReturnType<PresentedOpenController['state']['getSnapshot']>> }
+  hooks: {
+    presentedOpen: ObservableSnapshot<ReturnType<PresentedOpenController['state']['getSnapshot']>>
+    presentedHost: ObservableSnapshot<ReturnType<PresentedOpenController['host']['getSnapshot']>>
+  }
+  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<TurnTailOwnerProps, 'openFile'> & {
+export function Deliverables({ matched, openFile, t, sessionId, openPresented, usePresentedOpen, usePresentedHost, reloadPresentedHost }: Pick<TurnTailOwnerProps, 'openFile'> & {
   matched: DeliverablesMatch
 } & PropsLocale<typeof NS> & Pick<SessionStandardProps, 'sessionId'> & InjectFace<DeliverablesInjected>) {
   const states = usePresentedOpen(value => value)
+  const host = usePresentedHost(value => value)
   return <>
     {matched.produced.length > 0 && <ProducedFiles matched={matched.produced} openFile={openFile} t={t} />}
     {matched.presented.length > 0 && <div className={css.root}>
-      <span className={css.label}>{t('presented.label')}</span>
+      <div className={css.label}>
+        <span>{t('presented.label')}</span>
+        {host !== null && host !== 'error' && <span>{t('presented.target')}</span>}
+      </div>
+      {host === 'error' && <div className={css.hostStatus}>
+        <span>{t('presented.hostError')}</span>
+        <Button size="sm" onClick={() => { void reloadPresentedHost() }}>{t('presented.retry')}</Button>
+      </div>}
+      {host !== null && host !== 'error' && !host.available && <span className={css.hostStatus}>{t('presented.unavailable')}</span>}
       <div className={css.presented} data-presented-files-row>
-        {matched.presented.map((file) => {
-          const phase = states[presentedFileUrl(sessionId, file.seq, file.index)]
-          return <button key={file.path} type="button" className={css.file}
-            disabled={phase === 'opening'}
-            onClick={() => { void openPresented(sessionId, file.seq, file.index) }}
-            title={t('presented.open', { name: file.path })} aria-label={t('presented.open', { name: file.path })}>
-            <LinkIcon kind={classifyLinkPath(file.path)} className={css.fileIcon} />
-            <span className={css.details}>
-              <span className={css.fileName}>{basename(file.path)}</span>
-              <span className={css.metadata}>{basename(file.path).match(/\.([^.]+)$/)?.[1]?.toUpperCase() ?? t('presented.file')}</span>
-              {file.description && <span className={css.description}>{file.description}</span>}
-              {phase !== undefined && <span className={css.description} role="status">{t(`presented.${phase}`)}</span>}
-            </span>
-            <span className={css.open}><IconRightUpOutline16 /><span>{t('presented.action')}</span></span>
-          </button>})}
+        {matched.presented.map(file => <PresentedFileCard key={file.path} file={file}
+          phase={states[presentedFileUrl(sessionId, file.seq, file.index)]}
+          host={host === 'error' ? null : host} t={t}
+          onAction={(action) => { void openPresented(sessionId, file.seq, file.index, action) }} />)}
       </div>
     </div>}
   </>

+ 71 - 0
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<typeof NS>) {
+  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) => <span className={css.menuLabel}>
+    <span>{title}</span><span className={css.menuDetail}>{detail}</span>
+  </span>
+  return <div className={css.file} data-presented-file>
+    <div className={css.heading}>
+      <span className={css.fileIcon}><LinkIcon kind={classifyLinkPath(file.path)} /></span>
+      <div className={css.details}>
+        <div className={css.nameRow}>
+          <span className={css.fileName}>{basename(file.path)}</span>
+          <span className={css.metadata}>{basename(file.path).match(/\.([^.]+)$/)?.[1]?.toUpperCase() ?? t('presented.file')}</span>
+        </div>
+        {file.description && <span className={css.description}>{file.description}</span>}
+      </div>
+    </div>
+    <div className={css.footer}>
+      <span className={css.path}><IconFolderOpenOutline16 /><span>{file.path}</span></span>
+      <div className={css.split}>
+        <Button segment="start" className={css.open} icon={<IconRightUpOutline16 />} disabled={disabled}
+          aria-label={t('presented.open', { name: file.path })} onClick={() => { act('open') }}>
+          {t('presented.action')}
+        </Button>
+        <Menu open={menuOpen && !disabled} autoFocus portal align="end" onClose={() => { setMenuOpen(false) }}
+          anchor={<Button segment="end" className={css.chevron} disabled={disabled}
+            aria-haspopup="menu" aria-expanded={menuOpen && !disabled}
+            aria-label={t('presented.more', { name: file.path })}
+            onClick={() => { setMenuOpen(value => !value) }}><IconChevronDownOutline14 /></Button>}
+          items={[
+            { type: 'label', id: 'host', text: t('presented.host', { name: host?.name ?? '' }) },
+            { id: 'open', icon: <IconRightUpOutline16 />,
+              label: description(t('presented.defaultApp'), t('presented.openDetail')) },
+            { type: 'separator', id: 'separator' },
+            { id: 'reveal', icon: <IconFolderOpenOutline16 />,
+              label: description(t(`presented.${reveal}`), t(reveal === 'directory' ? 'presented.directoryDetail' : 'presented.revealDetail')) },
+          ]}
+          onSelect={(id) => { act(id === 'reveal' ? 'reveal' : 'open') }} />
+      </div>
+    </div>
+    {phase !== undefined && <span className={css.description} role="status">
+      {t(phase === 'revealed' && reveal === 'directory' ? 'presented.directoryOpened' : `presented.${phase}`)}
+    </span>}
+  </div>
+}

+ 4 - 2
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),
   )

+ 34 - 0
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<DeliverablesKey, string> = {
   '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',

+ 46 - 11
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<Record<string, PresentedOpenPhase | undefined>>({})
+  /** Native destination metadata, or a retryable read failure. */
+  readonly host = createSnapshotStore<PresentedHost | 'error' | null>(null)
+  private loading: Promise<void> | undefined
   private readonly lifetime = new AbortController()
   private readonly pending = new Set<Promise<void>>()
 
@@ -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<void> {
+  async open(sessionId: SessionId, seq: number, index: number, action: PresentedAction = 'open'): Promise<void> {
     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<void> {
+    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<void> {
+    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<void> {
     this.lifetime.abort()
     await Promise.all(this.pending)
   }
 
-  private async request(url: string): Promise<void> {
-    let phase: PresentedOpenPhase = 'opened'
+  private async request(url: string, action: PresentedAction): Promise<void> {
+    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 })
   }

+ 10 - 2
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<Promise<Response>>()
   ctx.effect(() => async () => {
@@ -36,6 +41,8 @@ export function registerPresentOpen(ctx: Context): void {
 
 async function handlePresentOpen(ctx: Context, request: Request): Promise<Response> {
   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<Respon
   }
   try {
     request.signal.throwIfAborted()
+    if (!ctx.sessionController.workspaceDesktop().available) return new Response('Host desktop unavailable.', { status: 409 })
     const { session, target } = await ctx.sessionQuery.readEvent({
       sessionId: id as SessionId, seq: Number(seq) as SessionSeq, before: 0, after: 0,
     }, request.signal)
@@ -59,7 +67,7 @@ async function handlePresentOpen(ctx: Context, request: Request): Promise<Respon
     }
     if (!(await stat(path)).isFile()) return new Response('Presented path is not a file.', { status: 404 })
     request.signal.throwIfAborted()
-    await ctx.sessionController.openWorkspacePath({ path }, request.signal)
+    await ctx.sessionController.openWorkspacePath({ path, ...(action === 'reveal' ? { action } : {}) }, request.signal)
     return new Response(null, { status: 204, headers: { 'cache-control': 'no-store' } })
   } catch (error: unknown) {
     request.signal.throwIfAborted()

+ 26 - 0
packages/client/ui-deliverables/src/presented.ts

@@ -6,6 +6,32 @@ import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand'
 /** Authenticated POST route for opening a workspace file on the Host desktop. */
 export const PRESENT_OPEN_PATH = '/api/present.open'
 
+/** Authenticated desktop availability and destination metadata. */
+export const PRESENT_HOST_PATH = '/api/present.host'
+
+/** Native file action selected by an explicit user gesture. */
+export type PresentedAction = 'open' | 'reveal'
+
+/** Serving Host information; file-manager names never derive from the browser's OS. */
+export interface PresentedHost {
+  name: string
+  available: boolean
+  fileManager: 'finder' | 'explorer' | 'directory' | null
+}
+
+/**
+ * Validate desktop metadata received over HTTP.
+ * @param value - decoded response.
+ * @returns whether all displayed and actionable fields are supported.
+ */
+export function isPresentedHost(value: unknown): value is PresentedHost {
+  if (typeof value !== 'object' || value === null) return false
+  const host = value as Record<string, unknown>
+  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.

+ 55 - 0
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<Response>()
+  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<Response>()
+  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()
+})

+ 25 - 2
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()
+})

+ 95 - 0
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(<PresentedFileCard {...p} host={{ ...p.host, fileManager }} />)
+  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(<PresentedFileCard {...p} />)
+  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(<PresentedFileCard {...p} phase={phase} />)
+  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(<PresentedFileCard {...p} host={null} />)
+  expect(view.getAllByRole('button').every(button => (button as HTMLButtonElement).disabled)).toBe(true)
+  view.rerender(<PresentedFileCard {...p} host={{ ...p.host, available: false, fileManager: null }} />)
+  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(<PresentedFileCard {...p} phase="revealError" t={makeTranslate(zh)} />)
+  expect(view.getByRole('status').textContent).toBe(zh['presented.revealError'])
+  view.rerender(<PresentedFileCard {...p} phase="revealed" host={{ ...p.host, fileManager: 'directory' }} />)
+  expect(view.getByRole('status').textContent).toBe(en['presented.directoryOpened'])
+  view.rerender(<PresentedFileCard {...p} phase="revealed" />)
+  expect(view.getByRole('status').textContent).toBe(en['presented.revealed'])
+})
+
+
+it('supports keyboard selection and returns focus to the trigger on Escape', () => {
+  const view = render(<PresentedFileCard {...props()} />)
+  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)
+})

+ 24 - 4
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: <T,>(select: (state: ReturnType<typeof controller.host.getSnapshot>) => T): T =>
+      select(controller.host.getSnapshot()),
     openPresented: vi.fn((...args: Parameters<PresentedOpenController['open']>) => controller.open(...args)),
     usePresentedOpen: <T,>(select: (state: ReturnType<typeof controller.state.getSnapshot>) => 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(<Deliverables {...props} matched={matched} openFile={owner.openFile} sessionId={SessionId('child-session')} t={makeTranslate(en)} />)
-    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(<Deliverables {...props} matched={matched} openFile={() => {}} 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(<Deliverables {...props} matched={matched} openFile={() => {}} sessionId={SessionId('session')} t={makeTranslate(en)} />)
+  expect(view.getByText(en['presented.unavailable'])).toBeTruthy()
 })

+ 2 - 2
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

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
packages/client/ui-primitives/README.md


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
packages/client/ui-primitives/README.zh.md


+ 3 - 0
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; }

+ 4 - 2
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<HTMLButtonElement>) {
   return (
-    <button type="button" className={clsx(css.button, css[variant], css[size], className)} {...rest}>
+    <button type="button" className={clsx(css.button, css[variant], css[size], segment && css[segment], className)} {...rest}>
       {icon != null && <span className={css.icon}>{icon}</span>}
       {children}
     </button>

+ 20 - 3
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<HTMLButtonElement>('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<HTMLButtonElement>('button')?.focus()
+      }
+      if (!autoFocus || !['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) return
+      const buttons = Array.from(listRef.current?.querySelectorAll<HTMLButtonElement>('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

+ 7 - 1
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<SessionOpenWorkspacePathValue>',
         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',

+ 2 - 2
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

+ 2 - 0
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.
+
 -----
 
 <a id="understand-the-implementation"></a>

+ 2 - 0
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()` 检查决定。调用方必须先授权绝对文件路径,再执行操作。平台分派由注入运行器的测试覆盖;原生桌面验证由对应平台负责。
+
 -----
 
 <a id="understand-the-implementation"></a>

+ 3 - 0
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'

+ 52 - 1
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<void> {
   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<void> {
+  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}`)
+}

+ 53 - 1
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<PathOpenerRunner>(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<PathOpenerRunner>(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<PathOpenerRunner>(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<PathOpenerRunner>().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()
+})

+ 12 - 5
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":

Неке датотеке нису приказане због велике количине промена