From 74bf7aaa2fdd56c419e46292215d15d1a3efee63 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 9 Sep 2026 16:18:24 +0800 Subject: [PATCH] fix(web): preserve artifact actions across reconnects and keyboard use --- ...8-present-workspace-source-files.i18n.yaml | 4 +- ...26-09-08-present-workspace-source-files.md | 2 +- ...09-08-present-workspace-source-files.zh.md | 2 +- apps/web/tests/present.e2e.ts | 2 + .../client/ui-deliverables/README.i18n.yaml | 4 +- packages/client/ui-deliverables/README.md | 2 +- packages/client/ui-deliverables/README.zh.md | 2 +- .../src/client/PresentedFileCard.tsx | 16 ++++++-- .../ui-deliverables/src/client/index.ts | 2 + .../ui-deliverables/src/client/locales.ts | 12 ++++-- .../src/client/present-open.ts | 26 ++++++++++--- .../tests/present-open.client.spec.ts | 36 +++++++++++++++++ .../tests/presented-file-card.client.spec.tsx | 36 +++++++++++++++++ .../tests/produced-files.client.spec.tsx | 11 ++++++ .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- packages/client/ui-primitives/src/Menu.tsx | 2 +- packages/util/native-command/README.i18n.yaml | 4 +- packages/util/native-command/README.md | 2 +- packages/util/native-command/README.zh.md | 2 +- .../util/native-command/src/path-opener.ts | 13 ++++++- .../native-command/tests/path-opener.spec.ts | 39 ++++++++++++++++++- 23 files changed, 192 insertions(+), 35 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml index 3c1e280e32..715341ab78 100644 --- a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md -2026-09-08-present-workspace-source-files.md: 0dade8823d1385a28b7d9fe6085895d64dd9e898 -2026-09-08-present-workspace-source-files.zh.md: d849b62870a42ad3643e8909f70bf9c29f9115fa +2026-09-08-present-workspace-source-files.md: b8956b17a20090847da6de3c301254a73701e6f2 +2026-09-08-present-workspace-source-files.zh.md: 2e82f4e27a99f484005243a4769f3aadad290666 diff --git a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md index 0dade8823d..b8956b17a2 100644 --- a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md +++ b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md @@ -18,7 +18,7 @@ The tool remains an ordinary package with shared filesystem and tool error class An authenticated POST selects a declaration by viewed Session, event sequence, and original file index. The event carries no owning Session ID; relative paths in inherited history resolve against the viewed Session's workspace. The Host rechecks canonical workspace containment and regular-file existence before native opening. Route disposal cancels and awaits pending commands. The “Files changed” row lists successful file-tool mutations and retains its separate text-preview behavior. Its Chinese label is “本轮文件改动”; neither label implies final delivery. -File cards use the same split-control pattern as the Session header. The card and the left Open segment preview the source in the right Sidebar; the chevron opens the standard menu for default-app and file-manager actions. The Host selects the file in Finder or Explorer, or opens its containing folder through the default Linux file manager. Both native actions resolve the same saved declaration and recheck workspace containment; neither accepts a browser-supplied replacement path. Host-derived desktop metadata keeps remote-browser labels and availability honest, and the route enforces the configured availability on each native gesture. One delivery spans the row; multiple deliveries use at most two columns, retain every declaration, and collapse after the first four cards until the user expands the list. +File cards use the same split-control pattern as the Session header. The card and the left Open segment preview the source in the right Sidebar; the chevron opens the standard menu for default-app and file-manager actions. The Host selects the file in Finder or Explorer, or opens its containing folder through the default Linux file manager. Both native actions resolve the same saved declaration and recheck workspace containment; neither accepts a browser-supplied replacement path. Host-derived desktop metadata keeps remote-browser labels and availability honest, and the route enforces the configured availability on each native gesture. One delivery spans the row; multiple deliveries use at most two columns, retain every declaration, and collapse after the first four cards until the user expands the list. Desktop metadata is invalidated with the connection generation so an old Host cannot keep native actions disabled or supply the wrong file-manager labels. Old metadata requests are cancelled and cannot replace the new generation’s response. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md index d849b62870..2e82f4e27a 100644 --- a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md +++ b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md @@ -18,7 +18,7 @@ Status: implemented 经过认证的 POST 按当前查看的 Session、事件序号和原始文件索引选择声明。事件不携带所属 Session ID;继承历史中的相对路径按当前查看的 Session 工作区解析。Host 在原生打开前重新检查规范路径的工作区包含关系和普通文件是否存在。路由释放时取消并等待进行中的命令。“本轮文件改动”行列出成功的文件工具修改,并保留独立的文本预览行为。其英文标签为“Files changed”;两个标签均不表示最终交付。 -文件卡片采用与 Session 顶栏相同的分段控件。点击卡片或左侧“打开”区域会在右侧 Sidebar 预览源文件;右侧箭头打开包含默认应用与文件管理器操作的标准菜单。Host 在 Finder 或文件资源管理器中选中文件,或通过 Linux 默认文件管理器打开所在文件夹。两个原生操作都解析同一份已保存声明并重新检查工作区包含关系;均不接受浏览器提供的替代路径。来自 Host 的桌面信息使远程浏览器中的文案和可用性保持准确,路由在每次原生操作时执行配置的可用性检查。单个交付占满整行;多个交付每行最多两列,并保留所有声明,前四张卡片之后的内容在用户展开列表前保持收起。 +文件卡片采用与 Session 顶栏相同的分段控件。点击卡片或左侧“打开”区域会在右侧 Sidebar 预览源文件;右侧箭头打开包含默认应用与文件管理器操作的标准菜单。Host 在 Finder 或文件资源管理器中选中文件,或通过 Linux 默认文件管理器打开所在文件夹。两个原生操作都解析同一份已保存声明并重新检查工作区包含关系;均不接受浏览器提供的替代路径。来自 Host 的桌面信息使远程浏览器中的文案和可用性保持准确,路由在每次原生操作时执行配置的可用性检查。单个交付占满整行;多个交付每行最多两列,并保留所有声明,前四张卡片之后的内容在用户展开列表前保持收起。 桌面元数据随连接代次失效,避免旧主机信息让原生操作持续禁用或显示错误的文件管理器名称。旧元数据请求会被取消,不能覆盖新代次的响应。 ## 考虑过的替代方案 diff --git a/apps/web/tests/present.e2e.ts b/apps/web/tests/present.e2e.ts index 7b2f110f76..d1b4bc713c 100644 --- a/apps/web/tests/present.e2e.ts +++ b/apps/web/tests/present.e2e.ts @@ -128,6 +128,8 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con const revealResponse = page.waitForResponse(response => response.url().includes('action=reveal') && response.request().method() === 'POST') await page.getByRole('menuitem', { name: process.platform === 'darwin' ? /Show in Finder/ : /Open containing folder/ }).click() expect((await revealResponse).status()).toBe(204) + expect(await row.getByRole('button', { name: 'Open report.txt in sidebar', exact: true }) + .evaluate(button => button === document.activeElement)).toBe(true) await expect.poll(opened).toHaveLength(beforeReveal + 1) expect((await opened()).at(-1)).toEqual({ action: 'reveal', content: null, path: await realpath(process.platform === 'darwin' ? join(cwd, 'report.txt') : cwd) }) for (const [name, bytes] of [['report.txt', 'EDITED_REPORT\n'], ['说明.txt', 'EDITED_NOTE\n']] as const) { diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index d5c876bb0c..ab6fce907a 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-deliverables/README.md -README.md: 87d4b1acbafbab3d1c35a14688671b9ae974c32c -README.zh.md: 9ad0b8102735448836445fdc80b2371ef539d241 +README.md: 9faea88696f3d6a7d39429e82449978647330a5f +README.zh.md: 8b2c846dffe674d2112f9b7680040a762d8b0c60 diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index 87d4b1acba..9faea88696 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -32,7 +32,7 @@ Mount this plugin alongside `ui-conversation`; a finished turn then ends with th The Web `standard`, `ptc`, and `cordis` presets expose `present` for final workspace files, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../fs/tool-present/README.md) owns file-count limits and Session declarations. The closing turn shows one delivery as a full-width card and multiple deliveries in a grid of at most two cards per row. A list longer than four files starts collapsed and provides a control that reveals or hides the complete list. Each card shows the basename and description, or the file type when no description exists; a trailing parenthesized suffix in the description is omitted, and hovering the card replaces that line with the Sidebar-preview action. Clicking the card or the left side of its split Open control previews the file in the right Sidebar. The chevron opens the standard menu for the Host default application plus Show in Finder on macOS, Show in File Explorer on Windows and WSL, or Open containing folder through the default Linux file manager. Matching inline-code references open the same source files without starting a browser download. Repeated declaration of a path selects its latest description before the closing reply. -The `present` tool row shows running, delivered, failed, or interrupted status; expanding a settled row reveals its recorded result. The collapsible card grid retains every delivered file. Both menu actions share pending state and show progress, acknowledgement, or an action-specific retryable error. Desktop information is read when delivery cards appear. A missing desktop disables the Open menu; a failed desktop-information read offers Retry. It requires a desktop and a suitable default application on the serving Host; a remote browser does not open applications on its own device. +The `present` tool row shows running, delivered, failed, or interrupted status; expanding a settled row reveals its recorded result. The collapsible card grid retains every delivered file. Both menu actions share pending state and show progress, acknowledgement, or an action-specific retryable error. Desktop information is read when delivery cards appear and invalidated on connection replacement; responses from a replaced connection cannot publish metadata. Selecting a native menu action returns keyboard focus to the available Sidebar Open button. Pending actions close the menu until another explicit gesture. A missing desktop disables the Open menu; a failed desktop-information read offers Retry. It requires a desktop and a suitable default application on the serving Host; a remote browser does not open applications on its own device. ### The row diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 9ad0b81027..8b2c846dff 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -32,7 +32,7 @@ kind: "package-reference" Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交付最终工作区文件,包括通过 Bash 创建的文件。创建文件后,以 `files: [{ path, description? }]` 调用。[present 工具](../../fs/tool-present/README.zh.md)拥有文件数量限制和 Session 声明。收尾 turn 把单个交付显示为横向占满内容区的卡片,把多个交付显示为每行最多两张卡片的网格。文件超过四个时,列表默认收起,并提供显示或隐藏完整列表的控件。每张卡片显示 basename 与说明;没有说明时显示文件类型,说明末尾的括号后缀会被省略,悬停卡片时该行切换为侧栏预览提示。点击卡片或分段“打开”控件的左侧会在右侧 Sidebar 中预览文件;右侧箭头打开标准菜单,其中提供 Host 默认应用,以及 macOS 上的“在 Finder 中显示”、Windows 和 WSL 上的“在文件资源管理器中显示”或 Linux 默认文件管理器的“打开所在文件夹”。匹配的行内代码引用打开相同源文件,不触发浏览器下载。同一路径重复声明时,选择收尾回复之前最近一次的说明。 -`present` 工具行显示正在交付、已交付、失败或中断状态;展开已结束的调用可查看其记录的结果。可折叠卡片网格保留全部交付文件。菜单中的两个操作共享等待状态,并显示进度、请求确认或各自可重试的错误。交付卡片出现时才读取桌面信息。Host 没有桌面时禁用“打开”菜单;桌面信息读取失败时提供“重试”。服务 Host 必须具备桌面和合适的默认应用;远程浏览器不会打开其所在设备上的应用。 +`present` 工具行显示正在交付、已交付、失败或中断状态;展开已结束的调用可查看其记录的结果。可折叠卡片网格保留全部交付文件。菜单中的两个操作共享等待状态,并显示进度、请求确认或各自可重试的错误。交付卡片出现时读取桌面信息,连接更换时清除缓存,旧连接的响应不能更新元数据。选择原生菜单操作后,键盘焦点回到仍可用的侧边栏“打开”按钮。等待操作完成时关闭菜单,用户再次点击才会打开。Host 没有桌面时禁用“打开”菜单;桌面信息读取失败时提供“重试”。服务 Host 必须具备桌面和合适的默认应用;远程浏览器不会打开其所在设备上的应用。 ### 该行 diff --git a/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx index 2ca532182f..73e29f5523 100644 --- a/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx +++ b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx @@ -1,5 +1,5 @@ /** File identity and explicit default-app or file-manager actions for one delivery. */ -import { useState } from 'react' +import { useRef, useState } from 'react' import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path' import { Menu, LinkIcon, classifyLinkPath, IconRightUpOutline16, @@ -31,15 +31,23 @@ export function PresentedFileCard({ file, cwd, phase, host, onPreview, onAction, onAction: (action: PresentedAction) => void } & PropsLocale) { const [menuOpen, setMenuOpen] = useState(false) + const previewRef = useRef(null) const pending = phase === 'opening' || phase === 'revealing' const menuDisabled = pending || host === null || !host.available + if (menuDisabled && menuOpen) setMenuOpen(false) const reveal = host?.fileManager ?? 'directory' - const act = (action: PresentedAction) => { setMenuOpen(false); onAction(action) } + const act = (action: PresentedAction) => { + setMenuOpen(false) + previewRef.current?.focus() + onAction(action) + } const name = basename(file.path) const metadata = name.match(/\.([^.]+)$/)?.[1]?.toUpperCase() ?? t('presented.file') const status = phase === undefined ? cardDescription(file.description, metadata) - : t(phase === 'revealed' && reveal === 'directory' ? 'presented.directoryOpened' : `presented.${phase}`) + : t(reveal === 'directory' && phase === 'revealed' ? 'presented.directoryOpened' + : reveal === 'directory' && phase === 'revealing' ? 'presented.directoryOpening' + : reveal === 'directory' && phase === 'revealError' ? 'presented.directoryError' : `presented.${phase}`) return
- { setMenuOpen(false) }} diff --git a/packages/client/ui-deliverables/src/client/index.ts b/packages/client/ui-deliverables/src/client/index.ts index 2a324c0be4..7ce723aa09 100644 --- a/packages/client/ui-deliverables/src/client/index.ts +++ b/packages/client/ui-deliverables/src/client/index.ts @@ -9,6 +9,7 @@ */ import type { Context as ClientContext } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-api-remotes/client' +import type {} from '@deepseek-ai/dsh-client-connection/client' import type { ChatFileMentions } from '@deepseek-ai/dsh-client-ui-chat/client' import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -41,6 +42,7 @@ export const inject = ['slots', 'locale', 'uiConversation', 'remote', 'remote.se export function apply(ctx: ClientContext): void { const opener = new PresentedOpenController() ctx.effect(() => () => opener.dispose()) + ctx.on('connection/reset', () => { opener.resetHost() }) ctx.uiConversation.events.register(deliverablesDefinition) ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-deliverables: dictionaries') ctx.slots.inject( diff --git a/packages/client/ui-deliverables/src/client/locales.ts b/packages/client/ui-deliverables/src/client/locales.ts index de5310bbf0..5b9fa7781d 100644 --- a/packages/client/ui-deliverables/src/client/locales.ts +++ b/packages/client/ui-deliverables/src/client/locales.ts @@ -5,10 +5,12 @@ export const NS = 'deliverables' /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { - 'presented.revealError': '无法打开所在文件夹,请重试', + 'presented.revealError': '无法在文件管理器中显示,请重试', + 'presented.directoryError': '无法打开所在文件夹,请重试', + 'presented.directoryOpening': '正在打开所在文件夹…', 'presented.directoryOpened': '已请求打开所在文件夹', 'presented.revealed': '已请求在文件管理器中显示', - 'presented.revealing': '正在打开所在文件夹…', + 'presented.revealing': '正在文件管理器中显示…', 'presented.unavailable': '此主机没有可用的桌面,无法打开文件或文件夹', 'presented.retry': '重试', 'presented.hostError': '无法读取主机桌面信息', @@ -44,10 +46,12 @@ export const zh = { /** English dictionary (same key set). */ export const en: Record = { - 'presented.revealError': 'Could not open containing folder. Try again.', + 'presented.revealError': 'Could not show in file manager. Try again.', + 'presented.directoryError': 'Could not open containing folder. Try again.', + 'presented.directoryOpening': 'Opening containing folder…', 'presented.directoryOpened': 'Requested opening containing folder', 'presented.revealed': 'Requested display in file manager', - 'presented.revealing': 'Opening containing folder…', + 'presented.revealing': 'Showing in file manager…', 'presented.unavailable': 'This Host has no desktop available to open files or folders', 'presented.retry': 'Retry', 'presented.hostError': 'Could not read the Host desktop information', diff --git a/packages/client/ui-deliverables/src/client/present-open.ts b/packages/client/ui-deliverables/src/client/present-open.ts index 8d74c1615f..bb47634011 100644 --- a/packages/client/ui-deliverables/src/client/present-open.ts +++ b/packages/client/ui-deliverables/src/client/present-open.ts @@ -13,6 +13,7 @@ export class PresentedOpenController { /** Native destination metadata, or a retryable read failure. */ readonly host = createSnapshotStore(null) private loading: Promise | undefined + private metadata = new AbortController() private readonly lifetime = new AbortController() private readonly pending = new Set>() @@ -47,25 +48,38 @@ export class PresentedOpenController { if (this.lifetime.signal.aborted) return if (this.loading !== undefined) return this.loading this.host.set(null) - const task = this.readHost() + const task = this.readHost(AbortSignal.any([this.lifetime.signal, this.metadata.signal])) this.loading = task this.pending.add(task) try { await task } - finally { this.loading = undefined; this.pending.delete(task) } + finally { + if (this.loading === task) this.loading = undefined + this.pending.delete(task) + } } - private async readHost(): Promise { + /** Invalidate desktop metadata on connection replacement; mounted cards request the new Host. */ + resetHost(): void { + const wasLoading = this.loading !== undefined + this.metadata.abort() + this.metadata = new AbortController() + this.loading = undefined + this.host.set(null) + if (wasLoading) void this.loadHost() + } + + private async readHost(signal: AbortSignal): Promise { let host: PresentedHost | 'error' = 'error' try { - const response = await fetch(PRESENT_HOST_PATH, { signal: this.lifetime.signal }) + const response = await fetch(PRESENT_HOST_PATH, { signal }) if (response.ok) { const value: unknown = await response.json() if (isPresentedHost(value)) host = value } } catch { - // HTTP, JSON, and transport failures leave a retryable metadata read. + host = 'error' } - if (!this.lifetime.signal.aborted) this.host.set(host) + if (!signal.aborted) this.host.set(host) } /** Cancel outstanding requests and wait until no request can publish state. */ diff --git a/packages/client/ui-deliverables/tests/present-open.client.spec.ts b/packages/client/ui-deliverables/tests/present-open.client.spec.ts index aa022adf4c..bdcaaab3b9 100644 --- a/packages/client/ui-deliverables/tests/present-open.client.spec.ts +++ b/packages/client/ui-deliverables/tests/present-open.client.spec.ts @@ -116,3 +116,39 @@ it('coalesces metadata reads and suppresses their publication after disposal', a await Promise.all([first, second, disposal]) expect(controller.host.getSnapshot()).toBeNull() }) + + +it('invalidates cached desktop metadata without eagerly fetching an unused Host', async () => { + const fetcher = vi.fn().mockResolvedValue(Response.json({ name: 'old', available: false, fileManager: null })) + vi.stubGlobal('fetch', fetcher) + const controller = new PresentedOpenController() + await controller.loadHost() + controller.resetHost() + expect(controller.host.getSnapshot()).toBeNull() + expect(fetcher).toHaveBeenCalledOnce() + fetcher.mockResolvedValue(Response.json({ name: 'new', available: true, fileManager: 'finder' })) + await controller.loadHost() + expect(controller.host.getSnapshot()).toMatchObject({ name: 'new', available: true }) + await controller.dispose() +}) + +it('discards a replaced Host response and keeps the new metadata request coalesced', async () => { + const oldReply = Promise.withResolvers() + const newReply = Promise.withResolvers() + const fetcher = vi.fn().mockReturnValueOnce(oldReply.promise).mockReturnValue(newReply.promise) + vi.stubGlobal('fetch', fetcher) + const controller = new PresentedOpenController() + const oldLoad = controller.loadHost() + controller.resetHost() + expect((fetcher.mock.calls[0]?.[1] as RequestInit).signal?.aborted).toBe(true) + const newLoad = controller.loadHost() + oldReply.resolve(Response.json({ name: 'old', available: false, fileManager: null })) + await oldLoad + expect(controller.host.getSnapshot()).toBeNull() + const coalesced = controller.loadHost() + expect(fetcher).toHaveBeenCalledTimes(2) + newReply.resolve(Response.json({ name: 'new', available: true, fileManager: 'finder' })) + await Promise.all([newLoad, coalesced]) + expect(controller.host.getSnapshot()).toMatchObject({ name: 'new' }) + await controller.dispose() +}) diff --git a/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx b/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx index 0f060643b4..28207b5a29 100644 --- a/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx @@ -128,3 +128,39 @@ it.each([ expect(view.getByText(expected)).toBeTruthy() expect(view.queryByText(description)).toBeNull() }) + + +it('does not reopen a menu after a shared native request settles', () => { + const p = props() + const view = render() + fireEvent.click(view.getByRole('button', { name: 'More file actions for out/report.pdf' })) + expect(view.getByRole('menu')).toBeTruthy() + view.rerender() + expect(view.queryByRole('menu')).toBeNull() + view.rerender() + expect(view.queryByRole('menu')).toBeNull() +}) + +it('keeps focus on the available preview button after selecting a native action', () => { + const p = props() + const view = render() + fireEvent.click(view.getByRole('button', { name: 'More file actions for out/report.pdf' })) + const item = view.getByRole('menuitem', { name: 'Show in Finder' }) + item.focus() + fireEvent.click(item) + view.rerender() + const preview = view.getByRole('button', { name: 'Open out/report.pdf in sidebar' }) + expect(document.activeElement).toBe(preview) + view.rerender() + expect(document.activeElement).toBe(preview) +}) + +it.each([en, zh])('distinguishes directory-only progress and errors in each locale', (dictionary) => { + const p = { ...props(), t: makeTranslate(dictionary) } + const view = render() + expect(view.getByText(dictionary['presented.revealing'])).toBeTruthy() + view.rerender() + expect(view.getByText(dictionary['presented.directoryOpening'])).toBeTruthy() + view.rerender() + expect(view.getByText(dictionary['presented.directoryError'])).toBeTruthy() +}) diff --git a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx index 3c72544468..4b7ae935b0 100644 --- a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx @@ -555,13 +555,24 @@ describe('plugin registration', () => { service?.forClosing(delivered, SessionId('child-session'))?.resolve('report.docx')?.open() expect(fetcher).toHaveBeenCalledWith('/api/present.open?sessionId=child-session&seq=2&index=0', { method: 'POST', signal: expect.any(AbortSignal) as AbortSignal }) const face = entry!.inject!(SessionId('child-session') as never) as unknown as DeliverablesInjected + fetcher.mockResolvedValueOnce(Response.json({ name: 'desktop', available: true, fileManager: 'finder' })) await face.reloadPresentedHost() + expect(face.hooks.presentedHost.getSnapshot()).toMatchObject({ name: 'desktop' }) + ctx.emit('connection/reset') + expect(face.hooks.presentedHost.getSnapshot()).toBeNull() await face.openPresented(SessionId('child-session'), 2, 0) expect(face.hooks.presentedOpen.getSnapshot()['/api/present.open?sessionId=child-session&seq=2&index=0']).toBe('opened') // A turn that produced nothing yields no vocabulary at all. expect(service?.forClosing(tailOwner(undefined, 2), SessionId('viewed-session'))).toBeUndefined() + fetcher.mockResolvedValueOnce(Response.json({ name: 'last-host', available: true, fileManager: 'finder' })) + await face.reloadPresentedHost() await fiber.dispose() + const reset = vi.fn() + const unsubscribe = face.hooks.presentedHost.subscribe(reset) + ctx.emit('connection/reset') + expect(reset).not.toHaveBeenCalled() + unsubscribe() expect(ctx.slots.entries('conversation.chat.turnTail')).toHaveLength(0) expect(ctx.slots.entries('tool.call.toolview')).toHaveLength(0) // Fiber teardown retracts the service: the consumer's ctx.get sees the off state. diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index fc91ac631b..361c454a4d 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: 194fc2a3ac97a8b40bb6c385af3b4ee6b3930b52 -README.zh.md: e2dcea7a7c03556fef789db21cef8187c8b72d66 +README.md: 1c3bb7a7d36884234340b29da9f8763e46796e61 +README.zh.md: a8bc94f4afeb5c13d95ccafea4ef1b926524abd0 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 194fc2a3ac..1c3bb7a7d3 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -67,7 +67,7 @@ Writing your own component in your own package is fine when the need is genuinel ### Controls and icons -The catalog above lists what each export is for; this section covers the behavior that props alone do not show. The `ic_ds_*` icon set and `FishLogo`/`BrandWordmark` marks fill brand and inline-icon slots. `LinkIcon` draws the leading category glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, all riding `currentColor` — and `classifyLinkPath` derives a file path's category from its extension. `FileTypeIcon` is the one glyph that does not ride `currentColor`: its sheet colour is the type's identity, so a consumer that wants it muted (an empty state) applies `filter: grayscale(1)` itself. `classifyFileType` and `classifyLinkPath` read one extension vocabulary (`file-extensions.ts`), so a path classifies consistently on a link and on a sheet; the sheet is the finer of the two. `ConnectionIndicator` renders a warning-colored disconnected action, a connecting label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Hover or keyboard focus shows only the reconnect action label, including while the connecting dots animate. Every state reserves the widest supplied label and uses fixed icon and text columns, so copy changes do not move or resize the control. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. `Button.segment` joins the start and end controls of a split action without changing standalone button geometry. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and restores the trigger on Escape; it is opt-in for action menus. +The catalog above lists what each export is for; this section covers the behavior that props alone do not show. The `ic_ds_*` icon set and `FishLogo`/`BrandWordmark` marks fill brand and inline-icon slots. `LinkIcon` draws the leading category glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, all riding `currentColor` — and `classifyLinkPath` derives a file path's category from its extension. `FileTypeIcon` is the one glyph that does not ride `currentColor`: its sheet colour is the type's identity, so a consumer that wants it muted (an empty state) applies `filter: grayscale(1)` itself. `classifyFileType` and `classifyLinkPath` read one extension vocabulary (`file-extensions.ts`), so a path classifies consistently on a link and on a sheet; the sheet is the finer of the two. `ConnectionIndicator` renders a warning-colored disconnected action, a connecting label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Hover or keyboard focus shows only the reconnect action label, including while the connecting dots animate. Every state reserves the widest supplied label and uses fixed icon and text columns, so copy changes do not move or resize the control. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. `Button.segment` joins the start and end controls of a split action without changing standalone button geometry. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus. ### Rendering agent output diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index e2dcea7a7c..a8bc94f4af 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -67,7 +67,7 @@ kind: "package-library" ### 控件与图标 -上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。`ic_ds_*` 图标集与 `FishLogo`/`BrandWordmark` 标记填充品牌与行内图标 slot。`LinkIcon` 为可点击产物链接绘制前置分类图形——地球、文件夹、代码、图片、文档或纸张,全部随 `currentColor`——`classifyLinkPath` 按扩展名推导文件路径的类别。`FileTypeIcon` 是唯一不随 `currentColor` 的字形:纸片颜色就是类型的身份,需要压灰的使用方(空状态)自行加 `filter: grayscale(1)`。`classifyFileType` 与 `classifyLinkPath` 读同一份扩展名词表(`file-extensions.ts`),一条路径在链接上与纸片上的归类一致;纸片分得更细。`ConnectionIndicator` 可渲染警告色的断联操作、以独立于 retry 时序的 500ms 节奏推进一至三个点的连接中状态,或成功色的恢复状态。悬停或键盘聚焦时只显示重连操作文案,连接中的圆点动画也保持隐藏。所有状态都为最长的输入 label 预留空间,并使用固定的图标列和文字列,因此文案变化不会移动控件或改变其宽度。它的 owner 提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。 `Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。 `rankByName` 是 `/` 菜单命令源与 skill 源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 `Button.segment` 将分段操作的首尾按钮连接起来,不改变独立按钮的几何样式。`Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时将焦点还给触发按钮;操作菜单可显式启用。 +上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。`ic_ds_*` 图标集与 `FishLogo`/`BrandWordmark` 标记填充品牌与行内图标 slot。`LinkIcon` 为可点击产物链接绘制前置分类图形——地球、文件夹、代码、图片、文档或纸张,全部随 `currentColor`——`classifyLinkPath` 按扩展名推导文件路径的类别。`FileTypeIcon` 是唯一不随 `currentColor` 的字形:纸片颜色就是类型的身份,需要压灰的使用方(空状态)自行加 `filter: grayscale(1)`。`classifyFileType` 与 `classifyLinkPath` 读同一份扩展名词表(`file-extensions.ts`),一条路径在链接上与纸片上的归类一致;纸片分得更细。`ConnectionIndicator` 可渲染警告色的断联操作、以独立于 retry 时序的 500ms 节奏推进一至三个点的连接中状态,或成功色的恢复状态。悬停或键盘聚焦时只显示重连操作文案,连接中的圆点动画也保持隐藏。所有状态都为最长的输入 label 预留空间,并使用固定的图标列和文字列,因此文案变化不会移动控件或改变其宽度。它的 owner 提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。 `Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。 `rankByName` 是 `/` 菜单命令源与 skill 源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 `Button.segment` 将分段操作的首尾按钮连接起来,不改变独立按钮的几何样式。`Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。 ### 渲染 agent 输出 diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx index ed19fbb70c..edc4825bc1 100644 --- a/packages/client/ui-primitives/src/Menu.tsx +++ b/packages/client/ui-primitives/src/Menu.tsx @@ -48,7 +48,7 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } /** * Render an anchored dropdown menu. - * @param props.autoFocus - focus the first item on open and enable arrow-key navigation; Escape restores the trigger. + * @param props.autoFocus - focus the first item on open and enable arrow-key navigation; Escape focuses the anchor's first button. * @param props.open - whether the list is showing (owner-controlled). * @param props.anchor - the trigger element (rendered in place). * @param props.items - selectable rows and optional separators. diff --git a/packages/util/native-command/README.i18n.yaml b/packages/util/native-command/README.i18n.yaml index 5dba1e1698..6e9e0b3aad 100644 --- a/packages/util/native-command/README.i18n.yaml +++ b/packages/util/native-command/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/util/native-command/README.md -README.md: b127c1f03fc91282cd2a847af84f8183f147f9ac -README.zh.md: 7ff612fe9bead184ace2b5ee2c54700c8d673df0 +README.md: b0b1963a97c6d528c123d4900db529bd3dc38332 +README.zh.md: 6a245e3473a4e4dd9944dc53898e04be2fcba2e4 diff --git a/packages/util/native-command/README.md b/packages/util/native-command/README.md index b127c1f03f..b0b1963a97 100644 --- a/packages/util/native-command/README.md +++ b/packages/util/native-command/README.md @@ -47,7 +47,7 @@ The `NativeCommandRunner` type is the injectable command boundary for host integ `openNativePath(path, signal)` hands a path to the default application and prefers the named default browser for HTML and SVG where the platform can identify one. `openNativeTextFile(path, signal)` selects text-editor intent; on macOS it uses `open -t`. WSL paths are translated with `wslpath -w` before the Windows desktop receives them. `canOpenNativePath()` reports whether the current Host plausibly has a desktop target. -`revealNativePath(path, signal)` selects the file in Finder or Explorer, including WSL path translation, and opens its parent directory through `xdg-open` on desktop Linux. `nativeFileManager()` identifies that action for Host-derived UI labels; desktop availability remains a separate `canOpenNativePath()` check. Callers must authorize the absolute file path before invoking either operation. Platform dispatch is covered by injected-runner tests; native desktop verification belongs to the corresponding platform. +`revealNativePath(path, signal)` selects the file in Finder or Explorer, including WSL path translation, and opens its parent directory through `xdg-open` on desktop Linux. `nativeFileManager()` identifies that action for Host-derived UI labels; desktop availability remains a separate `canOpenNativePath()` check. Callers must authorize the absolute file path before invoking either operation. Platform dispatch is covered by injected-runner tests; native desktop verification belongs to the corresponding platform. Explorer receives an encoded file URI as a separate argument. Its exit code 1 is accepted as a delegated handoff; cancellation, missing executables, and other exit codes still reject. This acknowledgement does not prove that a desktop window selected the file. ----- diff --git a/packages/util/native-command/README.zh.md b/packages/util/native-command/README.zh.md index 7ff612fe9b..6a245e3473 100644 --- a/packages/util/native-command/README.zh.md +++ b/packages/util/native-command/README.zh.md @@ -47,7 +47,7 @@ const { stdout, stderr } = await runNativeCommand('osascript', ['-e', script], s `openNativePath(path, signal)` 将路径交给默认应用;平台能够确定默认浏览器时,HTML 与 SVG 会优先交给该浏览器。`openNativeTextFile(path, signal)` 选择文本编辑器意图;macOS 使用 `open -t`。WSL 路径先通过 `wslpath -w` 转换,再交给 Windows 桌面。`canOpenNativePath()` 报告当前 Host 是否可能具备桌面目标。 -`revealNativePath(path, signal)` 在 Finder 或文件资源管理器中选中文件,包含 WSL 路径转换;在桌面 Linux 上通过 `xdg-open` 打开上层目录。`nativeFileManager()` 标识该操作,供 UI 根据 Host 选择文案;桌面是否可用仍由独立的 `canOpenNativePath()` 检查决定。调用方必须先授权绝对文件路径,再执行操作。平台分派由注入运行器的测试覆盖;原生桌面验证由对应平台负责。 +`revealNativePath(path, signal)` 在 Finder 或文件资源管理器中选中文件,包含 WSL 路径转换;在桌面 Linux 上通过 `xdg-open` 打开上层目录。`nativeFileManager()` 标识该操作,供 UI 根据 Host 选择文案;桌面是否可用仍由独立的 `canOpenNativePath()` 检查决定。调用方必须先授权绝对文件路径,再执行操作。平台分派由注入运行器的测试覆盖;原生桌面验证由对应平台负责。 Explorer 接收独立参数中的编码文件 URI。退出码 1 按已转交请求处理;取消、找不到可执行文件和其他退出码仍然报错。该确认不能证明桌面窗口已选中文件。 ----- diff --git a/packages/util/native-command/src/path-opener.ts b/packages/util/native-command/src/path-opener.ts index 0d8dcb32da..8f3443b3c1 100644 --- a/packages/util/native-command/src/path-opener.ts +++ b/packages/util/native-command/src/path-opener.ts @@ -11,6 +11,7 @@ import { release as osRelease } from 'node:os' import { dirname, extname } from 'node:path' +import { pathToFileURL } from 'node:url' import { runNativeCommand, type NativeCommandRunner } from './runner.ts' /** Testable command boundary; native implementations never invoke a shell. */ @@ -222,7 +223,7 @@ export function nativeFileManager(internals: PathOpenerInternals = {}): NativeFi * @param path - absolute file path already authorized by the caller. * @param signal - caller lifetime; abort terminates the native command. * @param internals - platform, environment, and command runner for adapter tests. - * @returns after the file-manager command accepts the request; launch failures reject. + * @returns after command completion; Explorer exit 1 is accepted as a delegated handoff, not proof of selection. */ export async function revealNativePath( path: string, signal: AbortSignal, internals: PathOpenerInternals = {}, @@ -243,7 +244,15 @@ export async function revealNativePath( windowsPath = translated.stdout.replace(/[\r\n]+$/, '') if (windowsPath === '') throw new Error('wslpath returned no Windows path') } - await run('explorer.exe', [`/select,${windowsPath}`], signal) + // Explorer parses commas itself; a file URI preserves commas and whitespace in the path. + const target = pathToFileURL(windowsPath, { windows: true }).href.replaceAll(',', '%2C') + try { + await run('explorer.exe', ['/select,', target], signal) + } catch (error) { + signal.throwIfAborted() + // Explorer can exit 1 after delegating to the existing desktop process. + if (!(error instanceof Error) || !('code' in error) || error.code !== 1) throw error + } return } if (manager === 'directory') { diff --git a/packages/util/native-command/tests/path-opener.spec.ts b/packages/util/native-command/tests/path-opener.spec.ts index 347588095d..5082d141ed 100644 --- a/packages/util/native-command/tests/path-opener.spec.ts +++ b/packages/util/native-command/tests/path-opener.spec.ts @@ -331,7 +331,7 @@ describe('canOpenNativePath', () => { describe('native file manager', () => { it.each([ ['darwin', 'finder', '/tmp/my report.txt', 'open', ['-R', '/tmp/my report.txt']], - ['win32', 'explorer', 'C:\\work\\my report.txt', 'explorer.exe', ['/select,C:\\work\\my report.txt']], + ['win32', 'explorer', 'C:\\work\\my report.txt', 'explorer.exe', ['/select,', 'file:///C:/work/my%20report.txt']], ['linux', 'directory', '/tmp/a $b; report.txt', 'xdg-open', ['/tmp']], ] as const)('reveals through %s without opening the file association', async (platform, manager, path, command, args) => { const run = vi.fn(async () => ({ stdout: '', stderr: '' })) @@ -347,7 +347,7 @@ describe('native file manager', () => { expect(nativeFileManager(internals)).toBe('explorer') await revealNativePath('/mnt/c/work/报告.txt', signal(), internals) expect(run.mock.calls.map(([cmd, args]) => [cmd, args])).toEqual([ - ['wslpath', ['-w', '/mnt/c/work/报告.txt']], ['explorer.exe', ['/select,C:\\work\\报告.txt']], + ['wslpath', ['-w', '/mnt/c/work/报告.txt']], ['explorer.exe', ['/select,', 'file:///C:/work/%E6%8A%A5%E5%91%8A.txt']], ]) }) @@ -378,3 +378,38 @@ it('uses the native runner for a file-manager handoff when none is injected', as await revealNativePath('/tmp/report.txt', signal()) expect(execFileMock).toHaveBeenCalled() }) + + +it.each(['win32', 'linux'] as const)('accepts Explorer delegate exit 1 through the native runner on %s', async (platform) => { + execFileMock.mockImplementation((command, _args, _options, callback) => { + if (command === 'wslpath') callback(null, 'C:\\work\\report.txt', '') + else callback(Object.assign(new Error('delegated'), { code: 1 }), '', '') + }) + await expect(revealNativePath(platform === 'win32' ? 'C:\\work\\report.txt' : '/mnt/c/work/report.txt', signal(), + { platform, env: { WSL_DISTRO_NAME: 'Ubuntu' } })).resolves.toBeUndefined() +}) + +it.each([2, 'ENOENT', undefined])('preserves Explorer failure %s', async (code) => { + const failure = Object.assign(new Error('launch failed'), code === undefined ? {} : { code }) + execFileMock.mockImplementation((_command, _args, _options, callback) => { callback(failure, '', '') }) + await expect(revealNativePath('C:\\file.txt', signal(), { platform: 'win32' })).rejects.toMatchObject({ code }) +}) + +it('preserves cancellation even when Explorer returns delegate exit 1', async () => { + const abort = new AbortController() + const reason = new Error('cancelled') + const run = vi.fn(async () => { + abort.abort(reason) + throw Object.assign(new Error('delegated'), { code: 1 }) + }) + await expect(revealNativePath('C:\\file.txt', abort.signal, { platform: 'win32', run })).rejects.toBe(reason) +}) + +it.each([ + ['C:\\my files\\报告,#%.txt', 'file:///C:/my%20files/%E6%8A%A5%E5%91%8A%2C%23%25.txt'], + ['\\\\server\\share\\a,b.txt', 'file://server/share/a%2Cb.txt'], +])('preserves special characters in the Explorer target %s', async (path, target) => { + const run = vi.fn().mockResolvedValue({ stdout: '', stderr: '' }) + await revealNativePath(path, signal(), { platform: 'win32', run }) + expect(run).toHaveBeenCalledWith('explorer.exe', ['/select,', target], expect.any(AbortSignal)) +})