소스 검색

fix(web): preserve artifact actions across reconnects and keyboard use

creatixchu 5 일 전
부모
커밋
74bf7aaa2f
23개의 변경된 파일190개의 추가작업 그리고 33개의 파일을 삭제
  1. 2 2
      .agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml
  2. 1 1
      .agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md
  3. 1 1
      .agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md
  4. 2 0
      apps/web/tests/present.e2e.ts
  5. 2 2
      packages/client/ui-deliverables/README.i18n.yaml
  6. 1 1
      packages/client/ui-deliverables/README.md
  7. 1 1
      packages/client/ui-deliverables/README.zh.md
  8. 12 4
      packages/client/ui-deliverables/src/client/PresentedFileCard.tsx
  9. 2 0
      packages/client/ui-deliverables/src/client/index.ts
  10. 8 4
      packages/client/ui-deliverables/src/client/locales.ts
  11. 20 6
      packages/client/ui-deliverables/src/client/present-open.ts
  12. 36 0
      packages/client/ui-deliverables/tests/present-open.client.spec.ts
  13. 36 0
      packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx
  14. 11 0
      packages/client/ui-deliverables/tests/produced-files.client.spec.tsx
  15. 2 2
      packages/client/ui-primitives/README.i18n.yaml
  16. 0 0
      packages/client/ui-primitives/README.md
  17. 0 0
      packages/client/ui-primitives/README.zh.md
  18. 1 1
      packages/client/ui-primitives/src/Menu.tsx
  19. 2 2
      packages/util/native-command/README.i18n.yaml
  20. 1 1
      packages/util/native-command/README.md
  21. 1 1
      packages/util/native-command/README.zh.md
  22. 11 2
      packages/util/native-command/src/path-opener.ts
  23. 37 2
      packages/util/native-command/tests/path-opener.spec.ts

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

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

+ 1 - 1
.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 的桌面信息使远程浏览器中的文案和可用性保持准确,路由在每次原生操作时执行配置的可用性检查。单个交付占满整行;多个交付每行最多两列,并保留所有声明,前四张卡片之后的内容在用户展开列表前保持收起。 桌面元数据随连接代次失效,避免旧主机信息让原生操作持续禁用或显示错误的文件管理器名称。旧元数据请求会被取消,不能覆盖新代次的响应。
 
 ## 考虑过的替代方案
 

+ 2 - 0
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) {

+ 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: 87d4b1acbafbab3d1c35a14688671b9ae974c32c
-README.zh.md: 9ad0b8102735448836445fdc80b2371ef539d241
+README.md: 9faea88696f3d6a7d39429e82449978647330a5f
+README.zh.md: 8b2c846dffe674d2112f9b7680040a762d8b0c60

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

+ 1 - 1
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 必须具备桌面和合适的默认应用;远程浏览器不会打开其所在设备上的应用。
 
 ### 该行
 

+ 12 - 4
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<typeof NS>) {
   const [menuOpen, setMenuOpen] = useState(false)
+  const previewRef = useRef<HTMLButtonElement>(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 <div className={css.file} data-presented-file>
     <button type="button" className={css.cardPreview} title={resolveWorkspacePath(cwd, file.path)}
       aria-label={t('presented.previewCard', { name: file.path })} onClick={onPreview} />
@@ -54,7 +62,7 @@ export function PresentedFileCard({ file, cwd, phase, host, onPreview, onAction,
         </span>
       </div>
       <div className={css.split}>
-        <button type="button" className={css.open}
+        <button ref={previewRef} type="button" className={css.open}
           aria-label={t('presented.previewButton', { name: file.path })}
           onClick={onPreview}>{t('presented.action')}</button>
         <Menu className={css.menuAnchor} open={menuOpen && !menuDisabled} autoFocus portal align="end" onClose={() => { setMenuOpen(false) }}

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

+ 8 - 4
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<DeliverablesKey, string> = {
-  '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',

+ 20 - 6
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<PresentedHost | 'error' | null>(null)
   private loading: Promise<void> | undefined
+  private metadata = new AbortController()
   private readonly lifetime = new AbortController()
   private readonly pending = new Set<Promise<void>>()
 
@@ -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)
+    }
+  }
+
+  /** 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(): Promise<void> {
+  private async readHost(signal: AbortSignal): Promise<void> {
     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. */

+ 36 - 0
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<Response>()
+  const newReply = Promise.withResolvers<Response>()
+  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()
+})

+ 36 - 0
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(<PresentedFileCard {...p} />)
+  fireEvent.click(view.getByRole('button', { name: 'More file actions for out/report.pdf' }))
+  expect(view.getByRole('menu')).toBeTruthy()
+  view.rerender(<PresentedFileCard {...p} phase="opening" />)
+  expect(view.queryByRole('menu')).toBeNull()
+  view.rerender(<PresentedFileCard {...p} phase="opened" />)
+  expect(view.queryByRole('menu')).toBeNull()
+})
+
+it('keeps focus on the available preview button after selecting a native action', () => {
+  const p = props()
+  const view = render(<PresentedFileCard {...p} />)
+  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(<PresentedFileCard {...p} phase="revealing" />)
+  const preview = view.getByRole('button', { name: 'Open out/report.pdf in sidebar' })
+  expect(document.activeElement).toBe(preview)
+  view.rerender(<PresentedFileCard {...p} phase="revealed" />)
+  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(<PresentedFileCard {...p} phase="revealing" />)
+  expect(view.getByText(dictionary['presented.revealing'])).toBeTruthy()
+  view.rerender(<PresentedFileCard {...p} phase="revealing" host={{ ...p.host, fileManager: 'directory' }} />)
+  expect(view.getByText(dictionary['presented.directoryOpening'])).toBeTruthy()
+  view.rerender(<PresentedFileCard {...p} phase="revealError" host={{ ...p.host, fileManager: 'directory' }} />)
+  expect(view.getByText(dictionary['presented.directoryError'])).toBeTruthy()
+})

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

+ 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: 194fc2a3ac97a8b40bb6c385af3b4ee6b3930b52
-README.zh.md: e2dcea7a7c03556fef789db21cef8187c8b72d66
+README.md: 1c3bb7a7d36884234340b29da9f8763e46796e61
+README.zh.md: a8bc94f4afeb5c13d95ccafea4ef1b926524abd0

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
packages/client/ui-primitives/README.md


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
packages/client/ui-primitives/README.zh.md


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

+ 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: b127c1f03fc91282cd2a847af84f8183f147f9ac
-README.zh.md: 7ff612fe9bead184ace2b5ee2c54700c8d673df0
+README.md: b0b1963a97c6d528c123d4900db529bd3dc38332
+README.zh.md: 6a245e3473a4e4dd9944dc53898e04be2fcba2e4

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

+ 1 - 1
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 按已转交请求处理;取消、找不到可执行文件和其他退出码仍然报错。该确认不能证明桌面窗口已选中文件。
 
 -----
 

+ 11 - 2
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') {

+ 37 - 2
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<PathOpenerRunner>(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<PathOpenerRunner>(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<PathOpenerRunner>().mockResolvedValue({ stdout: '', stderr: '' })
+  await revealNativePath(path, signal(), { platform: 'win32', run })
+  expect(run).toHaveBeenCalledWith('explorer.exe', ['/select,', target], expect.any(AbortSignal))
+})

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.