diff --git a/apps/web/tests/present.e2e.ts b/apps/web/tests/present.e2e.ts index 6a9a790de7..c8d4abc172 100644 --- a/apps/web/tests/present.e2e.ts +++ b/apps/web/tests/present.e2e.ts @@ -164,6 +164,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: 'Show file location', exact: true }).click() expect((await revealResponse).status()).toBe(204) + await page.waitForFunction(() => document.querySelector('[data-presented-files-row] button:disabled') === null) + expect(await page.getByRole('alert').count()).toBe(0) 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) { @@ -172,34 +174,11 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con await row.locator('[data-presented-file]').filter({ hasText: name }).getByRole('button', { name: 'Open in Test Editor', exact: true }).click() expect((await response).status()).toBe(204) await page.waitForFunction(() => document.querySelector('[data-presented-files-row] button:disabled') === null) + expect(await page.getByRole('alert').count()).toBe(0) + expect(await row.locator('[data-presented-file] [role="status"]').count()).toBe(0) expect(await opened()).toHaveLength(count + 1) expect((await opened()).at(-1)).toEqual({ action: 'open', path: await realpath(join(cwd, name)), content: bytes }) } - const success = row.locator('[data-presented-file]').filter({ hasText: '说明.txt' }).locator('[data-success]') - await success.waitFor() - const animation = await success.evaluate((element) => { - const style = getComputedStyle(element) - const fade = element.getAnimations()[0] - if (fade === undefined) throw new Error('Successful file feedback has no fade animation') - // Seek the CSS animation so the 200 ms fade does not race the assertion. - fade.pause() - fade.currentTime = 5100 - return { - delay: style.animationDelay, duration: style.animationDuration, - fill: style.animationFillMode, opacity: Number(getComputedStyle(element).opacity), - } - }) - expect(animation.delay).toBe('5s') - expect(animation.duration).toBe('0.2s') - expect(animation.fill).toBe('forwards') - expect(animation.opacity).toBeGreaterThan(0) - expect(animation.opacity).toBeLessThan(1) - try { - await page.emulateMedia({ reducedMotion: 'reduce' }) - expect(await success.evaluate(element => getComputedStyle(element).animationName)).toBe('none') - } finally { - await page.emulateMedia({ reducedMotion: 'no-preference' }) - } } expect(downloads).toEqual([]) const response = await page.request.get(new URL(`/api/session.export?sessionId=${sessionId}`, scaffold.authenticatedUrl).href) @@ -318,4 +297,64 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }) + + it('announces mobile native opens and reveals on another device, and reports a deleted file', async () => { + const context = await browser.newContext({ + storageState: await page.context().storageState(), + userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 Version/18.6 Mobile/15E148 Safari/604.1', + viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, locale: 'en-US', timezoneId: 'Asia/Shanghai', + }) + try { + const phone = await context.newPage() + const consoleWatch = watchConsole(phone) + const phoneDownloads: string[] = [] + phone.on('download', (download) => { phoneDownloads.push(download.suggestedFilename()) }) + await phone.goto(page.url(), { waitUntil: 'load' }) + await phone.getByText(/^PRESENT_DONE\.?$/).waitFor({ timeout: 30_000 }) + const card = phone.locator('[data-presented-file]').filter({ hasText: '说明.txt' }) + const open = card.getByRole('button', { name: 'Open in Test Editor', exact: true }) + await open.waitFor() + const notices: string[] = [] + const beforeOpen = (await opened()).length + const openResponse = phone.waitForResponse(response => response.url().includes('/api/present.open?') && response.request().method() === 'POST') + await open.tap() + expect((await openResponse).status()).toBe(204) + const success = phone.getByRole('alert').filter({ hasText: 'Opened on another device' }) + await success.waitFor() + notices.push(`## Open\n\n${await success.ariaSnapshot()}`) + await expect.poll(opened).toHaveLength(beforeOpen + 1) + expect((await opened()).at(-1)).toEqual({ action: 'open', path: await realpath(join(cwd, '说明.txt')), content: 'EDITED_NOTE\n' }) + expect(await card.locator('[role="status"]').count()).toBe(0) + const bounds = await success.boundingBox() + expect(bounds).not.toBeNull() + expect(bounds!.x).toBeGreaterThanOrEqual(0) + expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(390) + + await card.getByRole('button', { name: 'More ways to open', exact: true }).tap() + const revealResponse = phone.waitForResponse(response => response.url().includes('action=reveal') && response.request().method() === 'POST') + await phone.getByRole('menuitem', { name: 'Show file location', exact: true }).tap() + expect((await revealResponse).status()).toBe(204) + const revealed = phone.getByRole('alert').filter({ hasText: 'File location shown on another device' }) + await revealed.waitFor() + notices.push(`## Show file location\n\n${await revealed.ariaSnapshot()}`) + await expect.poll(opened).toHaveLength(beforeOpen + 2) + expect((await opened()).at(-1)).toEqual({ action: 'reveal', content: null, path: await realpath(process.platform === 'darwin' ? join(cwd, '说明.txt') : cwd) }) + + await unlink(join(cwd, '说明.txt')) + const missingResponse = phone.waitForResponse(response => response.url().includes('/api/present.open?') && response.request().method() === 'POST') + await open.tap() + expect((await missingResponse).status()).toBe(404) + const failure = phone.getByRole('alert').filter({ hasText: 'Could not open. Try again.' }) + await failure.waitFor() + notices.push(`## Missing file\n\n${await failure.ariaSnapshot()}`) + await card.getByText('Could not open. Click to retry.', { exact: true }).waitFor() + expect(await opened()).toHaveLength(beforeOpen + 2) + expect(phoneDownloads).toEqual([]) + await compareOrRefreshGolden(join(DIR, 'mobile-open.expected.md'), notices.join('\n\n'), MODE) + expect(consoleWatch.pageErrors).toEqual([]) + expect(consoleWatch.warnings).toEqual([]) + } finally { + await context.close() + } + }) }) diff --git a/docs/upgrade-guide/v0.2.1-rc.3/ptc-caller-cancellation/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.3/ptc-caller-cancellation/guide.i18n.yaml new file mode 100644 index 0000000000..50d54a9371 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/ptc-caller-cancellation/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.3/ptc-caller-cancellation/guide.md +/: + en: 6deb73f8bd3b1b2a + zh: 8bafa6e8c6277923 +/ptc-caller-cancellation-uses-the-standard-tool-error-code: + en: c647ac85178ef0ea + zh: a6e5b76d53bbb6ef +/ptc-caller-cancellation-uses-the-standard-tool-error-code/change: + en: 1a9a455bf55e6b81 + zh: ec925abc44d7684f +/ptc-caller-cancellation-uses-the-standard-tool-error-code/migration: + en: e3bbbebda24059c0 + zh: 926e91f3ce5a8a72 diff --git a/docs/upgrade-guide/v0.2.1-rc.3/ptc-caller-cancellation/guide.md b/docs/upgrade-guide/v0.2.1-rc.3/ptc-caller-cancellation/guide.md new file mode 100644 index 0000000000..a53b061808 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/ptc-caller-cancellation/guide.md @@ -0,0 +1,20 @@ +--- +kind: upgrade-guide +description: "Caller-cancelled run_code results use ABORTED instead of CODE_RUN_FAILED." +--- + +# PTC caller cancellation uses the standard tool error code + +English | [中文](guide.zh.md) + +## Change + +When a running `run_code` call is cancelled by its caller and the runtime reports `abort`, its `tool/result` error changes from `CodeRunFailedError` / `CODE_RUN_FAILED` to `AbortError` / `ABORTED`. The result content is `Error: tool call aborted`, replacing the runtime diagnostic, captured output and sandbox summary. SDK clients and Session consumers that classify these results are affected. + +The Agent continues to record the specific cancellation cause in `turn/end`, including a hook's `reason`. Runtime failures such as timeout and cleanup failure, and runtime-local cancellation without caller cancellation, retain `CODE_RUN_FAILED`. Cancellation still waits for cleanup; it does not roll back completed tool effects, and retained runtime variables may be cleared. + +## Migration + +1. In consumers of new `tool/result` events, classify caller cancellation by `data.error.code === 'ABORTED'`. Read the Agent cancellation cause from the corresponding `turn/end` event's `data.reason.reason` when `data.reason.kind === 'aborted'`. +2. Keep support for `CODE_RUN_FAILED` when reading historical PTC cancellation results. Existing Session logs are not rewritten. +3. Cancel a running `run_code` call and confirm its result has code `ABORTED`, followed by a `turn/end` event containing the original Agent cancellation cause. Confirm ordinary runtime failures still report `CODE_RUN_FAILED`. diff --git a/docs/upgrade-guide/v0.2.1-rc.3/ptc-caller-cancellation/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.3/ptc-caller-cancellation/guide.zh.md new file mode 100644 index 0000000000..fbd17773ec --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/ptc-caller-cancellation/guide.zh.md @@ -0,0 +1,20 @@ +--- +kind: upgrade-guide +description: "调用方取消的 run_code 结果使用 ABORTED,取代 CODE_RUN_FAILED。" +--- + +# PTC 调用方取消使用标准工具错误码 + +[English](guide.md) | 中文 + +## 变更 + +运行中的 `run_code` 被调用方取消且运行时报告 `abort` 时,其 `tool/result` 错误从 `CodeRunFailedError` / `CODE_RUN_FAILED` 变为 `AbortError` / `ABORTED`。结果内容为 `Error: tool call aborted`,取代运行时诊断、捕获输出和沙箱摘要。按这些结果分类的 SDK 客户端和 Session 消费方受到影响。 + +Agent 继续在 `turn/end` 中记录具体取消原因,包括 hook 的 `reason`。超时、清理失败等运行时失败,以及没有调用方取消的运行时内部取消,保留 `CODE_RUN_FAILED`。取消仍等待清理完成;它不会回滚已完成的工具操作,保留的运行时变量可能被清空。 + +## 迁移 + +1. 在新 `tool/result` 事件的消费方中,通过 `data.error.code === 'ABORTED'` 识别调用方取消。当对应 `turn/end` 事件的 `data.reason.kind === 'aborted'` 时,从 `data.reason.reason` 读取 Agent 取消原因。 +2. 读取历史 PTC 取消结果时,继续支持 `CODE_RUN_FAILED`。已有 Session 日志不会被重写。 +3. 取消运行中的 `run_code`,确认其结果错误码为 `ABORTED`,随后的 `turn/end` 事件包含原始 Agent 取消原因。确认普通运行时失败仍报告 `CODE_RUN_FAILED`。 diff --git a/packages/client/ui-conversation/src/client/input/decorations.ts b/packages/client/ui-conversation/src/client/input/decorations.ts index d71c4fb252..637c6874a1 100644 --- a/packages/client/ui-conversation/src/client/input/decorations.ts +++ b/packages/client/ui-conversation/src/client/input/decorations.ts @@ -2,7 +2,7 @@ * Plain-text reference scan (the plain-text-reference decision; * see .agents/notes/archived/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md): * a `/name` or `@name` token whose name is on the trigger's lexicon, and - * syntax-recognizable `@dir/` folder tokens. Pure derivation — the editor's + * syntax-recognizable `@path` file and folder tokens. Pure derivation — the editor's * text-ref entity transform consumes these ranges; editing the text out of * match shape simply drops the range next scan. */ @@ -10,9 +10,9 @@ /** * One plain-text reference range (the plain-text-reference decision; * see .agents/notes/archived/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md): - * a `/name` or `@name` token - * whose name is on the trigger's lexicon. Pure derivation — editing the text - * out of match shape simply drops the range next scan. + * a `/name` or `@name` token whose name is on the trigger's lexicon, or an + * `@path` file or folder token. Pure derivation — editing the text out of + * match shape simply drops the range next scan. */ export interface TextRefRange { readonly start: number @@ -22,7 +22,42 @@ export interface TextRefRange { /** Token matcher: a trigger char at line start or after whitespace, then a word-ish name (never crosses \n). */ const TEXT_REF_RE = /(^|\s)([/@])([\w-]+)/g -const FOLDER_REF_RE = /(^|\s)(@(?:"[^"\n]*\/|[^\s"]+\/))/g +/** One `@` path token: a quoted path through its closing quote or line end, or an unquoted run. */ +const PATH_TOKEN_RE = /(^|\s)(@(?:"[^"\n]*"?|[^\s"]\S*))/g +/** + * Punctuation that ends prose rather than a path: the characters the + * sent-message file and folder rules in ui-reference strip from the end of an + * unquoted token. + */ +const TRAILING_PUNCTUATION = new Set('.,;:!?,。;:!?') +/** An unquoted token names a path only with a separator or an extension; a bare `@name` stays plain until a lexicon claims it. */ +const PATH_LIKE_RE = /^@.*[/\\.]/u + +/** + * End of the whole `@path` file or folder token at `start`, so a path is never + * cut at its directory prefix. A closed quote ends at its quote and an + * unquoted token with a separator or extension drops trailing punctuation, + * the spans the sent-message rules render. An open quote (a folder drill in + * progress) ends at its last slash when only trailing punctuation and + * whitespace or the draft end follow it. + * @param draft - draft text. + * @param start - offset of the token's `@`. + * @param token - the path token at `start`. + * @returns the token's end offset, or undefined when it names no path. + */ +function pathEnd(draft: string, start: number, token: string): number | undefined { + if (!token.startsWith('@"')) { + let length = token.length + while (TRAILING_PUNCTUATION.has(token.charAt(length - 1))) length -= 1 + return PATH_LIKE_RE.test(token.slice(0, length)) ? start + length : undefined + } + if (token.length > 3 && token.endsWith('"')) return start + token.length + const slash = token.lastIndexOf('/') + if (slash < 0) return undefined + let next = start + slash + 1 + while (TRAILING_PUNCTUATION.has(draft.charAt(next))) next += 1 + return next === draft.length || /\s/u.test(draft.charAt(next)) ? start + slash + 1 : undefined +} /** * What may follow a `/name` token: whitespace or the draft end, the boundary * the host skill gesture (`dsh-tool-skill`) requires, so `/nfs-hg/xxx`, @@ -56,13 +91,12 @@ export function scanTextRefs( out.push({ start, end: start + 1 + name.length, trigger }) } } - FOLDER_REF_RE.lastIndex = 0 - let folder: RegExpExecArray | null - while ((folder = FOLDER_REF_RE.exec(draft)) !== null) { - const token = folder[2] ?? '' - const start = folder.index + (folder[1]?.length ?? 0) - const end = start + token.length - if (!out.some(range => range.start < end && range.end > start)) { + PATH_TOKEN_RE.lastIndex = 0 + let path: RegExpExecArray | null + while ((path = PATH_TOKEN_RE.exec(draft)) !== null) { + const start = path.index + (path[1]?.length ?? 0) + const end = pathEnd(draft, start, path[2] ?? '') + if (end !== undefined && !out.some(range => range.start < end && range.end > start)) { out.push({ start, end, trigger: '@' }) } } diff --git a/packages/client/ui-conversation/tests/draft-document.client.spec.ts b/packages/client/ui-conversation/tests/draft-document.client.spec.ts index 81cf3de056..8e50e1ad3a 100644 --- a/packages/client/ui-conversation/tests/draft-document.client.spec.ts +++ b/packages/client/ui-conversation/tests/draft-document.client.spec.ts @@ -844,4 +844,15 @@ describe('plain-text reference catalogs', () => { { start: 0, end: 14, trigger: '@' }, ]) }) + + it('matches whole file and folder path tokens, never a directory prefix', () => { + const slices = (text: string) => scanTextRefs(text, new Map()).map(range => text.slice(range.start, range.end)) + expect(slices('@src/deep/file.txt, @"/a/nv/SM100 ISA.html" 看 @"a/ b.md"。 @src/.env @C:\\src\\a.ts @src/de')) + .toEqual(['@src/deep/file.txt', '@"/a/nv/SM100 ISA.html"', '@"a/ b.md"', '@src/.env', '@C:\\src\\a.ts', '@src/de']) + expect(slices('@"dir a/" @src/deep/。 @dir/ 请看 @"src/",然后 @"open dir/ x')) + .toEqual(['@"dir a/"', '@src/deep/', '@dir/', '@"src/"', '@"open dir/']) + expect(slices('@person @"" @"a/b')).toEqual([]) + expect(slices('@"open @')).toEqual([]) + expect(slices('drill @"open dir/。')).toEqual(['@"open dir/']) + }) }) diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index fc6af394fd..a657abf806 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -1938,6 +1938,15 @@ describe('decorations', () => { expect(shell.snapshot.draft).toBe('see @src/components/') }) + it('a pasted path decorates as one whole file or folder token', async () => { + const { view, shell, textarea } = bench() + const marks = () => [...view.container.querySelectorAll('[data-composer-text-ref]')].map(el => el.textContent) + const text = 'read @"/a/nv/SM100 ISA.html" and @src/deep/file.txt then @src/deep/ first' + fireEvent.paste(textarea, { clipboardData: { items: [], getData: () => text } }) + await vi.waitFor(() => { expect(shell.snapshot.draft).toBe(text) }) + expect(marks()).toEqual(['@"/a/nv/SM100 ISA.html"', '@src/deep/file.txt', '@src/deep/']) + }) + it('a plain-text reference keeps its DOM node while typing ahead of it (bug #2793 regression)', () => { const { view, shell } = bench() act(() => { shell.setDraft('see @src/components/ here') }) diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index 29c4d5bea0..675bcff105 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/README.i18n.yaml @@ -18,8 +18,8 @@ en: 4a2fcdcf13276fef zh: 8bde03bbbf15840c /deepseek-ai-dsh-client-ui-deliverables/use-this-package/explicit-deliveries: - en: 62d4ae0abeeba61e - zh: 32eb43e49166bf61 + en: 5fc6e2d7926f08c0 + zh: 18cdb7450add873e /deepseek-ai-dsh-client-ui-deliverables/use-this-package/the-changed-files-card: en: 4b279e2bddddc083 zh: a3135393299c935b diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index 9e4f011a0a..0632574d83 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -36,7 +36,7 @@ The `present` tool's preparing stage is one non-expandable document-icon row. It The Web `standard`, `ptc`, and `cordis` presets expose `present` for final files accessible through the Session filesystem, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../deliverables/deliverable-file-references/README.md) owns file-count limits and Session declarations. A single delivery fills the row; multiple deliveries form a two-column grid, collapsed after four files. Cards show the basename, description or file type, and a shared native opening control. Delivery and changed-file icon tiles use white at 50% opacity in the light theme and 5% in the dark theme. Icon tiles and single-file changed cards use the same border as delivery cards. Clicking the card previews the file in the right Sidebar. The control opens the default application and lists associated applications, with file reveal last. Matching inline-code references also open Sidebar previews. Delivery cards appear in the turn tail only after the turn ends, so a turn waiting on a blocking question shows none yet; the `present` tool description directs the model to link files in its message for earlier review. Cards include every declaration in the turn, including declarations recorded after the closing reply; duplicate declarations use the last description. -The `present` tool row retains its document icon and recorded result across progress, success, failure, and interruption. Native actions share pending state and publish progress or retryable errors on the card. Successful open or reveal feedback stays visible for five seconds, fades over 200ms, then restores the file description; failures remain visible until another attempt. Desktop metadata is loaded when cards mount and invalidated on connection replacement. Without a Host desktop, cards retain Sidebar preview but omit native controls; failed metadata reads offer Retry. Delivery cards and change review expose `deliverables.file.actions` and `deliverables.review.file.actions`, supplied by [ui-open-in-app](../ui-open-in-app/README.md). Association GET requests and native POST actions both validate the recorded file coordinates and Session filesystem mapping before using the Host desktop. +The `present` tool row retains its document icon and recorded result across progress, success, failure, and interruption. Native actions share pending state and publish progress or retryable errors on the card. An acknowledged open or reveal immediately restores the file description; failures remain visible until another attempt. Each gesture settles on a discriminated outcome — acknowledged open, acknowledged reveal, named failure, or a request dropped because the same file was already opening or the plugin was disposed — which the contributed control reports according to the mobile success and shared failure policy in [ui-open-in-app](../ui-open-in-app/README.md). Disposing the plugin cancels pending requests and suppresses their announcements. Desktop metadata is loaded when cards mount and invalidated on connection replacement. Without a Host desktop, cards retain Sidebar preview but omit native controls; failed metadata reads offer Retry. Delivery cards and change review expose `deliverables.file.actions` and `deliverables.review.file.actions`, supplied by [ui-open-in-app](../ui-open-in-app/README.md). Association GET requests and native POST actions both validate the recorded file coordinates and Session filesystem mapping before using the Host desktop. ### The changed-files card diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index dca3f6f1ee..2951888e72 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -36,7 +36,7 @@ kind: "package-reference" Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present`,用于声明会话文件系统可访问的最终文件,包括通过 Bash 创建的文件。创建后以 `files: [{ path, description? }]` 调用。[present 工具](../../deliverables/deliverable-file-references/README.zh.md)拥有文件数量限制和会话声明。单个交付占满一行,多个交付使用双列网格,超过四个时默认收起。卡片显示文件名、说明或文件类型,以及共享的原生打开控件。交付和改动文件卡片的图标底块在浅色主题下使用 50% 不透明度的白色,深色主题下为 5%。图标底块与单文件改动卡片的外框使用与交付卡片相同的描边。点击卡片会在右侧 Sidebar 预览文件。控件打开默认应用,并列出关联应用,文件定位始终放在最后。匹配的行内代码引用也打开 Sidebar 预览。交付卡片只在本轮结束后出现在 turn tail 中,因此等待阻塞提问的轮次暂时没有卡片;`present` 工具描述会引导模型在需要提前审阅时于消息中链接文件。卡片包含本轮的所有声明,包括收尾回复之后记录的声明;重复声明使用最近一次的说明。 -`present` 工具行在执行、成功、失败和中断状态下保留文档图标及记录的结果。原生操作共享等待状态,并在卡片显示进度或可重试错误。打开或定位成功的反馈显示五秒后,在 200ms 内淡出并恢复文件说明;失败信息保留到下次尝试。卡片挂载时读取桌面信息,连接更换时清除缓存。Host 没有桌面时,卡片保留 Sidebar 预览并隐藏原生控件;元数据读取失败时提供重试。交付卡片和变更对比页通过 `deliverables.file.actions` 和 `deliverables.review.file.actions` 使用 [ui-open-in-app](../ui-open-in-app/README.zh.md) 提供的控件。关联应用 GET 查询与原生 POST 操作都会先校验记录中的文件坐标及会话文件系统映射,再调用 Host 桌面。 +`present` 工具行在执行、成功、失败和中断状态下保留文档图标及记录的结果。原生操作共享等待状态,并在卡片显示进度或可重试错误。打开或定位获得确认后立即恢复文件说明;失败信息保留到下次尝试。每次操作会得出一个带判别标签的结果——已确认的打开、已确认的定位、具名失败,或因同一文件正在打开或插件已释放而丢弃的请求——由贡献的控件按照 [ui-open-in-app](../ui-open-in-app/README.zh.md) 的移动端成功与各端失败反馈规则播报。释放插件会取消在途请求,并停止播报这些请求的结果。卡片挂载时读取桌面信息,连接更换时清除缓存。Host 没有桌面时,卡片保留 Sidebar 预览并隐藏原生控件;元数据读取失败时提供重试。交付卡片和变更对比页通过 `deliverables.file.actions` 和 `deliverables.review.file.actions` 使用 [ui-open-in-app](../ui-open-in-app/README.zh.md) 提供的控件。关联应用 GET 查询与原生 POST 操作都会先校验记录中的文件坐标及会话文件系统映射,再调用 Host 桌面。 ### 改动文件卡片 diff --git a/packages/client/ui-deliverables/src/client/Deliverables.module.css b/packages/client/ui-deliverables/src/client/Deliverables.module.css index b0ce22c8fb..e5c439fe27 100644 --- a/packages/client/ui-deliverables/src/client/Deliverables.module.css +++ b/packages/client/ui-deliverables/src/client/Deliverables.module.css @@ -33,9 +33,3 @@ .toggle svg { flex: none; width: 14px; height: 14px; } @container (max-width: 620px) { .presented { grid-template-columns: minmax(0, 1fr); } } .actions { display: inline-flex; flex: none; pointer-events: auto; } - -.secondaryText[data-success] { animation-name: success-fade; animation-timing-function: ease-out; animation-fill-mode: forwards; } -@keyframes success-fade { to { opacity: 0; } } -@media (prefers-reduced-motion: reduce) { - .secondaryText[data-success] { animation-name: none; } -} diff --git a/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx index 41390b0227..64994d8923 100644 --- a/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx +++ b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx @@ -5,7 +5,7 @@ import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path' import { FileTypeIcon, fileExtension } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { PresentedHost } from '../presented.ts' -import { PRESENTED_SUCCESS_HOLD_MS, PRESENTED_SUCCESS_FADE_MS, type PresentedOpenPhase } from './present-open.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' @@ -29,15 +29,13 @@ export function PresentedFileCard({ file, cwd, phase, host, onPreview, onMenu, a onMenu: (point: { x: number; y: number }) => void actions: ReactNode } & PropsLocale) { - const succeeded = phase === 'opened' || phase === 'revealed' const reveal = host?.fileManager ?? 'directory' const name = basename(file.path) const metadata = fileExtension(name).toUpperCase() || t('presented.file') const status = phase === undefined ? cardDescription(file.description, metadata) - : t(reveal === 'directory' && phase === 'revealed' ? 'presented.directoryOpened' - : reveal === 'directory' && phase === 'revealing' ? 'presented.directoryOpening' - : reveal === 'directory' && phase === 'revealError' ? 'presented.directoryError' : `presented.${phase}`) + : t(reveal === 'directory' && phase === 'revealing' ? 'presented.directoryOpening' + : reveal === 'directory' && phase === 'revealError' ? 'presented.directoryError' : `presented.${phase}`) return
, t: makeTranslate(en), }) -it('localizes reveal failures and accurately reports a directory-only action', () => { +it('localizes reveal failures and restores the description when progress clears', () => { const p = props() const view = render() expect(view.getByText(zh['presented.revealError'])).toBeTruthy() - view.rerender() - expect(view.getByText(en['presented.directoryOpened'])).toBeTruthy() - view.rerender() - expect(view.getByText(en['presented.revealed'])).toBeTruthy() + view.rerender() + expect(view.getByText(p.file.description)).toBeTruthy() + expect(view.queryByRole('status')).toBeNull() }) diff --git a/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx b/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx index 90209385fd..3036b0e4b6 100644 --- a/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx @@ -160,7 +160,7 @@ describe('ReviewTab', () => { loadChangesSummary: vi.fn(() => Promise.resolve()), loadChangesDiff: vi.fn(() => Promise.resolve()), reloadPresentedHost: vi.fn(() => Promise.resolve()), - openChanged: vi.fn(() => Promise.resolve(null)), + openChanged: vi.fn(() => Promise.resolve({ kind: 'opened' })), } const sessions: SessionListState = { ids: [SESSION], diff --git a/packages/client/ui-open-in-app/README.i18n.yaml b/packages/client/ui-open-in-app/README.i18n.yaml index 7852f0da44..028691d184 100644 --- a/packages/client/ui-open-in-app/README.i18n.yaml +++ b/packages/client/ui-open-in-app/README.i18n.yaml @@ -18,11 +18,11 @@ en: 056e2bd9e3c78dd8 zh: be35d2ba53be0442 /deepseek-ai-dsh-client-ui-open-in-app/use-this-package/what-to-expect: - en: 7ec51cb7e0c6ced6 - zh: 6a7a8bb56c388a68 + en: 6f3934925cfda953 + zh: 9e3126bce73cf344 /deepseek-ai-dsh-client-ui-open-in-app/understand-the-implementation: - en: a0304e1bd9b44370 - zh: af0fdfd01c296dce + en: ec7a7c56aaeafce6 + zh: 4cee0cd2cb7531df /deepseek-ai-dsh-client-ui-open-in-app/further-exploration: en: 562c58d589fb7afa zh: 9ba88b381160cc96 diff --git a/packages/client/ui-open-in-app/README.md b/packages/client/ui-open-in-app/README.md index e5cff4f597..95d7be4a70 100644 --- a/packages/client/ui-open-in-app/README.md +++ b/packages/client/ui-open-in-app/README.md @@ -31,7 +31,9 @@ Mount this plugin in the Web composition beside [`dsh-host-open-in-app`](../../h The Session header, file-tree header, and document header use the same 24px-high split button with 9px corners. These headers show only the icon; their tooltip names the default application or the reveal action. They show the default action’s icon, mark its application with “(default)” in the menu, disable while their own gesture runs, and report failures through a transient toast. The file-tree opener targets the displayed directory supplied as `absolutePath` and shares the application choice and launch state with the Session header. The directory adapter uses the existing cross-platform application catalog and remembers the last successful choice in `dsh.open-in-app.choice`; a missing choice falls back to the first available application. -The **Open locally** shortcut captures the main Session's directory and the same remembered application as the header button. The effective binding appears in the button tooltip and `aria-keyshortcuts`; Web follows the [shortcut service’s platform defaults](../shortcuts/README.md). The command runs only while the Conversation is selected and the header button has a directory and installed application to open. An in-flight launch also blocks the command. Pointer and keyboard gestures share the controller's launch state, so repeated gestures cannot launch twice or change the remembered choice mid-launch. +On iOS and Android phones and tablets, including installed PWAs, an acknowledged native open shows “Opened on another device”; reveal shows “File location shown on another device”. Desktop clients stay silent on success, including narrow browser windows and installed desktop PWAs. Every client receives a failure toast when the Host refuses or cannot be reached. Success means the Host accepted the native operation, not that the destination application finished loading the file. The feedback uses the existing mobile-device detector rather than viewport width or installation mode. + +The **Open locally** shortcut captures the main Session's directory and the same remembered application as the header button. The effective binding appears in the button tooltip and `aria-keyshortcuts`; Web follows the [shortcut service’s platform defaults](../shortcuts/README.md). The command runs only while the Conversation is selected and the header button has a directory and installed application to open. An in-flight launch also blocks the command; its outcome follows the same feedback policy as pointer gestures. Pointer and keyboard gestures share the controller's launch state, so repeated gestures cannot launch twice or change the remembered choice mid-launch. File menus list the discovered associated applications without a separate “Open in default app” row. “Show file location” stays in a fixed footer separated from the scrolling applications. A successful query with only one available action renders a single button without a dropdown. Initial file-association loading uses a gray skeleton icon. When the Host identifies a default application the menu marks it “(default)” and the main button opens it; otherwise the first associated application takes that place, so the main button reveals the file only when no application is registered. Directory menus have no reveal row. Choosing a file application does not change the system default. An unpreviewable file uses the same menu in a 40px-high split button with an icon and action text; its label is “Open” or “Show file location” according to the default action. @@ -39,7 +41,7 @@ Mounted controls with the same association reader and file share one query and i Workspace context menus open the captured folder through the same directory application catalog as the Session header. File-tree directories expose native reveal and Copy folder path; they do not offer file preview or save. -Context menus contribute file preview, native application opening, original-file reveal, and URL navigation and copying through [ui-context-menu](../ui-context-menu/README.md). Session reveal follows the installed file-manager catalog and registers a structured Session contribution at order 500, between Copy session ID and Archive. Its failures use the shared Toast after dismissal. Native file actions resolve Session paths through authorized metadata before querying applications. Missing files disable path actions; image reveal requires an actual Host file and always targets that original. [ui-file-actions](../ui-file-actions/README.md) owns copy, download, save-as, and their completion feedback, including revealing the saved destination from its toast. +Context menus contribute file preview, native application opening, original-file reveal, and URL navigation and copying through [ui-context-menu](../ui-context-menu/README.md). Session reveal follows the installed file-manager catalog and registers a structured Session contribution at order 500, between Copy session ID and Archive. Native context-menu operations share the feature's overlay toast and mobile/desktop feedback policy after dismissal. Native file actions resolve Session paths through authorized metadata before querying applications. Missing files disable path actions; image reveal requires an actual Host file and always targets that original. [ui-file-actions](../ui-file-actions/README.md) owns copy, download, save-as, and their completion feedback, including revealing the saved destination from its toast. File menus copy absolute paths without line numbers; URL copying preserves query and fragment. Image files also expose image copy and original download, alongside the existing file actions. Successful downloads and saves show the shared Toast; picker cancellation is silent. @@ -51,9 +53,9 @@ File menus copy absolute paths without line numbers; URL copying preserves query
Implementation internals — click to expand -Both directory slots use `OpenInAppAction` with an explicit `absolutePath`. The `conversation.session.header.utilities` registration reads the Session cwd, while `sidebar.right.tab.files.actions` receives the displayed directory from its owner. The plugin registers the `open-in-app` dictionaries as one effect. A page-lifetime controller ([`src/client/controller.ts`](src/client/controller.ts)) owns the once-per-page availability read, the persisted choice snapshot store, and the launch POST; the component receives the shared sources through the inject `hooks` compartment, so every Session header shares one truth. Document-relative route forms and wire payload types come from the host package's browser-safe `@deepseek-ai/dsh-host-open-in-app/shared` subpath. The controller guards in-flight launches and publishes their captured directory and status; header controls derive delayed busy and transient error visuals from that source. +Both directory slots use `OpenInAppAction` with an explicit `absolutePath`. The `conversation.session.header.utilities` registration reads the Session cwd, while `sidebar.right.tab.files.actions` receives the displayed directory from its owner. The plugin registers the `open-in-app` dictionaries as one effect. A page-lifetime controller ([`src/client/controller.ts`](src/client/controller.ts)) owns the once-per-page availability read, the persisted choice snapshot store, and the launch POST; the component receives the shared sources through the inject `hooks` compartment, so every Session header shares one truth. Document-relative route forms and wire payload types come from the host package's browser-safe `@deepseek-ai/dsh-host-open-in-app/shared` subpath. The controller guards in-flight launches and publishes the captured directory and busy state. Adapters resolve native and transport failures to named outcomes; ignored or cancelled requests return a skipped outcome and announce nothing. -The directory and file adapters supply application metadata and operations to [`OpenTargetButton`](src/client/OpenTargetButton.tsx), which owns menu ordering, default markers, icons, sizing, and gesture feedback. The file header and empty state share `FileOpenTarget`, while `OpenPathInjected.applications` queries `session.workspacePathApplications` through [`open-path.ts`](src/client/open-path.ts). Opening uses `session.openWorkspacePath`; the Host revalidates an explicitly selected handler before launch. The directory adapter keeps the existing catalog routes. `FileRouteAction` supplies the same control to delivery cards and change review through `deliverables.file.actions` and `deliverables.review.file.actions`; their authenticated routes retain Session file authorization. A failed or unavailable file query therefore needs no platform-specific UI implementation. +The directory and file adapters supply application metadata and operations to [`OpenTargetButton`](src/client/OpenTargetButton.tsx), which owns menu ordering, default markers, icons, and sizing. Its injected reporter sends outcomes to one feature-owned `shell.overlay` toast, so switching Sessions or closing the initiating preview cannot discard the result. The file header and empty state share `FileOpenTarget`, while `OpenPathInjected.applications` queries `session.workspacePathApplications` through [`open-path.ts`](src/client/open-path.ts). Opening uses `session.openWorkspacePath`; the Host revalidates an explicitly selected handler before launch. The directory adapter keeps the existing catalog routes. `FileRouteAction` supplies the same control to delivery cards and change review through `deliverables.file.actions` and `deliverables.review.file.actions`; their authenticated routes retain Session file authorization. A failed or unavailable file query therefore needs no platform-specific UI implementation.
diff --git a/packages/client/ui-open-in-app/README.zh.md b/packages/client/ui-open-in-app/README.zh.md index 8286533716..775efac9d8 100644 --- a/packages/client/ui-open-in-app/README.zh.md +++ b/packages/client/ui-open-in-app/README.zh.md @@ -31,7 +31,9 @@ kind: "package-reference" 会话标题栏、文件树标题栏和文档标题栏共用同一个高 24px、圆角 9px 的分体按钮。这些标题栏都只显示图标,悬停提示显示默认应用名称或文件定位动作。它们都显示默认动作的图标,在菜单的默认应用后标注“(默认)”,仅在自己的操作执行期间禁用,并通过短暂提示报告失败。文件树打开按钮以 `absolutePath` 传入的显示目录为目标,并与会话头部共享应用选择和启动状态。目录适配器使用已有的跨平台应用列表,将最后一次成功选择保存在 `dsh.open-in-app.choice` 中;原选择不可用时回退到第一个可用应用。 -**在本地打开**快捷键捕获主会话的目录,并使用与头部按钮相同的已记住应用。按钮 tooltip 和 `aria-keyshortcuts` 显示当前有效绑定;Web 遵循[快捷键服务的平台默认值](../shortcuts/README.zh.md)。只有选中会话界面、且头部按钮有可打开的目录和已安装应用时,命令才执行。启动尚未结束时也会阻止命令。鼠标与键盘操作共享 controller 的启动状态,重复操作不会启动两次,也不会在启动期间更改已记住的选择。 +在 iOS、Android 手机和平板上(包括已安装的 PWA),原生打开获得确认后显示「已在其它设备打开」,文件定位则显示「已在其它设备显示文件位置」。电脑端成功时不提示,包括窄窗口浏览器与已安装的桌面 PWA。Host 拒绝请求或无法连通时,所有客户端都会收到失败 toast。成功表示 Host 已接受原生操作,不代表目标应用已完成文件加载。反馈复用现有移动设备判定,不依据窗口宽度或安装模式。 + +**在本地打开**快捷键捕获主会话的目录,并使用与头部按钮相同的已记住应用。按钮 tooltip 和 `aria-keyshortcuts` 显示当前有效绑定;Web 遵循[快捷键服务的平台默认值](../shortcuts/README.zh.md)。只有选中会话界面、且头部按钮有可打开的目录和已安装应用时,命令才执行。启动尚未结束时也会阻止命令;其结果遵循与点击操作相同的反馈规则。鼠标与键盘操作共享 controller 的启动状态,重复操作不会启动两次,也不会在启动期间更改已记住的选择。 文件菜单列出已发现的关联应用,不单列“用默认应用打开”。“显示文件位置”固定在菜单底部,通过分隔线与滚动的应用列表分开。查询成功且只有一个可用操作时显示单按钮,不再显示下拉箭头。文件关联首次加载时使用灰色骨架图标。Host 识别出默认应用时,菜单将该项标注为“(默认)”,主按钮打开该应用;否则第一个关联应用占据该位置,只有在一个关联应用都没有时主按钮才执行文件定位。目录菜单不包含定位项。选择文件应用不修改系统默认应用。无法预览文件时,空态使用同一菜单,按钮增大到 40px 高并显示图标和动作文字;根据默认动作显示“打开”或“显示文件位置”。 @@ -39,7 +41,7 @@ kind: "package-reference" 工作区右键菜单通过与会话标题相同的目录应用列表打开被点击的文件夹。文件树目录提供本机显示位置和复制文件夹路径,不提供文件预览或保存。 -右键菜单通过 [ui-context-menu](../ui-context-menu/README.zh.md) 贡献文件预览、原生应用打开、原文件定位以及链接导航与复制。会话定位沿用已安装文件管理器目录,以顺序 500 注册为结构化 Session 贡献,位于复制 Session ID 与归档之间。失败提示通过共享 Toast 在菜单关闭后显示。原生文件操作先通过授权元数据解析会话路径,再查询关联应用。文件不存在时禁用路径操作;图片定位要求实际 Host 文件,并始终针对该原文件。[ui-file-actions](../ui-file-actions/README.zh.md) 负责复制、下载、另存为及其完成提示,包括通过提示中的操作定位保存目标。 +右键菜单通过 [ui-context-menu](../ui-context-menu/README.zh.md) 贡献文件预览、原生应用打开、原文件定位以及链接导航与复制。会话定位沿用已安装文件管理器目录,以顺序 500 注册为结构化 Session 贡献,位于复制 Session ID 与归档之间。右键原生操作在菜单关闭后,通过本功能的浮层 toast 沿用移动端与电脑端反馈规则。原生文件操作先通过授权元数据解析会话路径,再查询关联应用。文件不存在时禁用路径操作;图片定位要求实际 Host 文件,并始终针对该原文件。[ui-file-actions](../ui-file-actions/README.zh.md) 负责复制、下载、另存为及其完成提示,包括通过提示中的操作定位保存目标。 文件菜单复制不含行号的绝对路径;网址复制保留查询参数与锚点。图片文件在既有文件操作之外,还提供复制图片与下载原图。下载和保存成功后显示共享 Toast,取消选择器时不提示。 @@ -51,9 +53,9 @@ kind: "package-reference"
实现内幕——点击展开 -两个目录插槽均通过显式的 `absolutePath` 使用 `OpenInAppAction`。`conversation.session.header.utilities` 的注册函数读取会话 cwd,`sidebar.right.tab.files.actions` 则接收所属组件传入的显示目录。插件以一个 effect 注册 `open-in-app` 词典。一个页面生命周期的 controller([`src/client/controller.ts`](src/client/controller.ts))拥有每页一次的可用性读取、持久化选择的 snapshot store 与启动 POST;组件经 inject 的 `hooks` 隔间接收共享源,因此所有会话头部共享同一份事实。文档相对的路由形式与 wire 载荷类型来自主机包的浏览器安全子路径 `@deepseek-ai/dsh-host-open-in-app/shared`。controller 守卫执行中的启动,并发布所捕获的目录与状态;头部控件从该源派生延迟出现的等待态和短暂错误态。 +两个目录插槽均通过显式的 `absolutePath` 使用 `OpenInAppAction`。`conversation.session.header.utilities` 的注册函数读取会话 cwd,`sidebar.right.tab.files.actions` 则接收所属组件传入的显示目录。插件以一个 effect 注册 `open-in-app` 词典。一个页面生命周期的 controller([`src/client/controller.ts`](src/client/controller.ts))拥有每页一次的可用性读取、持久化选择的 snapshot store 与启动 POST;组件经 inject 的 `hooks` 隔间接收共享源,因此所有会话头部共享同一份事实。文档相对的路由形式与 wire 载荷类型来自主机包的浏览器安全子路径 `@deepseek-ai/dsh-host-open-in-app/shared`。controller 守卫执行中的启动,并发布所捕获的目录与等待状态。适配器将原生操作和传输失败解析为具名结果;被忽略或取消的请求返回跳过结果,不作播报。 -目录和文件适配器把应用信息与操作交给 [`OpenTargetButton`](src/client/OpenTargetButton.tsx),由它统一管理菜单顺序、默认标记、图标、尺寸和操作反馈。文件标题栏和空态共用 `FileOpenTarget`,`OpenPathInjected.applications` 通过 [`open-path.ts`](src/client/open-path.ts) 查询 `session.workspacePathApplications`。打开操作使用 `session.openWorkspacePath`,Host 在启动前重新验证指定的关联应用。`FileRouteAction` 通过 `deliverables.file.actions` 和 `deliverables.review.file.actions` 为交付卡片和变更对比页提供同一控件,其认证路由保留会话文件校验。目录适配器继续使用已有的应用列表路由,文件查询失败或不可用时无需增加平台专用的界面实现。 +目录和文件适配器把应用信息与操作交给 [`OpenTargetButton`](src/client/OpenTargetButton.tsx),由它统一管理菜单顺序、默认标记、图标和尺寸。注入的回调将结果交给本功能在 `shell.overlay` 中持有的唯一 toast,因此切换会话或关闭发起操作的预览不会丢失结果。文件标题栏和空态共用 `FileOpenTarget`,`OpenPathInjected.applications` 通过 [`open-path.ts`](src/client/open-path.ts) 查询 `session.workspacePathApplications`。打开操作使用 `session.openWorkspacePath`,Host 在启动前重新验证指定的关联应用。`FileRouteAction` 通过 `deliverables.file.actions` 和 `deliverables.review.file.actions` 为交付卡片和变更对比页提供同一控件,其认证路由保留会话文件校验。目录适配器继续使用已有的应用列表路由,文件查询失败或不可用时无需增加平台专用的界面实现。
diff --git a/packages/client/ui-open-in-app/src/client/FileRouteAction.tsx b/packages/client/ui-open-in-app/src/client/FileRouteAction.tsx index 49c27245f1..35a507422b 100644 --- a/packages/client/ui-open-in-app/src/client/FileRouteAction.tsx +++ b/packages/client/ui-open-in-app/src/client/FileRouteAction.tsx @@ -1,10 +1,10 @@ /** Adapt authorized delivery and changed-file routes to the shared opening control. */ -import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-deliverables/client' import { parseNativeFileApplications } from '@deepseek-ai/dsh-native-command/types' import type { SessionWorkspacePathApplication } from '@deepseek-ai/dsh-api-session-controller/types' import { useFileApplications } from './file-applications.ts' -import { OpenTargetButton } from './OpenTargetButton.tsx' +import { OpenTargetButton, type OpenTargetReporter } from './OpenTargetButton.tsx' import type { NS } from './locales.ts' async function queryRoute(url: string, signal: AbortSignal): Promise { @@ -20,18 +20,18 @@ async function queryRoute(url: string, signal: AbortSignal): Promise, 'actionUrl' | 'available' | 'pending' | 'onAction'> & PropsLocale, + props: Pick, 'actionUrl' | 'available' | 'pending' | 'onAction'> + & PropsLocale & InjectFace, ) { const association = useFileApplications(props.actionUrl, queryRoute, props.available) if (!props.available) return null return app.default)?.id} failed={association.failed} - loading={association.loading} busy={props.pending} refresh={association.refresh} t={props.t} - execute={async (operation) => { - return props.onAction(operation.kind === 'reveal' ? 'reveal' : 'open', operation.kind === 'application' ? operation.id : undefined) - }} /> + loading={association.loading} busy={props.pending} refresh={association.refresh} t={props.t} report={props.report} + execute={operation => props.onAction(operation.kind === 'reveal' ? 'reveal' : 'open', operation.kind === 'application' ? operation.id : undefined)} /> } diff --git a/packages/client/ui-open-in-app/src/client/OpenInAppAction.tsx b/packages/client/ui-open-in-app/src/client/OpenInAppAction.tsx index 9610a9c11c..ceb84fc611 100644 --- a/packages/client/ui-open-in-app/src/client/OpenInAppAction.tsx +++ b/packages/client/ui-open-in-app/src/client/OpenInAppAction.tsx @@ -2,19 +2,20 @@ import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import { APP_LABEL_KEY } from './applications.ts' import type { OpenInAppLaunchState } from './controller.ts' +import type { OpenInAppOutcome } from './outcome.ts' import type { ShortcutCatalogEntry } from '@deepseek-ai/dsh-client-shortcuts/client' import { NS } from './locales.ts' -import { OpenTargetButton } from './OpenTargetButton.tsx' +import { OpenTargetButton, type OpenTargetReporter } from './OpenTargetButton.tsx' /** Browser operations and state shared by the Session header and file tree contributions. */ -export interface OpenInAppActionInjected { +export interface OpenInAppActionInjected extends OpenTargetReporter { hooks: { openInAppApps: ObservableSnapshot openInAppChoice: ObservableSnapshot openInAppLaunch: ObservableSnapshot shortcuts: ObservableSnapshot } - launch: (appId: string, path: string) => Promise + launch: (appId: string, path: string) => Promise choose: (appId: string) => void iconUrl: (appId: string) => string } @@ -27,7 +28,7 @@ export type OpenInAppActionProps = /** * Adapt the installed directory catalog to the shared opening control. - * @param props - displayed directory, installed catalog, and launch operations. + * @param props - displayed directory, installed catalog, launch operations, and the announcement seat. * @returns the shared control, or null without an eligible application. */ export function OpenInAppAction(props: OpenInAppActionProps): React.JSX.Element | null { @@ -45,17 +46,12 @@ export function OpenInAppAction(props: OpenInAppActionProps): React.JSX.Element return ( { const id = operation.kind === 'application' ? operation.id : preferred.id - try { - await props.launch(id, absolutePath) - } catch (_error) { - // Native launch failure is announced by the shared control. - return 'openError' - } - if (operation.kind === 'application') props.choose(id) - return null + const outcome = await props.launch(id, absolutePath) + if (outcome.kind === 'opened' && operation.kind === 'application') props.choose(id) + return outcome }} /> ) diff --git a/packages/client/ui-open-in-app/src/client/OpenPathAction.tsx b/packages/client/ui-open-in-app/src/client/OpenPathAction.tsx index 2b6008d9b1..4111f70d84 100644 --- a/packages/client/ui-open-in-app/src/client/OpenPathAction.tsx +++ b/packages/client/ui-open-in-app/src/client/OpenPathAction.tsx @@ -5,18 +5,19 @@ import type { SessionWorkspacePathApplication } from '@deepseek-ai/dsh-api-sessi import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/client' -import type { OpenInAppPathAction, OpenInAppPathFailure } from './open-path.ts' +import type { OpenInAppPathAction } from './open-path.ts' +import type { OpenInAppOutcome } from './outcome.ts' import { useFileApplications } from './file-applications.ts' -import { OpenTargetButton } from './OpenTargetButton.tsx' +import { OpenTargetButton, type OpenTargetReporter } from './OpenTargetButton.tsx' import type { NS } from './locales.ts' /** Desktop availability and the gesture carrier injected into both path controls. */ -export interface OpenPathInjected { +export interface OpenPathInjected extends OpenTargetReporter { hooks: { openInAppDesktop: ObservableSnapshot } loadDesktop: () => Promise - openPath: (path: string, action: OpenInAppPathAction, application?: string) => Promise + openPath: (path: string, action: OpenInAppPathAction, application?: string) => Promise applications: (path: string, signal: AbortSignal) => Promise } @@ -27,7 +28,7 @@ export type OpenPathActionProps = & InjectFace /** File inputs shared by the document header and its unpreviewable state. */ -type FileOpenTargetProps = Pick & { +type FileOpenTargetProps = Pick & { empty?: boolean } @@ -49,7 +50,7 @@ export function FileOpenTarget(props: FileOpenTargetProps): ReactNode { key={props.absolutePath} kind="file" applications={apps} defaultId={apps.find(app => app.default)?.id} loading={association.loading} failed={association.failed} prominent={props.empty === true} t={props.t} - refresh={association.refresh} + refresh={association.refresh} report={props.report} execute={operation => props.openPath(props.absolutePath, operation.kind === 'reveal' ? 'reveal' : 'open', operation.kind === 'application' ? operation.id : undefined)} /> diff --git a/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx b/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx index 93d4c67625..2e8f882766 100644 --- a/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx +++ b/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx @@ -1,4 +1,4 @@ -/** Shared file and directory opener: one default action, application menu, and per-gesture feedback. */ +/** Shared file and directory opener: one default action, application menu, and one reported outcome per gesture. */ import { useRef, useState } from 'react' import type { ReactNode } from 'react' import { @@ -6,8 +6,7 @@ import { } from '@deepseek-ai/dsh-client-ui-primitives' import type { ShortcutCatalogEntry } from '@deepseek-ai/dsh-client-shortcuts/client' import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' -import type { OpenInAppPathFailure } from './open-path.ts' -import { useOpenFailureToast } from './open-failure-toast.tsx' +import type { OpenInAppOutcome } from './outcome.ts' import type { NS } from './locales.ts' import css from './OpenTargetButton.module.css' @@ -21,8 +20,14 @@ export interface OpenTargetApplication { /** Opening intent; default selection remains owned by the target's adapter. */ export type OpenTargetOperation = { readonly kind: 'default' } | { readonly kind: 'application'; readonly id: string } | { readonly kind: 'reveal' } +/** Seat a contribution sends one settled gesture to for app-wide announcement. */ +export interface OpenTargetReporter { + /** Report the settled outcome; `{ kind: 'skipped' }` announces nothing. */ + readonly report: (outcome: OpenInAppOutcome) => void +} + /** Inputs shared by both target adapters and the file empty-state action. */ -export interface OpenTargetButtonProps { +export interface OpenTargetButtonProps extends OpenTargetReporter { readonly shortcut?: Pick | undefined readonly kind: 'file' | 'directory' readonly applications: readonly OpenTargetApplication[] @@ -32,34 +37,33 @@ export interface OpenTargetButtonProps { readonly loading?: boolean readonly prominent?: boolean readonly t: TranslateNS - readonly execute: (operation: OpenTargetOperation) => Promise + /** Resolve every gesture to an outcome, including Host and transport failures; never reject. */ + readonly execute: (operation: OpenTargetOperation) => Promise readonly refresh?: () => void } /** - * Serialize gestures and announce their failures through the initiating control's toast. - * @param execute - target adapter that returns the failure to announce, or null. - * @param t - localized control copy. - * @returns the pending state, feedback, and guarded action callback. + * Serialize gestures and report the outcome each one settles on to the app-wide + * announcer, which outlives this control and the panel holding it. + * @param execute - target adapter that settles on the gesture's outcome. + * @param report - announcement seat the settled outcome is sent to. + * @returns the pending state and the guarded action callback. */ -export function useOpenTargetGesture(execute: OpenTargetButtonProps['execute'], t: TranslateNS): { +export function useOpenTargetGesture( + execute: OpenTargetButtonProps['execute'], report: OpenTargetButtonProps['report'], +): { pending: boolean - toast: ReactNode act: (operation: OpenTargetOperation) => void } { const [pending, setPending] = useState(false) const inFlight = useRef(false) - const { toast, show } = useOpenFailureToast() return { pending, - toast, act: (operation) => { if (inFlight.current) return inFlight.current = true setPending(true) - void execute(operation).then((failure) => { - if (failure !== null) show(t(`path.${failure}`)) - }).finally(() => { inFlight.current = false; setPending(false) }) + void execute(operation).then(report).finally(() => { inFlight.current = false; setPending(false) }) }, } } @@ -76,12 +80,12 @@ function ApplicationIcon({ source, size = 14 }: { source: string | null; size?: * Render identical split buttons for files and directories. File reveal always * stays last; it is the default only when no application is registered. * @param props - target applications, default selection, and operations. - * @returns the control and its transient failure feedback. + * @returns the control alone: its settled outcomes are announced by the seat it reports to. */ export function OpenTargetButton(props: OpenTargetButtonProps): ReactNode { const { applications, defaultId, kind, t } = props const [menuOpen, setMenuOpen] = useState(false) - const { pending, toast, act } = useOpenTargetGesture(props.execute, t) + const { pending, act } = useOpenTargetGesture(props.execute, props.report) // Files open through an application whenever one is registered: the OS-marked // default when the Host reported one, else the first entry in the Shell's // preference order. Reveal is only promoted with no registered application, so @@ -107,54 +111,51 @@ export function OpenTargetButton(props: OpenTargetButtonProps): ReactNode { ? : return ( - <> - { setMenuOpen(false) }} - items={[ - ...applications.map(app => ({ - id: `app:${app.id}`, - icon: , - label: app.id === preferred?.id ? t('path.appDefault', { app: app.name }) : app.name, - })), - ...(props.failed ? [{ id: 'unavailable', label: t('path.appsError'), disabled: true }] : []), - ]} - footer={kind === 'file' ? [{ - id: 'reveal', icon: , - label: revealDefault ? t('path.appDefault', { app: t('path.reveal') }) : t('path.reveal'), - }] : []} - onSelect={(id) => { run(id === 'reveal' ? { kind: 'reveal' } : { kind: 'application', id: id.slice(4) }) }} - anchor={( -
- - - - {hasMenu && } -
- )} - /> - {toast} - + { setMenuOpen(false) }} + items={[ + ...applications.map(app => ({ + id: `app:${app.id}`, + icon: , + label: app.id === preferred?.id ? t('path.appDefault', { app: app.name }) : app.name, + })), + ...(props.failed ? [{ id: 'unavailable', label: t('path.appsError'), disabled: true }] : []), + ]} + footer={kind === 'file' ? [{ + id: 'reveal', icon: , + label: revealDefault ? t('path.appDefault', { app: t('path.reveal') }) : t('path.reveal'), + }] : []} + onSelect={(id) => { run(id === 'reveal' ? { kind: 'reveal' } : { kind: 'application', id: id.slice(4) }) }} + anchor={( +
+ + + + {hasMenu && } +
+ )} + /> ) } diff --git a/packages/client/ui-open-in-app/src/client/context-menus.tsx b/packages/client/ui-open-in-app/src/client/context-menus.tsx index 91cbd0f090..ab7e0a46e4 100644 --- a/packages/client/ui-open-in-app/src/client/context-menus.tsx +++ b/packages/client/ui-open-in-app/src/client/context-menus.tsx @@ -11,6 +11,7 @@ import type { OpenInAppController } from './controller.ts' import { fileAddressFor, resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path' import type { OpenInAppPathController } from './open-path.ts' import type { OpenInAppKey } from './locales.ts' +import type { OpenInAppToastSource } from './open-toast.tsx' /** * Register native opening, original-path reveal, and URL actions. @@ -18,17 +19,18 @@ import type { OpenInAppKey } from './locales.ts' * @param paths - existing native file actions. * @param t - localized action labels. * @param directories - installed directory applications. + * @param report - native operation feedback shared with the application controls. */ export function registerContextMenus( ctx: Context, paths: OpenInAppPathController, t: (key: OpenInAppKey, values?: Record) => string, directories: OpenInAppController, + report: OpenInAppToastSource['report'], ): void { const fileManager = () => t(directories.apps.getSnapshot()?.includes('finder') ? 'menu.finder' : 'menu.reveal') const open = async (path: string, action: 'open' | 'reveal', application?: string): Promise => { - const failure = await paths.openPath(path, action, application) - if (failure !== null) ctx.menus.notify(t(`path.${failure}`)) + report(await paths.openPath(path, action, application)) } const resolvePath = async (sessionId: SessionId, path: string, signal: AbortSignal): Promise => { try { @@ -49,7 +51,10 @@ export function registerContextMenus( const manager = directories.apps.getSnapshot()?.find(id => ['finder', 'explorer', 'filemanager'].includes(id)) return [{ id: 'reveal', icon: , label: fileManager(), disabled: manager === undefined, failureLabel: t('path.revealError'), - ...manager === undefined ? {} : { run: () => directories.launch(manager, path) } }] + ...manager === undefined ? {} : { run: async () => { + const outcome = await directories.launch(manager, path) + report(outcome.kind === 'failed' ? { kind: 'failed', failure: 'revealError' } : outcome) + } } }] }, }), 'open-in-app: Workspace context menu') ctx.effect(() => ctx.menus.register({ diff --git a/packages/client/ui-open-in-app/src/client/controller.ts b/packages/client/ui-open-in-app/src/client/controller.ts index d93148bab1..c2e25085e8 100644 --- a/packages/client/ui-open-in-app/src/client/controller.ts +++ b/packages/client/ui-open-in-app/src/client/controller.ts @@ -7,6 +7,7 @@ import { } from '@deepseek-ai/dsh-host-open-in-app/shared' import { APP_LABEL_KEY } from './applications.ts' +import type { OpenInAppOutcome } from './outcome.ts' /** Shared launch status for controls targeting the captured workspace path. */ export interface OpenInAppLaunchState { @@ -72,10 +73,12 @@ export class OpenInAppController { * @param appId - catalog id from the availability list. * @param path - the session's absolute workspace directory. * Concurrent gestures are ignored until the current Host request settles. - * @returns after the host acknowledged the launch; rejects on any failure. + * @returns the acknowledged handoff, or `{ kind: 'skipped' }` while another + * launch holds the operation, or a named failure when the Host refuses or + * cannot be reached. Host and transport failures resolve without rejecting. */ - async launch(appId: string, path: string): Promise { - if (this.operation.getSnapshot().phase === 'busy') return + async launch(appId: string, path: string): Promise { + if (this.operation.getSnapshot().phase === 'busy') return { kind: 'skipped' } this.operation.set({ phase: 'busy', path }) const body: OpenInAppOpenPayload = { app: appId, path } try { @@ -86,9 +89,11 @@ export class OpenInAppController { }) if (!response.ok) throw new Error(`open failed: HTTP ${String(response.status)}`) this.operation.set({ phase: 'idle', path }) - } catch (error) { + return { kind: 'opened' } + } catch (_error) { + // Refusal and transport failure share the same user-visible outcome. this.operation.set({ phase: 'error', path }) - throw error + return { kind: 'failed', failure: 'openError' } } } diff --git a/packages/client/ui-open-in-app/src/client/index.ts b/packages/client/ui-open-in-app/src/client/index.ts index ea77587ab3..2b604f32bc 100644 --- a/packages/client/ui-open-in-app/src/client/index.ts +++ b/packages/client/ui-open-in-app/src/client/index.ts @@ -28,7 +28,9 @@ import { OpenInAppPathController } from './open-path.ts' import { OpenPathAction, type OpenPathInjected } from './OpenPathAction.tsx' import { FileRouteAction } from './FileRouteAction.tsx' import { OpenPathEmptyAction } from './OpenPathEmptyAction.tsx' +import type { OpenTargetReporter } from './OpenTargetButton.tsx' import { en, ja, es, fr, de, ru, pt, NS, zh, type OpenInAppKey } from './locales.ts' +import { createOpenInAppToastSource, OpenInAppToast } from './open-toast.tsx' declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { @@ -38,7 +40,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { } export type { OpenInAppActionInjected, OpenInAppActionProps } from './OpenInAppAction.tsx' -export type { OpenInAppPathAction, OpenInAppPathFailure, OpenInAppPathRemote } from './open-path.ts' +export type { OpenInAppPathAction, OpenInAppPathRemote } from './open-path.ts' +export type { OpenInAppOutcome, OpenInAppPathFailure } from './outcome.ts' export type { OpenPathActionProps, OpenPathInjected } from './OpenPathAction.tsx' export type { OpenPathEmptyActionProps } from './OpenPathEmptyAction.tsx' @@ -56,8 +59,14 @@ export function apply(ctx: ClientContext): void { const paths = new OpenInAppPathController(ctx.remote) ctx.effect(() => ctx.locale.register(NS, { zh, en, ja, es, fr, de, ru, pt }), 'open-in-app: dictionaries') const t = ctx.locale.bind(NS) - registerContextMenus(ctx, paths, t, controller) - registerSessionReveal(ctx, controller, t) + // Keep operation feedback visible after its initiating panel closes. + const announcement = createOpenInAppToastSource() + registerContextMenus(ctx, paths, t, controller, announcement.report) + registerSessionReveal(ctx, controller, t, announcement.report) + ctx.slots.inject('shell.overlay', () => ctx.slots.register({ + name: 'shell.overlay', id: 'open-in-app.toast', locale: NS, + inject: () => ({ hooks: announcement.hooks, dismiss: announcement.dismiss }), + }, OpenInAppToast)) const target = () => { if (ctx.layout.panelInfo.getSnapshot().activePanelId !== null) return undefined const session = Object.values(ctx.sessions.list.getSnapshot().byId) @@ -80,9 +89,7 @@ export function apply(ctx: ClientContext): void { const selected = target() if (selected === undefined) return { status: 'blocked', reason: t('shortcut.unavailable') } return { status: 'handled', run: () => { - void controller.launch(selected.appId, selected.path).catch((error: unknown) => { - console.warn('workspace open rejected:', error) - }) + void controller.launch(selected.appId, selected.path).then(announcement.report) } } }, }), 'open-in-app: workspace command') @@ -94,6 +101,7 @@ export function apply(ctx: ClientContext): void { shortcuts: ctx.shortcuts.catalog, }, launch: (appId, path) => controller.launch(appId, path), + report: announcement.report, choose: (appId) => { controller.choose(appId) }, iconUrl: appId => `${OPEN_IN_APP_ICON_PREFIX_ROUTE}/${appId}`, }) @@ -118,6 +126,7 @@ export function apply(ctx: ClientContext): void { loadDesktop: () => paths.load(), openPath: (path, action, application) => paths.openPath(path, action, application), applications, + report: announcement.report, }) ctx.slots.inject('sidebar.right.tab.document.actions', () => ctx.slots.register({ name: 'sidebar.right.tab.document.actions', @@ -131,10 +140,11 @@ export function apply(ctx: ClientContext): void { locale: NS, inject: pathInjected, }, OpenPathEmptyAction)) + const routeInjected = (): OpenTargetReporter => ({ report: announcement.report }) ctx.slots.inject('deliverables.file.actions', () => ctx.slots.register({ - name: 'deliverables.file.actions', id: 'open-in-app', locale: NS, + name: 'deliverables.file.actions', id: 'open-in-app', locale: NS, inject: routeInjected, }, FileRouteAction)) ctx.slots.inject('deliverables.review.file.actions', () => ctx.slots.register({ - name: 'deliverables.review.file.actions', id: 'open-in-app', locale: NS, + name: 'deliverables.review.file.actions', id: 'open-in-app', locale: NS, inject: routeInjected, }, FileRouteAction)) } diff --git a/packages/client/ui-open-in-app/src/client/locales.ts b/packages/client/ui-open-in-app/src/client/locales.ts index 3609edf2c5..039e8cbbb5 100644 --- a/packages/client/ui-open-in-app/src/client/locales.ts +++ b/packages/client/ui-open-in-app/src/client/locales.ts @@ -59,6 +59,8 @@ export const zh = { 'path.open': '打开', 'path.more': '更多打开方式', 'path.reveal': '显示文件位置', + 'path.opened': '已在其它设备打开', + 'path.revealed': '已在其它设备显示文件位置', 'path.openError': '打开失败,请重试', 'path.revealError': '无法显示文件位置,请重试', ...PRODUCT_NAMES, @@ -90,6 +92,8 @@ export const en: Record = { 'path.open': 'Open', 'path.more': 'More ways to open', 'path.reveal': 'Show file location', + 'path.opened': 'Opened on another device', + 'path.revealed': 'File location shown on another device', 'path.openError': 'Could not open. Try again.', 'path.revealError': 'Could not show the file location. Try again.', ...PRODUCT_NAMES, @@ -125,6 +129,8 @@ export const ja = { 'path.open': '開く', 'path.more': 'その他の開き方', 'path.reveal': 'ファイルの場所を表示', + 'path.opened': '別のデバイスで開きました', + 'path.revealed': '別のデバイスでファイルの場所を表示しました', 'path.openError': '開けませんでした。もう一度お試しください。', 'path.revealError': 'ファイルの場所を表示できませんでした。もう一度お試しください。', 'app.finder': 'Finder', @@ -154,6 +160,8 @@ export const es = { 'path.open': 'Abrir', 'path.more': 'Más formas de abrir', 'path.reveal': 'Mostrar ubicación del archivo', + 'path.opened': 'Abierto en otro dispositivo', + 'path.revealed': 'Ubicación del archivo mostrada en otro dispositivo', 'path.openError': 'No se pudo abrir. Intentar otra vez.', 'path.revealError': 'No se pudo mostrar la ubicación del archivo. Intentar otra vez.', ...PRODUCT_NAMES, @@ -184,6 +192,8 @@ export const fr = { 'path.open': 'Ouvrir', 'path.more': "Plus de façons d'ouvrir", 'path.reveal': "Afficher l'emplacement du fichier", + 'path.opened': 'Ouvert sur un autre appareil', + 'path.revealed': 'Emplacement du fichier affiché sur un autre appareil', 'path.openError': "Impossible d'ouvrir. Essayer à nouveau.", 'path.revealError': "Impossible d'afficher l'emplacement du fichier. Essayer à nouveau.", ...PRODUCT_NAMES, @@ -214,6 +224,8 @@ export const de = { 'path.open': 'Öffnen', 'path.more': 'Mehr Möglichkeiten zum Öffnen', 'path.reveal': 'Dateispeicherort anzeigen', + 'path.opened': 'Auf einem anderen Gerät geöffnet', + 'path.revealed': 'Dateispeicherort auf einem anderen Gerät angezeigt', 'path.openError': 'Konnte nicht geöffnet werden. Versuchen Sie es erneut.', 'path.revealError': 'Der Dateispeicherort konnte nicht angezeigt werden. Versuchen Sie es erneut.', ...PRODUCT_NAMES, @@ -244,6 +256,8 @@ export const ru = { 'path.open': 'Открыть', 'path.more': 'Больше способов открыть', 'path.reveal': 'Показать местоположение файла', + 'path.opened': 'Открыто на другом устройстве', + 'path.revealed': 'Местоположение файла показано на другом устройстве', 'path.openError': 'Не удалось открыть. Попробуйте еще раз.', 'path.revealError': 'Не удалось показать местоположение файла. Попробуйте еще раз.', ...PRODUCT_NAMES, @@ -274,6 +288,8 @@ export const pt = { 'path.open': 'Abrir', 'path.more': 'Mais maneiras de abrir', 'path.reveal': 'Mostrar localização do arquivo', + 'path.opened': 'Aberto em outro dispositivo', + 'path.revealed': 'Localização do arquivo exibida em outro dispositivo', 'path.openError': 'Não foi possível abrir. Tente novamente.', 'path.revealError': 'Não foi possível mostrar o local do arquivo. Tente novamente.', ...PRODUCT_NAMES, diff --git a/packages/client/ui-open-in-app/src/client/open-failure-toast.tsx b/packages/client/ui-open-in-app/src/client/open-failure-toast.tsx deleted file mode 100644 index e9bfa51ca4..0000000000 --- a/packages/client/ui-open-in-app/src/client/open-failure-toast.tsx +++ /dev/null @@ -1,26 +0,0 @@ -/** Per-control failure banner for path gestures. */ -import { useCallback, useRef, useState } from 'react' -import type { ReactNode } from 'react' -import { IconWarningOutlineRegular, Toast } from '@deepseek-ai/dsh-client-ui-primitives' - -/** - * Transient failure banner owned by the control that initiated the gesture, - * so one failed request announces once, from the control the user pressed. - * @returns `toast` owned by the control and `show` to announce one - * failure with resolved copy; announcing again replays the banner. - */ -export function useOpenFailureToast(): { toast: ReactNode; show: (text: string) => void } { - // The seq keys the banner so the same failure text replays instead of staying silently in place. - const seq = useRef(0) - const [banner, setBanner] = useState<{ seq: number; text: string } | null>(null) - const dismiss = useCallback(() => { setBanner(null) }, []) - return { - toast: banner === null - ? null - : } onDone={dismiss} />, - show: (text) => { - seq.current += 1 - setBanner({ seq: seq.current, text }) - }, - } -} diff --git a/packages/client/ui-open-in-app/src/client/open-path.ts b/packages/client/ui-open-in-app/src/client/open-path.ts index e403a80da9..4484bb29b9 100644 --- a/packages/client/ui-open-in-app/src/client/open-path.ts +++ b/packages/client/ui-open-in-app/src/client/open-path.ts @@ -5,13 +5,11 @@ import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client' import type { SessionOpenWorkspacePathRequest, SessionWorkspacePathApplication } from '@deepseek-ai/dsh-api-session-controller/types' +import type { OpenInAppOutcome, OpenInAppPathFailure } from './outcome.ts' /** What a path gesture asks of the Host desktop: the default application, or the file manager showing the file. */ export type OpenInAppPathAction = 'open' | 'reveal' -/** Failure kind of one settled path gesture, for the initiating control to announce. */ -export type OpenInAppPathFailure = 'openError' | 'revealError' - /** The slice of the Client Remote the path actions call. */ export interface OpenInAppPathRemote { readonly session: { @@ -51,9 +49,9 @@ export class OpenInAppPathController { * @param path - absolute path on the Host, as the file's metadata reports it. * @param action - default application open, or file-manager reveal. * @param application - registered application path for an explicit open. - * @returns the failure kind to announce, or `null` once the Host acknowledged. + * @returns the acknowledged open or reveal, or the named failure to announce. */ - async openPath(path: string, action: OpenInAppPathAction, application?: string): Promise { + async openPath(path: string, action: OpenInAppPathAction, application?: string): Promise { const request: SessionOpenWorkspacePathRequest = action === 'reveal' ? { path, action } : { path, ...(application === undefined ? {} : { application }) } let ok = false try { @@ -62,7 +60,9 @@ export class OpenInAppPathController { // Swallows carrier rejections: a Host that cannot be reached failed the // gesture like a Host that refused it, and the control announces that. } - return ok ? null : action === 'open' ? 'openError' : 'revealError' + if (ok) return { kind: action === 'reveal' ? 'revealed' : 'opened' } + const failure: OpenInAppPathFailure = action === 'open' ? 'openError' : 'revealError' + return { kind: 'failed', failure } } /** diff --git a/packages/client/ui-open-in-app/src/client/open-toast.tsx b/packages/client/ui-open-in-app/src/client/open-toast.tsx new file mode 100644 index 0000000000..b711f6ecf8 --- /dev/null +++ b/packages/client/ui-open-in-app/src/client/open-toast.tsx @@ -0,0 +1,65 @@ +/** Native-open feedback that survives the initiating panel: mobile success and all failures. */ +import type { ReactNode } from 'react' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { IconWarningOutlineRegular, isMobileDevice, Toast } from '@deepseek-ai/dsh-client-ui-primitives' +import type { HostObservable, InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from '@deepseek-ai/dsh-client-ui-layout/client' +import { NS } from './locales.ts' +import type { OpenInAppOutcome } from './outcome.ts' + +/** One announced gesture; `seq` keys the banner so a repeat restarts it. */ +export type OpenInAppToastState = Exclude & { readonly seq: number } + +/** Store every contribution reports into and the overlay entry reads. */ +export interface OpenInAppToastSource { + /** Current notice for the overlay entry, or null while none shows. */ + readonly hooks: { readonly toast: HostObservable } + /** + * Announce failures on every device and acknowledged opens or reveals on mobile. + * Desktop successes and skipped gestures leave the current notice unchanged. + * @param outcome - settled native-open result. + */ + readonly report: (outcome: OpenInAppOutcome) => void + /** Clear the shown notice. */ + readonly dismiss: () => void +} + +/** + * Create the one outcome store the overlay entry shows. + * @returns the observable notice with its report and dismiss actions. + */ +export function createOpenInAppToastSource(): OpenInAppToastSource { + const store = createSnapshotStore(null) + let seq = 0 + return { + hooks: { toast: store }, + report: (outcome) => { + if (outcome.kind === 'skipped') return + if (outcome.kind !== 'failed' && !isMobileDevice()) return + seq += 1 + store.set({ ...outcome, seq }) + }, + dismiss: () => { store.set(null) }, + } +} + +/** Props of the `shell.overlay` entry: the notice store, its dismissal, and localized copy. */ +export type OpenInAppToastProps = PropsRuntime<'shell.overlay'> + & PropsLocale + & InjectFace> + +/** + * Render a mobile handoff confirmation or a native-open failure. + * @param props - the notice hook, its dismissal, and the locale seat. + * @returns the banner on display, or null. + */ +export function OpenInAppToast({ useToast, dismiss, t }: OpenInAppToastProps): ReactNode { + const toast = useToast(current => current) + if (toast === null) return null + const key = `open-in-app-${String(toast.seq)}` + if (toast.kind === 'failed') { + return } onDone={dismiss} /> + } + return +} diff --git a/packages/client/ui-open-in-app/src/client/outcome.ts b/packages/client/ui-open-in-app/src/client/outcome.ts new file mode 100644 index 0000000000..08dd76f795 --- /dev/null +++ b/packages/client/ui-open-in-app/src/client/outcome.ts @@ -0,0 +1,8 @@ +/** Settled native gestures shared by directory, path, and delivery controls. */ +import type { PresentedOpenFailure, PresentedOpenOutcome } from '@deepseek-ai/dsh-client-ui-deliverables/client' + +/** Failure kind of one settled gesture, keyed to the `path.*` copy it announces. */ +export type OpenInAppPathFailure = PresentedOpenFailure + +/** Acknowledged open or reveal, named failure, or deliberately skipped gesture. */ +export type OpenInAppOutcome = PresentedOpenOutcome diff --git a/packages/client/ui-open-in-app/src/client/session-reveal.tsx b/packages/client/ui-open-in-app/src/client/session-reveal.tsx index e002e3ab4e..c89297f6d9 100644 --- a/packages/client/ui-open-in-app/src/client/session-reveal.tsx +++ b/packages/client/ui-open-in-app/src/client/session-reveal.tsx @@ -3,14 +3,21 @@ import { IconFolderOpenOutlineRegular } from '@deepseek-ai/dsh-client-ui-primiti import type { Context } from '@deepseek-ai/cordis' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { OpenInAppController } from './controller.ts' +import type { OpenInAppToastSource } from './open-toast.tsx' /** * Share directory availability and launch feedback with the existing application controls. * @param ctx - owning plugin context. * @param controller - directory application catalog and launcher. * @param t - localized action and failure labels. + * @param report - native operation feedback shared with the application controls. */ -export function registerSessionReveal(ctx: Context, controller: OpenInAppController, t: PropsLocale<'open-in-app'>['t']): void { +export function registerSessionReveal( + ctx: Context, + controller: OpenInAppController, + t: PropsLocale<'open-in-app'>['t'], + report: OpenInAppToastSource['report'], +): void { ctx.effect(() => ctx.menus.register({ id: 'open-in-app.reveal', target: 'session', order: 500, observe: () => [controller.apps, ctx.sessions.list, ctx.locale], @@ -21,7 +28,10 @@ export function registerSessionReveal(ctx: Context, controller: OpenInAppControl return [{ id: 'reveal', label: t(applications?.includes('finder') ? 'menu.finder' : 'menu.reveal'), icon: , disabled: !directory || manager === undefined, failureLabel: t('path.revealError'), - ...(directory && manager !== undefined ? { run: () => controller.launch(manager, directory) } : {}), + ...(directory && manager !== undefined ? { run: async () => { + const outcome = await controller.launch(manager, directory) + report(outcome.kind === 'failed' ? { kind: 'failed', failure: 'revealError' } : outcome) + } } : {}), }] }, }), 'open-in-app: Session directory menu') diff --git a/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts b/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts index 57dc2277cb..e7f4e4a015 100644 --- a/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts @@ -1,5 +1,9 @@ // @vitest-environment jsdom +import { createElement } from 'react' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { isMobileDevice } from '@deepseek-ai/dsh-client-ui-primitives' /** * Browser-half lifecycle over the real SlotRegistry: the dictionary, * header-slot, and document-preview path registrations with fiber teardown @@ -11,7 +15,7 @@ import type { ShortcutCommand } from '@deepseek-ai/dsh-client-shortcuts/client' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { Context } from '@deepseek-ai/cordis' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { LayoutController, type MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client' @@ -23,9 +27,21 @@ import { OpenInAppAction } from '../src/client/OpenInAppAction.tsx' import { OpenPathAction } from '../src/client/OpenPathAction.tsx' import { OpenPathEmptyAction } from '../src/client/OpenPathEmptyAction.tsx' import { MenuRegistry } from '@deepseek-ai/dsh-client-ui-context-menu/src/client/registry.ts' +import type { OpenTargetReporter } from '../src/client/OpenTargetButton.tsx' +import { FileRouteAction } from '../src/client/FileRouteAction.tsx' +import type { OpenInAppOutcome } from '../src/client/outcome.ts' +import { OpenInAppToast, type OpenInAppToastProps, type OpenInAppToastSource } from '../src/client/open-toast.tsx' import { en, NS, zh } from '../src/client/locales.ts' +vi.mock('@deepseek-ai/dsh-client-ui-primitives', async original => ({ + ...await original(), + isMobileDevice: vi.fn(() => false), +})) + +beforeEach(() => { vi.mocked(isMobileDevice).mockReturnValue(false) }) + afterEach(() => { + cleanup() vi.unstubAllGlobals() vi.clearAllMocks() }) @@ -53,6 +69,7 @@ async function bench() { 'sidebar.right.tab.document.unpreviewable': { kind: 'list', scope: 'session' }, 'deliverables.file.actions': { kind: 'list', scope: 'session' }, 'deliverables.review.file.actions': { kind: 'list', scope: 'session' }, + 'shell.overlay': { kind: 'list', scope: 'root' }, }, } as never, () => null) const list = createSnapshotStore({ ids: [], byId: {}, phase: 'ready', projectionsBySession: {} }) @@ -85,10 +102,23 @@ function headerEntryIds(ctx: Context): (string | undefined)[] { return ctx.slots.entries('conversation.session.header.utilities').map(entry => entry.options.id) } +/** The one announcement store the frame-wide overlay entry exposes. */ +function toastSource(ctx: Context): Omit { + const overlay = ctx.slots.entries('shell.overlay')[0] + if (overlay === undefined) throw new Error('expected the open-in-app overlay entry') + expect(overlay.component).toBe(OpenInAppToast) + expect(overlay.options).toMatchObject({ id: 'open-in-app.toast' }) + expect(overlay.locale).toBe(NS) + return (overlay.inject as () => Omit)() +} + describe('open-in-app browser half', () => { - it('orders Session reveal between copy ID and archive, reports failure, and unregisters on disposal', async () => { + it.each([ + [false, true], [true, true], [false, false], [true, false], + ])('shares native feedback after Session menu dismissal (mobile=%s, success=%s)', async (mobile, success) => { + vi.mocked(isMobileDevice).mockReturnValue(mobile) const fetcher = vi.fn(async (input: string | URL) => String(input) === 'open-in-app/apps' - ? new Response(JSON.stringify({ apps: ['finder'] })) : new Response('', { status: 502 })) + ? new Response(JSON.stringify({ apps: ['finder'] })) : new Response('', { status: success ? 200 : 502 })) vi.stubGlobal('fetch', fetcher) const { ctx, fiber, list, notify } = await bench() const id = 'captured' as SessionId @@ -96,11 +126,18 @@ describe('open-in-app browser half', () => { [id]: { id, displayTitle: 'Captured', cwd: '/captured', running: false, blank: false, updatedAt: 0, retainedBy: {} }, } }) const menu = ctx.menus.resolve({ kind: 'session', value: { sessionId: id, displayTitle: 'Captured' } }) + const toast = toastSource(ctx) try { expect(ctx.slots.entries('sidebar.workspaces.session.menu.item')).toHaveLength(0) await vi.waitFor(() => { expect(menu.source.getSnapshot()[0]).toMatchObject({ label: 'Show in Finder', disabled: false }) }) menu.select('open-in-app.reveal/reveal') - await vi.waitFor(() => { expect(notify).toHaveBeenCalledWith(en['path.revealError']) }) + menu.dispose() + const face: Partial = ctx.slots.entries('conversation.session.header.utilities')[0]!.inject!() + await vi.waitFor(() => { expect(face.hooks?.openInAppLaunch.getSnapshot().phase).toBe(success ? 'idle' : 'error') }) + expect(toast.hooks.toast.getSnapshot()).toEqual(success + ? mobile ? { kind: 'opened', seq: 1 } : null + : { kind: 'failed', failure: 'revealError', seq: 1 }) + expect(notify).not.toHaveBeenCalled() expect(fetcher).toHaveBeenCalledWith('open-in-app/open', expect.objectContaining({ body: JSON.stringify({ app: 'finder', path: '/captured' }), })) @@ -109,14 +146,13 @@ describe('open-in-app browser half', () => { expect(removed.source.getSnapshot()).toEqual([]) removed.dispose() }) - it('refuses absent workspaces and reports failed keyboard launches', async () => { + it('refuses absent workspaces and reports failed keyboard launches to the app-wide toast', async () => { vi.stubGlobal('fetch', vi.fn(async (input: string | URL) => String(input) === 'open-in-app/apps' ? new Response(JSON.stringify({ apps: ['finder'] })) : new Response('failed', { status: 500 }))) - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const { fiber, list, commands } = await bench() + const { ctx, fiber, list, commands } = await bench() try { const command = commands.get('workspace.openLocal')! - expect(command.label()).toBe('Open locally') + expect(command.label()).toBe(en['open.tooltip']) const context = { region: 'page', modal: null, target: null } as const expect(command.resolve(context).status).toBe('blocked') const id = 'main' as SessionId @@ -127,9 +163,14 @@ describe('open-in-app browser half', () => { await vi.waitFor(() => { expect(command.resolve(context).status).toBe('handled') }) const result = command.resolve(context) if (result.status !== 'handled') throw new Error('Expected an available workspace') + const toast = toastSource(ctx) + expect(toast.hooks.toast.getSnapshot()).toBeNull() result.run() - await vi.waitFor(() => { expect(warn).toHaveBeenCalledWith('workspace open rejected:', expect.any(Error)) }) - } finally { await fiber.dispose(); warn.mockRestore() } + // The keyboard path has no control of its own: its failure reaches the same overlay store. + await vi.waitFor(() => { + expect(toast.hooks.toast.getSnapshot()).toEqual({ kind: 'failed', failure: 'openError', seq: 1 }) + }) + } finally { await fiber.dispose() } }) it('captures the main directory and remembered app before dispatch and shares pointer launch occupancy', async () => { let finish!: (response: Response) => void @@ -155,7 +196,8 @@ describe('open-in-app browser half', () => { if (resolution.status !== 'handled') throw new Error('available workspace was blocked') resolution.run() expect(command.resolve(context).status).toBe('blocked') - await face.launch('finder', '/second') + // A gesture another launch already holds reports deliberate silence, not a second success. + await expect(face.launch('finder', '/second')).resolves.toEqual({ kind: 'skipped' }) const calls = fetcher.mock.calls.filter(call => call[0] === 'open-in-app/open') expect(calls).toHaveLength(1) expect(calls[0]?.[1]?.body).toBe(JSON.stringify({ app: 'cursor', path: '/first' })) @@ -165,6 +207,52 @@ describe('open-in-app browser half', () => { expect(commands.size).toBe(0) }) + it.each([ + ['mobile', true, 'open', { kind: 'opened' }, 'path.opened'], + ['mobile', true, 'reveal', { kind: 'revealed' }, 'path.revealed'], + ['mobile', true, 'open', { kind: 'failed', failure: 'openError' }, 'path.openError'], + ['mobile', true, 'reveal', { kind: 'failed', failure: 'revealError' }, 'path.revealError'], + ['desktop', false, 'open', { kind: 'failed', failure: 'openError' }, 'path.openError'], + ['desktop', false, 'reveal', { kind: 'failed', failure: 'revealError' }, 'path.revealError'], + ] as const)('keeps %s feedback after the initiating card unmounts (mobile=%s, %s, %j)', async (_device, mobile, action, outcome, message) => { + vi.mocked(isMobileDevice).mockReturnValue(mobile) + vi.stubGlobal('fetch', vi.fn(async (input: string | URL) => String(input) === 'open-in-app/apps' + ? Response.json({ apps: [] }) + : Response.json([{ id: 'editor', name: 'Editor', default: true, icon: null }]))) + const { ctx, fiber } = await bench() + const pending = Promise.withResolvers() + try { + const entry = ctx.slots.entries('deliverables.file.actions')[0]! + const report = entry.inject!().report as OpenTargetReporter['report'] + const toast = toastSource(ctx) + render(createElement(OpenInAppToast, { + useToast: bindSnapshotSelector(toast.hooks.toast), dismiss: toast.dismiss, t: makeTranslate(en), + } as OpenInAppToastProps)) + const onAction = vi.fn(() => pending.promise) + const control = render(createElement(FileRouteAction, { + report, actionUrl: 'api/present.open?sessionId=s&seq=2&index=0', + available: true, pending: false, onAction, t: makeTranslate(en), + })) + await screen.findByRole('button', { name: 'Open in Editor' }) + if (action === 'reveal') { + fireEvent.click(screen.getByRole('button', { name: en['path.more'] })) + fireEvent.click(screen.getByRole('menuitem', { name: en['path.reveal'] })) + } else fireEvent.click(screen.getByRole('button', { name: 'Open in Editor' })) + expect(onAction).toHaveBeenCalledWith(action, undefined) + control.unmount() + expect(screen.queryByRole('button')).toBeNull() + expect(screen.queryByRole('alert')).toBeNull() + expect(ctx.slots.entries('shell.overlay')).toHaveLength(1) + await act(async () => { pending.resolve(outcome) }) + expect(screen.getByRole('alert').textContent).toBe(en[message]) + expect(toast.hooks.toast.getSnapshot()).toEqual({ ...outcome, seq: 1 }) + } finally { + await act(async () => { pending.resolve({ kind: 'skipped' }) }) + cleanup() + await fiber.dispose() + } + }) + it('declares the services it binds', () => { expect(inject).toEqual(['menus', 'sidebarRight', 'sidebarRightTabs', 'sessions', 'slots', 'locale', 'remote', 'remote.session', 'remote.workspaceFiles', 'shortcuts', 'layout']) }) @@ -198,6 +286,7 @@ describe('open-in-app browser half', () => { resolution.run() await vi.waitFor(() => { expect(face.hooks.openInAppLaunch.getSnapshot().phase).toBe('idle') }) expect(fetcher.mock.calls.filter(([input]) => input === 'open-in-app/open')).toHaveLength(1) + expect(toastSource(ctx).hooks.toast.getSnapshot()).toBeNull() } finally { await fiber.dispose() } }) @@ -221,7 +310,7 @@ describe('open-in-app browser half', () => { await expect(face.applications('/w/clip.mp4', signal)).resolves.toEqual([]) expect(remote.session.workspacePathApplications).toHaveBeenCalledWith({ path: '/w/clip.mp4' }, signal) expect(face.hooks.openInAppDesktop.getSnapshot()).toBe(true) - expect(await face.openPath('/w/clip.mp4', 'reveal')).toBeNull() + expect(await face.openPath('/w/clip.mp4', 'reveal')).toEqual({ kind: 'revealed' }) expect(remote.session.openWorkspacePath).toHaveBeenLastCalledWith({ path: '/w/clip.mp4', action: 'reveal' }) await fiber.dispose() expect(ctx.slots.entries('sidebar.right.tab.document.actions').map(entry => entry.options.id)).not.toContain('open-in-app') @@ -229,6 +318,18 @@ describe('open-in-app browser half', () => { expect(ctx.slots.entries('deliverables.file.actions')).toHaveLength(0) }) + it('injects the announcement seat into the delivery entries and releases every entry with the fiber', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ apps: [] }), { status: 200 }))) + const { ctx, fiber } = await bench() + const entry = ctx.slots.entries('deliverables.review.file.actions')[0] + const face = (entry?.inject as () => OpenTargetReporter & Record)() + face.report({ kind: 'failed', failure: 'revealError' }) + expect(toastSource(ctx).hooks.toast.getSnapshot()).toEqual({ kind: 'failed', failure: 'revealError', seq: 1 }) + await fiber.dispose() + expect(ctx.slots.entries('deliverables.review.file.actions')).toHaveLength(0) + expect(ctx.slots.entries('shell.overlay')).toHaveLength(0) + }) + it('adapts the Session directory, shares the controller with the file tree, and removes both on teardown', async () => { vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ apps: [] }), { status: 200 }))) const { ctx, fiber, list } = await bench() @@ -270,7 +371,7 @@ describe('open-in-app browser half', () => { const entry = ctx.slots.entries('conversation.session.header.utilities')[0] const injected: Partial | undefined = entry?.inject?.() if (injected?.hooks === undefined || injected.iconUrl === undefined - || injected.choose === undefined || injected.launch === undefined) { + || injected.choose === undefined || injected.launch === undefined || injected.report === undefined) { throw new Error('expected the injected open-in-app actions') } const { hooks } = injected @@ -283,7 +384,11 @@ describe('open-in-app browser half', () => { injected.choose('cursor') expect(hooks.openInAppChoice.getSnapshot()).toBe('cursor') - await injected.launch('cursor', '/w/dir') + const toast = toastSource(ctx) + injected.report({ kind: 'revealed' }) + expect(toast.hooks.toast.getSnapshot()).toBeNull() + + await expect(injected.launch('cursor', '/w/dir')).resolves.toEqual({ kind: 'opened' }) const openCall = fetcher.mock.calls.find(call => call[0] === 'open-in-app/open') expect(openCall?.[1]).toMatchObject({ method: 'POST', @@ -293,7 +398,7 @@ describe('open-in-app browser half', () => { await fiber.dispose() }) - it('publishes an empty availability list when the host read fails, and launches reject on HTTP errors', async () => { + it('publishes an empty availability list when the host read fails, and launches return named failures on HTTP errors', async () => { vi.stubGlobal('fetch', vi.fn(async (input: string | URL) => { if (String(input) === 'open-in-app/apps') throw new Error('down') return new Response('', { status: 502 }) @@ -306,7 +411,7 @@ describe('open-in-app browser half', () => { await vi.waitFor(() => { expect(hooks.openInAppApps.getSnapshot()).toEqual([]) }) - await expect(injected.launch('finder', '/w/dir')).rejects.toThrow('open failed: HTTP 502') + await expect(injected.launch('finder', '/w/dir')).resolves.toEqual({ kind: 'failed', failure: 'openError' }) await fiber.dispose() }) diff --git a/packages/client/ui-open-in-app/tests/context-menu-actions.client.spec.ts b/packages/client/ui-open-in-app/tests/context-menu-actions.client.spec.ts index cbea3fb5d7..ca38e1964f 100644 --- a/packages/client/ui-open-in-app/tests/context-menu-actions.client.spec.ts +++ b/packages/client/ui-open-in-app/tests/context-menu-actions.client.spec.ts @@ -12,6 +12,7 @@ import { OpenInAppPathController } from '../src/client/open-path.ts' import { OpenInAppController } from '../src/client/controller.ts' import { registerFileMenus } from '@deepseek-ai/dsh-client-ui-file-actions/src/client/context-menus.tsx' import { registerContextMenus } from '../src/client/context-menus.tsx' +import type { OpenInAppToastSource } from '../src/client/open-toast.tsx' const sessionId = SessionId('menu-actions') afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals() }) @@ -20,6 +21,7 @@ async function boot() { vi.stubGlobal('Blob', NodeBlob) const ctx = new Context() const notify = vi.fn(), failure = vi.fn(), writeText = vi.fn(async () => {}) + const report = vi.fn() vi.stubGlobal('navigator', { clipboard: { writeText } }) const registry = new MenuRegistry(failure) const remote = { @@ -52,7 +54,7 @@ async function boot() { ctx.provide('sidebarRight', sidebar as never) ctx.provide('sidebarRightTabs', { get: browserTab } as never) const fiber = ctx.plugin({ apply: (plugin: Context) => { - registerContextMenus(plugin, paths, key => key, directories) + registerContextMenus(plugin, paths, key => key, directories, report) registerFileMenus(plugin, ctx.fileActions, key => key) } }) await fiber.await() @@ -73,7 +75,7 @@ async function boot() { return action } return { registry, resolve, choose, remote, fileActions, paths, directories, directoryFetch, sessions, - sidebar, browserTab, notify, failure, writeText } + sidebar, browserTab, notify, failure, writeText, report } } it('routes file actions through the captured pane, line, native association and original bytes', async () => { @@ -84,8 +86,10 @@ it('routes file actions through the captured pane, line, native association and { paneId: 'pane', params: { line: 24 } }) await h.choose(file, 'open-in-app.file/open') expect(h.remote.session.openWorkspacePath).toHaveBeenCalledWith({ path: '/workspace/file.svg', application: 'preview' }) + await vi.waitFor(() => { expect(h.report).toHaveBeenLastCalledWith({ kind: 'opened' }) }) await h.choose(file, 'open-in-app.file/reveal') expect(h.remote.session.openWorkspacePath).toHaveBeenCalledWith({ path: '/workspace/file.svg', action: 'reveal' }) + await vi.waitFor(() => { expect(h.report).toHaveBeenLastCalledWith({ kind: 'revealed' }) }) await h.choose(file, 'file-actions.file/copyImage') await vi.waitFor(() => { expect(h.notify).toHaveBeenCalledWith('menu.copied', true) }) const source = h.fileActions.copyImage.mock.calls[0]![0] @@ -117,7 +121,7 @@ it('keeps copy-path available on failed metadata and reports native action failu expect((await h.resolve(file)).source.getSnapshot().find(item => item.id.endsWith('/sidebar'))?.disabled).toBe(true) h.remote.session.openWorkspacePath.mockResolvedValueOnce({ ok: false } as never) await h.choose(file, 'open-in-app.file/reveal') - await vi.waitFor(() => { expect(h.notify).toHaveBeenCalledWith('path.revealError') }) + await vi.waitFor(() => { expect(h.report).toHaveBeenLastCalledWith({ kind: 'failed', failure: 'revealError' }) }) h.remote.session.workspacePathApplications.mockResolvedValueOnce({ ok: false } as never) expect((await h.resolve(file)).source.getSnapshot().find(item => item.id.endsWith('/open'))?.disabled).toBe(true) h.paths.desktop.set(false) @@ -239,9 +243,10 @@ it('opens the captured Workspace through the directory app catalog and reports l const target: MenuTarget = { kind: 'workspace', value: { workspaceId: 'workspace' as never, path: '/workspace', displayTitle: 'Workspace' } } await h.choose(target, 'open-in-app.workspace/reveal') expect(h.directoryFetch).toHaveBeenLastCalledWith(expect.any(String), expect.objectContaining({ body: JSON.stringify({ app: 'finder', path: '/workspace' }) })) + await vi.waitFor(() => { expect(h.report).toHaveBeenLastCalledWith({ kind: 'opened' }) }) h.directoryFetch.mockResolvedValueOnce(new Response('', { status: 500 })) await h.choose(target, 'open-in-app.workspace/reveal') - await vi.waitFor(() => { expect(h.failure).toHaveBeenCalledWith('path.revealError') }) + await vi.waitFor(() => { expect(h.report).toHaveBeenLastCalledWith({ kind: 'failed', failure: 'revealError' }) }) h.directories.apps.set([]) expect((await h.resolve(target)).source.getSnapshot()[0]?.disabled).toBe(true) }) diff --git a/packages/client/ui-open-in-app/tests/context-menus.client.spec.ts b/packages/client/ui-open-in-app/tests/context-menus.client.spec.ts index d784bda930..b1b677ea0e 100644 --- a/packages/client/ui-open-in-app/tests/context-menus.client.spec.ts +++ b/packages/client/ui-open-in-app/tests/context-menus.client.spec.ts @@ -38,7 +38,7 @@ it('offers image actions on file targets, copies absolute paths, and keeps save const paths = new OpenInAppPathController(remote) const directories = new OpenInAppController() const fiber = ctx.plugin({ apply: (plugin: Context) => { - registerContextMenus(plugin, paths, key => key, directories) + registerContextMenus(plugin, paths, key => key, directories, vi.fn()) registerFileMenus(plugin, ctx.fileActions, key => key) } }) await fiber.await() diff --git a/packages/client/ui-open-in-app/tests/controller.client.spec.ts b/packages/client/ui-open-in-app/tests/controller.client.spec.ts index df7081d462..059dd5dedb 100644 --- a/packages/client/ui-open-in-app/tests/controller.client.spec.ts +++ b/packages/client/ui-open-in-app/tests/controller.client.spec.ts @@ -65,12 +65,13 @@ describe('OpenInAppController launching', () => { const pending = controller.launch('cursor', '/workspace/first') expect(controller.operation.getSnapshot()).toEqual({ phase: 'busy', path: '/workspace/first' }) controller.choose('finder') - await controller.launch('finder', '/workspace/second') + // The gesture another launch already holds settles as deliberate silence. + await expect(controller.launch('finder', '/workspace/second')).resolves.toEqual({ kind: 'skipped' }) expect(controller.choice.getSnapshot()).toBe('cursor') expect(fetcher).toHaveBeenCalledOnce() expect(fetcher.mock.calls[0]?.[1]?.body).toBe(JSON.stringify({ app: 'cursor', path: '/workspace/first' })) finish(jsonResponse({ ok: true })) - await pending + await expect(pending).resolves.toEqual({ kind: 'opened' }) expect(controller.operation.getSnapshot()).toEqual({ phase: 'idle', path: '/workspace/first' }) }) @@ -91,7 +92,7 @@ describe('OpenInAppController launching', () => { it('posts the launch body and surfaces HTTP failures', async () => { const fetcher = vi.fn(async (input: string | URL, init?: RequestInit) => { void input; void init; return jsonResponse({ ok: true }) }) const controller = new OpenInAppController(fetcher) - await controller.launch('cursor', '/w/dir') + await expect(controller.launch('cursor', '/w/dir')).resolves.toEqual({ kind: 'opened' }) expect(fetcher.mock.calls[0]?.[0]).toBe('open-in-app/open') expect(fetcher.mock.calls[0]?.[1]).toMatchObject({ method: 'POST', @@ -100,7 +101,11 @@ describe('OpenInAppController launching', () => { }) const failing = new OpenInAppController(async () => jsonResponse({}, 404)) - await expect(failing.launch('cursor', '/w/dir')).rejects.toThrow('open failed: HTTP 404') + await expect(failing.launch('cursor', '/w/dir')).resolves.toEqual({ kind: 'failed', failure: 'openError' }) expect(failing.operation.getSnapshot()).toEqual({ phase: 'error', path: '/w/dir' }) + + const offline = new OpenInAppController(async () => { throw new Error('offline') }) + await expect(offline.launch('cursor', '/w/dir')).resolves.toEqual({ kind: 'failed', failure: 'openError' }) + expect(offline.operation.getSnapshot()).toEqual({ phase: 'error', path: '/w/dir' }) }) }) diff --git a/packages/client/ui-open-in-app/tests/file-route-action.client.spec.tsx b/packages/client/ui-open-in-app/tests/file-route-action.client.spec.tsx index 5b9a158086..ba691a5707 100644 --- a/packages/client/ui-open-in-app/tests/file-route-action.client.spec.tsx +++ b/packages/client/ui-open-in-app/tests/file-route-action.client.spec.tsx @@ -1,15 +1,19 @@ // @vitest-environment jsdom -/** Authorized file routes use the same application menu and failure feedback as previews. */ +/** Authorized file routes use the same application menu and outcome reporting as previews. */ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, expect, it, vi } from 'vitest' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { FileRouteAction } from '../src/client/FileRouteAction.tsx' import { en } from '../src/client/locales.ts' +import type { OpenInAppOutcome } from '../src/client/outcome.ts' afterEach(() => { cleanup(); vi.unstubAllGlobals() }) const apps = [{ id: 'music', name: 'Music', default: true, icon: null }] -const props = () => ({ actionUrl: 'api/present.open?sessionId=s&seq=2&index=0', available: true, pending: false, - onAction: vi.fn(async () => null), t: makeTranslate(en) }) +const props = () => ({ + actionUrl: 'api/present.open?sessionId=s&seq=2&index=0', available: true, pending: false, + onAction: vi.fn(async (): Promise => ({ kind: 'opened' })), + report: vi.fn(), t: makeTranslate(en), +}) it('queries the authorized route, selects a handler, and keeps reveal last', async () => { const fetcher = vi.fn(async () => Response.json(apps)) @@ -18,13 +22,14 @@ it('queries the authorized route, selects a handler, and keeps reveal last', asy render() await act(async () => {}) expect(fetcher).toHaveBeenCalledWith(p.actionUrl, { signal: expect.any(AbortSignal) as AbortSignal }) - fireEvent.pointerEnter(screen.getByRole('button', { name: 'Open in Music' })) - expect(await screen.findByRole('tooltip')).toHaveProperty('textContent', 'Open in Music') + fireEvent.pointerEnter(screen.getByRole('button', { name: en['open.title'].replace('{app}', 'Music') })) + expect(await screen.findByRole('tooltip')).toHaveProperty('textContent', en['open.title'].replace('{app}', 'Music')) await act(async () => { fireEvent.click(screen.getByRole('button', { name: en['path.more'] })) }) expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['Music (default)', 'Show file location']) await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: 'Music (default)' })) }) expect(p.onAction).toHaveBeenLastCalledWith('open', 'music') - await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Open in Music' })) }) + expect(p.report).toHaveBeenLastCalledWith({ kind: 'opened' }) + await act(async () => { fireEvent.click(screen.getByRole('button', { name: en['open.title'].replace('{app}', 'Music') })) }) expect(p.onAction).toHaveBeenLastCalledWith('open', undefined) }) @@ -49,5 +54,26 @@ it('does not query without a desktop and honors another pending gesture', async expect(fetcher).not.toHaveBeenCalled() view.rerender() await act(async () => {}) - expect(screen.getByRole('button', { name: 'Open in Music' })).toHaveProperty('disabled', true) + expect(screen.getByRole('button', { name: en['open.title'].replace('{app}', 'Music') })).toHaveProperty('disabled', true) +}) + +it('reports failure and deliberate silence, and never renders a banner of its own', async () => { + vi.stubGlobal('fetch', vi.fn(async () => Response.json(apps))) + const failed = props() + failed.onAction = vi.fn(async (): Promise => ({ kind: 'failed', failure: 'openError' })) + render() + await act(async () => {}) + await act(async () => { fireEvent.click(screen.getByRole('button', { name: en['open.title'].replace('{app}', 'Music') })) }) + expect(failed.report).toHaveBeenCalledWith({ kind: 'failed', failure: 'openError' }) + // The banner belongs to ui-open-in-app's frame-wide overlay entry, not to this card control. + expect(screen.queryByRole('alert')).toBeNull() + cleanup() + + const dropped = props() + dropped.onAction = vi.fn(async (): Promise => ({ kind: 'skipped' })) + render() + await act(async () => {}) + await act(async () => { fireEvent.click(screen.getByRole('button', { name: en['open.title'].replace('{app}', 'Music') })) }) + expect(dropped.report).toHaveBeenCalledWith({ kind: 'skipped' }) + expect(screen.queryByRole('alert')).toBeNull() }) diff --git a/packages/client/ui-open-in-app/tests/open-in-app-action.client.spec.tsx b/packages/client/ui-open-in-app/tests/open-in-app-action.client.spec.tsx index 532a8e8df9..43952c4474 100644 --- a/packages/client/ui-open-in-app/tests/open-in-app-action.client.spec.tsx +++ b/packages/client/ui-open-in-app/tests/open-in-app-action.client.spec.tsx @@ -20,6 +20,7 @@ interface Bench { props: OpenInAppActionProps launch: ReturnType choose: ReturnType + report: ReturnType } function bench(over: { @@ -38,6 +39,7 @@ function bench(over: { }) const launch = vi.fn((appId: string, path: string) => controller.launch(appId, path)) const choose = vi.fn() + const report = vi.fn() const props: OpenInAppActionProps = { absolutePath: over.absolutePath ?? '/w', useOpenInAppApps: bindSnapshotSelector(apps), @@ -46,10 +48,11 @@ function bench(over: { useShortcuts: bindSnapshotSelector(createSnapshotStore(over.shortcuts ?? [])), launch, choose, + report, iconUrl: (appId: string) => `open-in-app/icon/${appId}`, t, } - return { props, launch, choose } + return { props, launch, choose, report } } describe('OpenInAppAction visibility', () => { @@ -75,7 +78,7 @@ describe('OpenInAppAction visibility', () => { it('shows the remembered choice, falling back to the first available app when it is gone', () => { render() - expect(screen.getByRole('button', { name: t('open.title', { app: 'Cursor' }) }).querySelector('img')?.getAttribute('src')).toBe('open-in-app/icon/cursor') + expect(screen.getByRole('button', { name: t('open.title', { app: zh['app.cursor'] }) }).querySelector('img')?.getAttribute('src')).toBe('open-in-app/icon/cursor') cleanup() render() @@ -97,15 +100,26 @@ describe('OpenInAppAction launching', () => { expect(view.container.querySelector('[data-open-target]')?.getAttribute('data-state')).toBe('busy') await act(async () => { pending.resolve(undefined) }) expect(main).toHaveProperty('disabled', false) + expect(b.report).toHaveBeenCalledWith({ kind: 'opened' }) }) - it('announces launch failure without changing the remembered application', async () => { + it('reports a dropped gesture without remembering its application', async () => { + const b = bench({ apps: ['finder', 'cursor'], absolutePath: '/w/dir' }) + render( ({ kind: 'skipped' })} />) + fireEvent.click(screen.getByRole('button', { name: zh['path.more'] })) + await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: 'Cursor' })) }) + expect(b.report).toHaveBeenCalledWith({ kind: 'skipped' }) + expect(b.choose).not.toHaveBeenCalled() + }) + + it('reports a launch failure without changing the remembered application', async () => { const b = bench({ apps: ['finder', 'cursor'], absolutePath: '/w', launch: async () => { throw new Error('gone') } }) render() fireEvent.click(screen.getByRole('button', { name: zh['path.more'] })) await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: 'Cursor' })) }) - expect(screen.getByRole('alert').textContent).toContain(zh['path.openError']) + expect(b.report).toHaveBeenCalledWith({ kind: 'failed', failure: 'openError' }) expect(b.choose).not.toHaveBeenCalled() + expect(screen.queryByRole('alert')).toBeNull() }) it('lists only applications, marks the remembered default, and remembers a successful choice', async () => { @@ -117,6 +131,7 @@ describe('OpenInAppAction launching', () => { await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: 'Cursor' })) }) expect(b.launch).toHaveBeenCalledWith('cursor', '/w/dir') expect(b.choose).toHaveBeenCalledWith('cursor') + expect(b.report).toHaveBeenCalledWith({ kind: 'opened' }) }) it('closes on Escape without launching and falls back after an icon fails', async () => { @@ -132,7 +147,7 @@ describe('OpenInAppAction launching', () => { expect(view.container.querySelector('svg')).not.toBeNull() }) - it('names the selected application in the tooltip', async () => { + it('names the selected application in both the button and tooltip', async () => { render() fireEvent.pointerEnter(screen.getByRole('button', { name: t('open.title', { app: zh['app.finder'] }) })) expect(await screen.findByText(t('open.title', { app: zh['app.finder'] }))).toBeTruthy() diff --git a/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx b/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx index 2113cba232..e38ebdf74d 100644 --- a/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx +++ b/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx @@ -1,16 +1,17 @@ // @vitest-environment jsdom /** * The two document-preview path controls: hidden until the Host reports a - * desktop, busy only for their own gesture, and announcing a failure once - * through their own toast. + * desktop, busy only for their own gesture, and reporting each settled outcome + * once to the app-wide announcement seat instead of rendering one itself. */ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi, type Mock } from 'vitest' import { act, cleanup, fireEvent, render, renderHook, screen } from '@testing-library/react' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { OpenPathAction, type OpenPathActionProps } from '../src/client/OpenPathAction.tsx' import { OpenPathEmptyAction, type OpenPathEmptyActionProps } from '../src/client/OpenPathEmptyAction.tsx' -import type { OpenInAppPathAction, OpenInAppPathFailure } from '../src/client/open-path.ts' +import type { OpenInAppPathAction } from '../src/client/open-path.ts' +import type { OpenInAppOutcome } from '../src/client/outcome.ts' import { useOpenTargetGesture } from '../src/client/OpenTargetButton.tsx' import { zh } from '../src/client/locales.ts' @@ -27,13 +28,15 @@ interface Bench { props: OpenPathActionProps & OpenPathEmptyActionProps desktop: ReturnType> loadDesktop: ReturnType - openPath: ReturnType Promise>> + openPath: ReturnType Promise>> + report: Mock<(outcome: OpenInAppOutcome) => void> } function bench(over: { desktop?: boolean | null; openPath?: Bench['openPath'] } = {}): Bench { const desktop = createSnapshotStore(over.desktop === undefined ? true : over.desktop) const loadDesktop = vi.fn(async () => {}) - const openPath = over.openPath ?? vi.fn(async () => null) + const openPath = over.openPath ?? vi.fn(async (): Promise => ({ kind: 'opened' })) + const report = vi.fn<(outcome: OpenInAppOutcome) => void>() function useSelector(source: { getSnapshot(): T }): (select: (value: T) => R) => R { return select => select(source.getSnapshot()) } @@ -43,9 +46,10 @@ function bench(over: { desktop?: boolean | null; openPath?: Bench['openPath'] } loadDesktop, applications: vi.fn(async () => [{ id: '/Music.app', name: 'Music', default: true, icon: null }]), openPath, + report, t, } as Bench['props'] - return { props, desktop, loadDesktop, openPath } + return { props, desktop, loadDesktop, openPath, report } } describe('OpenPathAction visibility', () => { @@ -89,8 +93,8 @@ describe('OpenPathAction gestures', () => { expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'open', '/Music.app') }) - it('disables both halves while its own gesture settles and leaves no failure on the control', async () => { - const settled = Promise.withResolvers() + it('disables both halves while its own gesture settles and reports the failure it settled on', async () => { + const settled = Promise.withResolvers() const b = bench({ openPath: vi.fn(() => settled.promise) }) const { container } = render() const main = await screen.findByRole('button', { name: t('open.title', { app: 'Music' }) }) @@ -100,10 +104,12 @@ describe('OpenPathAction gestures', () => { expect(container.querySelector('[data-open-path]')?.getAttribute('data-state')).toBe('busy') fireEvent.click(main) expect(b.openPath).toHaveBeenCalledOnce() - await act(async () => { settled.resolve('openError'); await settled.promise }) + await act(async () => { settled.resolve({ kind: 'failed', failure: 'openError' }); await settled.promise }) expect(main).toHaveProperty('disabled', false) expect(container.querySelector('[data-open-path]')?.getAttribute('data-state')).toBe('idle') - expect(screen.getByRole('alert').textContent).toContain(zh['path.openError']) + expect(b.report).toHaveBeenCalledTimes(1) + expect(b.report).toHaveBeenCalledWith({ kind: 'failed', failure: 'openError' }) + expect(screen.queryByRole('alert')).toBeNull() }) }) @@ -117,37 +123,29 @@ describe('OpenPathEmptyAction', () => { await act(async () => {}) await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.open'] })) }) expect(b.openPath).toHaveBeenCalledWith(ABSOLUTE_PATH, 'open', undefined) + expect(b.report).toHaveBeenCalledWith({ kind: 'opened' }) expect(screen.queryByRole('alert')).toBeNull() }) - it('announces a failed open once through its own toast, which leaves after its hold and fade', async () => { - vi.useFakeTimers() - const b = bench({ openPath: vi.fn(async () => 'openError' as const) }) + it('reports an acknowledged reveal and a failed open to the announcement seat, rendering none itself', async () => { + const revealed = bench({ openPath: vi.fn(async (): Promise => ({ kind: 'revealed' })) }) + render( []} />) + await act(async () => {}) + await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.reveal'] })) }) + expect(revealed.report).toHaveBeenCalledWith({ kind: 'revealed' }) + cleanup() + + const b = bench({ openPath: vi.fn(async (): Promise => ({ kind: 'failed', failure: 'openError' })) }) render() await act(async () => {}) await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.open'] })) }) - expect(screen.getAllByRole('alert')).toHaveLength(1) - expect(screen.getByRole('alert').textContent).toContain(zh['path.openError']) - act(() => { vi.advanceTimersByTime(4_000) }) + expect(b.report).toHaveBeenCalledTimes(1) + expect(b.report).toHaveBeenCalledWith({ kind: 'failed', failure: 'openError' }) + // The banner belongs to this plugin's overlay entry, which outlives the control. expect(screen.queryByRole('alert')).toBeNull() }) }) -it('dismisses a failure on schedule even when its owner rerenders', async () => { - vi.useFakeTimers() - const b = bench({ openPath: vi.fn(async () => 'openError' as const) }) - const view = render() - await act(async () => {}) - await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.open'] })) }) - act(() => { vi.advanceTimersByTime(2_000) }) - view.rerender() - await act(async () => {}) - act(() => { vi.advanceTimersByTime(1_999) }) - expect(screen.getByRole('alert')).toBeTruthy() - act(() => { vi.advanceTimersByTime(1) }) - expect(screen.queryByRole('alert')).toBeNull() -}) - it('shows the default icon and current application list, and selects an application without replacing default opening', async () => { const b = bench() @@ -218,13 +216,16 @@ it('uses reveal as the default only when no application is registered', async () it('coalesces repeated gestures before the busy state renders', async () => { - const pending = Promise.withResolvers() + const pending = Promise.withResolvers() const b = bench({ openPath: vi.fn(() => pending.promise) }) - const { result } = renderHook(() => useOpenTargetGesture(() => b.openPath(ABSOLUTE_PATH, 'open'), t)) + const { result } = renderHook(() => useOpenTargetGesture(() => b.openPath(ABSOLUTE_PATH, 'open'), b.report)) act(() => { result.current.act({ kind: 'default' }); result.current.act({ kind: 'default' }) }) expect(b.openPath).toHaveBeenCalledOnce() - await act(async () => { pending.resolve(null) }) + expect(b.report).not.toHaveBeenCalled() + await act(async () => { pending.resolve({ kind: 'opened' }) }) expect(result.current.pending).toBe(false) + expect(b.report).toHaveBeenCalledOnce() + expect(b.report).toHaveBeenCalledWith({ kind: 'opened' }) }) diff --git a/packages/client/ui-open-in-app/tests/open-path.client.spec.ts b/packages/client/ui-open-in-app/tests/open-path.client.spec.ts index 2def94a830..e542b9164c 100644 --- a/packages/client/ui-open-in-app/tests/open-path.client.spec.ts +++ b/packages/client/ui-open-in-app/tests/open-path.client.spec.ts @@ -51,8 +51,8 @@ describe('OpenInAppPathController gestures', () => { const openWorkspacePath = vi.fn(async (_request: SessionOpenWorkspacePathRequest, _signal?: AbortSignal) => ({ ok: true as const, value: { opened: true as const } })) const controller = new OpenInAppPathController(remoteOf({ openWorkspacePath })) - expect(await controller.openPath('/w/clip.mp4', 'open')).toBeNull() - expect(await controller.openPath('/w/clip.mp4', 'reveal')).toBeNull() + expect(await controller.openPath('/w/clip.mp4', 'open')).toEqual({ kind: 'opened' }) + expect(await controller.openPath('/w/clip.mp4', 'reveal')).toEqual({ kind: 'revealed' }) expect(openWorkspacePath.mock.calls.map(call => call[0])).toEqual([ { path: '/w/clip.mp4' }, { path: '/w/clip.mp4', action: 'reveal' }, @@ -63,11 +63,11 @@ describe('OpenInAppPathController gestures', () => { const refused = new OpenInAppPathController(remoteOf({ openWorkspacePath: async () => ({ ok: false, error: new RemoteError('gateway/internal', 'boom', {}) }), })) - expect(await refused.openPath('/w/clip.mp4', 'open')).toBe('openError') - expect(await refused.openPath('/w/clip.mp4', 'reveal')).toBe('revealError') + expect(await refused.openPath('/w/clip.mp4', 'open')).toEqual({ kind: 'failed', failure: 'openError' }) + expect(await refused.openPath('/w/clip.mp4', 'reveal')).toEqual({ kind: 'failed', failure: 'revealError' }) const rejecting = new OpenInAppPathController(remoteOf({ openWorkspacePath: () => Promise.reject(new Error('down')) })) - expect(await rejecting.openPath('/w/clip.mp4', 'open')).toBe('openError') + expect(await rejecting.openPath('/w/clip.mp4', 'open')).toEqual({ kind: 'failed', failure: 'openError' }) }) }) diff --git a/packages/client/ui-open-in-app/tests/open-toast.client.spec.tsx b/packages/client/ui-open-in-app/tests/open-toast.client.spec.tsx new file mode 100644 index 0000000000..714d99d1a6 --- /dev/null +++ b/packages/client/ui-open-in-app/tests/open-toast.client.spec.tsx @@ -0,0 +1,107 @@ +// @vitest-environment jsdom +/** Mobile handoff feedback and native-open failures rendered outside the initiating panel. */ +import { act, cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { + OpenInAppToast, + type OpenInAppToastProps, type OpenInAppToastState, +} from '../src/client/open-toast.tsx' +import { en, ja, es, fr, de, ru, pt, zh } from '../src/client/locales.ts' + +afterEach(() => { cleanup(); vi.useRealTimers(); vi.unstubAllGlobals() }) + +function mount(state: OpenInAppToastState | null, dictionary: typeof en | typeof zh = en) { + const dismiss = vi.fn() + const props = { + useToast: (select: (value: OpenInAppToastState | null) => T): T => select(state), + dismiss, + t: makeTranslate(dictionary), + } as OpenInAppToastProps + return { view: render(), dismiss } +} + +describe('open-in-app toast source', () => { + it.each([ + ['iPhone', 'iPhone', 'iPhone', 5, 390, true], + ['Android', 'Android', 'Linux armv8l', 5, 390, true], + ['desktop-mode iPad', 'Macintosh', 'MacIntel', 5, 1024, true], + ['desktop browser', 'Macintosh', 'MacIntel', 0, 1280, false], + ['narrow desktop browser', 'Macintosh', 'MacIntel', 0, 390, false], + ['touch Windows desktop', 'Windows NT', 'Win32', 10, 1280, false], + ['Electron desktop', 'Macintosh Electron', 'MacIntel', 0, 1280, false], + ] as const)('announces successes only for mobile devices: %s', async (_device, userAgent, platform, maxTouchPoints, width, mobile) => { + vi.resetModules() + vi.stubGlobal('navigator', { userAgent, platform, maxTouchPoints }) + vi.stubGlobal('innerWidth', width) + const { createOpenInAppToastSource } = await import('../src/client/open-toast.tsx') + const source = createOpenInAppToastSource() + const listener = vi.fn() + const stop = source.hooks.toast.subscribe(listener) + expect(source.hooks.toast.getSnapshot()).toBeNull() + + source.report({ kind: 'skipped' }) + expect(listener).not.toHaveBeenCalled() + expect(source.hooks.toast.getSnapshot()).toBeNull() + + source.report({ kind: 'opened' }) + expect(source.hooks.toast.getSnapshot()).toEqual(mobile ? { kind: 'opened', seq: 1 } : null) + source.report({ kind: 'revealed' }) + expect(source.hooks.toast.getSnapshot()).toEqual(mobile ? { kind: 'revealed', seq: 2 } : null) + const previous = source.hooks.toast.getSnapshot() + source.report({ kind: 'skipped' }) + expect(source.hooks.toast.getSnapshot()).toBe(previous) + + source.report({ kind: 'failed', failure: 'openError' }) + expect(source.hooks.toast.getSnapshot()).toEqual({ kind: 'failed', failure: 'openError', seq: mobile ? 3 : 1 }) + source.report({ kind: 'failed', failure: 'revealError' }) + expect(source.hooks.toast.getSnapshot()).toEqual({ kind: 'failed', failure: 'revealError', seq: mobile ? 4 : 2 }) + if (!mobile) { + source.report({ kind: 'opened' }) + expect(source.hooks.toast.getSnapshot()).toEqual({ kind: 'failed', failure: 'revealError', seq: 2 }) + } + source.dismiss() + expect(source.hooks.toast.getSnapshot()).toBeNull() + expect(listener).toHaveBeenCalledTimes(mobile ? 5 : 3) + + stop() + source.report({ kind: 'failed', failure: 'openError' }) + expect(listener).toHaveBeenCalledTimes(mobile ? 5 : 3) + }) +}) + +describe('OpenInAppToast', () => { + it('renders nothing while no outcome shows', () => { + mount(null) + expect(screen.queryByRole('alert')).toBeNull() + }) + + it('shows where the acknowledged open happened and dismisses once it has faded', () => { + vi.useFakeTimers() + const { dismiss } = mount({ kind: 'opened', seq: 1 }) + expect(screen.getByRole('alert').textContent).toBe('Opened on another device') + act(() => { vi.runAllTimers() }) + expect(dismiss).toHaveBeenCalledOnce() + }) + + it('shows the other-device confirmation in Chinese', () => { + mount({ kind: 'opened', seq: 1 }, zh) + expect(screen.getByRole('alert').textContent).toBe('已在其它设备打开') + }) + + it.each([ + ['en', en], ['zh', zh], ['ja', ja], ['es', es], + ['fr', fr], ['de', de], ['ru', ru], ['pt', pt], + ] as const)('localizes the other-device file-location confirmation: %s', (_locale, dictionary) => { + mount({ kind: 'revealed', seq: 2 }, dictionary) + expect(screen.getByRole('alert').textContent).toBe(dictionary['path.revealed']) + }) + + it('shows the warning banner for a failed open and for a failed reveal', () => { + mount({ kind: 'failed', failure: 'openError', seq: 3 }) + expect(screen.getByRole('alert').textContent).toBe(en['path.openError']) + cleanup() + mount({ kind: 'failed', failure: 'revealError', seq: 4 }, zh) + expect(screen.getByRole('alert').textContent).toBe(zh['path.revealError']) + }) +}) diff --git a/packages/client/ui-open-in-app/tests/session-reveal.client.spec.ts b/packages/client/ui-open-in-app/tests/session-reveal.client.spec.ts index 605ba4c8f0..75fde7a436 100644 --- a/packages/client/ui-open-in-app/tests/session-reveal.client.spec.ts +++ b/packages/client/ui-open-in-app/tests/session-reveal.client.spec.ts @@ -16,7 +16,7 @@ it.each([ [['vscode'], '/workspace', 'Show in folder', true], [null, '/workspace', 'Show in folder', true], [['finder'], undefined, 'Show in Finder', true], -] as const)('reveals the captured directory with catalog %j and path %s', (apps, cwd, label, disabled) => { +] as const)('reveals the captured directory with catalog %j and path %s', async (apps, cwd, label, disabled) => { const ctx = new Context() onTestFinished(() => ctx.fiber.dispose()) const locale = new LocaleRuntime(ctx) @@ -32,11 +32,17 @@ it.each([ ctx.provide('sessions', { list } as never) const controller = new OpenInAppController() controller.apps.set(apps) - const launch = vi.spyOn(controller, 'launch').mockResolvedValue() - registerSessionReveal(ctx, controller, locale.bind(NS)) + const launch = vi.spyOn(controller, 'launch').mockResolvedValue({ kind: 'opened' }) + const report = vi.fn() + registerSessionReveal(ctx, controller, locale.bind(NS), report) const menu = registry.resolve({ kind: 'session', value: { sessionId, displayTitle: 'Captured' } }) expect(menu.source.getSnapshot()[0]).toMatchObject({ label, disabled }) menu.select('open-in-app.reveal/reveal') - if (disabled) expect(launch).not.toHaveBeenCalled() - else expect(launch).toHaveBeenCalledWith(apps?.[0], cwd) + if (disabled) { + expect(launch).not.toHaveBeenCalled() + expect(report).not.toHaveBeenCalled() + } else { + expect(launch).toHaveBeenCalledWith(apps?.[0], cwd) + await vi.waitFor(() => { expect(report).toHaveBeenCalledWith({ kind: 'opened' }) }) + } }) diff --git a/packages/core/tool-executor-ptc/README.i18n.yaml b/packages/core/tool-executor-ptc/README.i18n.yaml index a056d5ebf3..d99f3ac777 100644 --- a/packages/core/tool-executor-ptc/README.i18n.yaml +++ b/packages/core/tool-executor-ptc/README.i18n.yaml @@ -21,8 +21,8 @@ en: 9d4f61c351945d30 zh: 1c575526230b0e02 /deepseek-ai-dsh-tool-executor-ptc/understand-the-implementation/ptc-mode: - en: a87a9af0deeddabe - zh: a4cef6f43a3b1422 + en: 48f90d4c78ba69d7 + zh: 5dcbbf23ebc1b803 /deepseek-ai-dsh-tool-executor-ptc/further-exploration: en: ee69078c38c655a9 zh: fb0873407f23c8c5 diff --git a/packages/core/tool-executor-ptc/README.md b/packages/core/tool-executor-ptc/README.md index 833a798623..fa20171fa9 100644 --- a/packages/core/tool-executor-ptc/README.md +++ b/packages/core/tool-executor-ptc/README.md @@ -59,6 +59,8 @@ New sub-calls use `:ptc:` ids. Consumers treat these ids as opaque an Successful image-bearing subcall results become deferred user-message context with `source.kind` set to `ptc-mode`. Other additional contexts retain their producing tool's attribution. +When the caller cancels and the runtime reports `abort`, `run_code` waits for cleanup and returns the native tool cancellation result: `AbortError` / `ABORTED`, with `Error: tool call aborted` as its content. The Agent records the specific cancellation cause in `turn/end`, including a hook's reason. Other runtime failures retain `CODE_RUN_FAILED` and their diagnostics, even if the caller also cancels. Cancellation can discard retained runtime state and does not undo completed tool effects. + `run_code` accepts `timeoutMs` when the mounted runtime supports an override; its schema reports the configured default and maximum, and the runtime's usage instructions. The Node default is 120,000 ms with a 600,000 ms cap, including nested tool and approval waits. A wider `sandbox_permissions` mode requires a non-empty `justification` and approval before the program starts. The schema asks the model to write that reason in the current user's language. The grant applies to that complete execution; standing Session policy and nested tools retain their own authority. Programs are never replayed automatically: inspect earlier effects before explicitly retrying a denied program. Retained PTC environments follow the standing file policy. PTC observes committed Session mode changes and immediately cancels affected calls in all Node modes. Later PTC calls wait for their nested-tool cleanup before resolving the latest policy. The runtime owns process invalidation and cleanup; permission setters remain synchronous. One-time escalations omit the state owner and complete in a temporary environment; standing variables remain available to ordinary calls. - [`src/index.ts`](src/index.ts) — Validated configuration and scoped registration; tool descriptions and batch execution. diff --git a/packages/core/tool-executor-ptc/README.zh.md b/packages/core/tool-executor-ptc/README.zh.md index c1b3f6cf20..2f56dbcb12 100644 --- a/packages/core/tool-executor-ptc/README.zh.md +++ b/packages/core/tool-executor-ptc/README.zh.md @@ -59,6 +59,8 @@ PTC 执行器公开保留的 `run_code` 传输以及按所加载运行时语言 成功且包含图片的子调用结果会成为延后的 user-message 上下文,其 `source.kind` 为 `ptc-mode`。其他 additional context 保留其生产工具的归属。 +调用方取消且运行时报告 `abort` 时,`run_code` 等待清理完成,并返回原生工具的取消结果:`AbortError` / `ABORTED`,内容为 `Error: tool call aborted`。Agent 在 `turn/end` 中记录具体取消原因,包括 hook 的理由。其他运行时失败保留 `CODE_RUN_FAILED` 及其诊断,即使调用方同时取消。取消可能丢弃保留的运行时状态,也不会撤销已完成的工具操作。 + 当已挂载运行时支持覆盖时,`run_code` 接受 `timeoutMs`;其 schema 报告配置的默认值和上限以及运行时使用说明。Node 默认值为 120,000 ms,上限为 600,000 ms,包含嵌套工具和审批等待。更宽的 `sandbox_permissions` 模式要求非空 `justification`,并在程序启动前获得审批。schema 提示模型使用当前用户的语言撰写理由。授权仅用于该次完整执行;常驻 Session 策略与嵌套工具保留各自权限。程序不会自动重放:显式重试被拒程序前,应检查先前已发生的效果。 保留状态的 PTC 环境遵循会话文件策略。PTC 观察已提交的 Session 模式变化,并立即取消所有 Node 模式下受影响的调用。后续 PTC 调用等待这些调用的嵌套工具清理后,再解析最新策略。运行时负责进程失效和清理,权限设置保持同步。单次提权省略状态所有者,在临时环境中执行;普通调用仍可访问原环境的变量。 - [`src/index.ts`](src/index.ts) — 配置校验和作用域注册;工具描述和批次执行。 diff --git a/packages/core/tool-executor-ptc/src/errors.ts b/packages/core/tool-executor-ptc/src/errors.ts index 67d468e9d0..c2dcd8ec45 100644 --- a/packages/core/tool-executor-ptc/src/errors.ts +++ b/packages/core/tool-executor-ptc/src/errors.ts @@ -3,7 +3,8 @@ import { HarnessError } from '@deepseek-ai/dsh-llm' /** * Thrown by `run_code` when the program run itself failed — a program - * exception, a budget expiry, an abort, or substrate death. Extends + * exception, a budget expiry, a runtime-local abort, or substrate death. Caller + * cancellation uses the shared `AbortError` / `ABORTED` tool outcome. Extends * {@link HarnessError} (`code: 'CODE_RUN_FAILED'`); the registry's execution * pipeline converts it into a structured `isError` result whose text carries * the failure kind plus the captured logs, so the model can self-correct. diff --git a/packages/core/tool-executor-ptc/src/ptc.ts b/packages/core/tool-executor-ptc/src/ptc.ts index d23a37b471..346837f460 100644 --- a/packages/core/tool-executor-ptc/src/ptc.ts +++ b/packages/core/tool-executor-ptc/src/ptc.ts @@ -9,7 +9,7 @@ import type { PtcDispatchLog } from './runtime-types.ts' import { symbols, type Context } from '@deepseek-ai/cordis' import { brandString } from '@deepseek-ai/dsh-brand' -import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { createUserMessage, HarnessError } from '@deepseek-ai/dsh-llm' import type { ContentBlock, ToolCallId, ToolSchema } from '@deepseek-ai/dsh-llm' import type { PtcBindingFunction, PtcRunResult, PtcRunSandbox, PtcRuntime } from '@deepseek-ai/dsh-ptc-runtime' @@ -19,7 +19,7 @@ import type {} from '@deepseek-ai/dsh-user-approval' import type {} from '@deepseek-ai/dsh-working-directory' import type {} from '@deepseek-ai/dsh-sandbox-policy' import { deepFreeze, snapshotJsonValue, type JsonValue } from '@deepseek-ai/dsh-util-values' -import { defineTool, parameterSchemaSpecToJsonSchema, RUN_CODE_NAME } from '@deepseek-ai/dsh-tools' +import { defineTool, parameterSchemaSpecToJsonSchema, RUN_CODE_NAME, TOOL_ABORTED } from '@deepseek-ai/dsh-tools' import type { ToolExecutionPipeline } from '@deepseek-ai/dsh-tools/pipeline' import { toolErrorMessage } from '@deepseek-ai/dsh-tools/pipeline' import { scopeTarget } from '@deepseek-ai/dsh-scope' @@ -694,6 +694,11 @@ export function createRunCodeTool( } if (result.error) { + if (result.error.kind === 'abort' && exec.signal.aborted) { + const error = new HarnessError('tool call aborted', TOOL_ABORTED) + error.name = 'AbortError' + throw error + } const logsText = result.logs.length > 0 ? `\nCaptured output:\n${result.logs.join('\n')}` : '' const sandboxText = result.sandbox === undefined ? '' : `\nFile sandbox: ${result.sandbox.mode}${result.sandbox.enforcement === undefined ? '' : `; enforcement: ${result.sandbox.enforcement}`}${result.sandbox.denied ? '; operation denied' : ''}.` diff --git a/packages/core/tool-executor-ptc/tests/ptc.spec.ts b/packages/core/tool-executor-ptc/tests/ptc.spec.ts index 942434e44b..99c9dd38c1 100644 --- a/packages/core/tool-executor-ptc/tests/ptc.spec.ts +++ b/packages/core/tool-executor-ptc/tests/ptc.spec.ts @@ -14,11 +14,11 @@ import { createScope } from '@deepseek-ai/dsh-scope' import type { Scope } from '@deepseek-ai/dsh-scope' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import { PtcRuntime } from '@deepseek-ai/dsh-ptc-runtime' -import type { PtcRunRequest, PtcRunResult } from '@deepseek-ai/dsh-ptc-runtime' -import ToolRuntime, { RUN_CODE_NAME, TOOL_ABORTED_BEFORE_DISPATCH, defineTool } from '@deepseek-ai/dsh-tools' +import type { PtcRunFailure, PtcRunRequest, PtcRunResult } from '@deepseek-ai/dsh-ptc-runtime' +import ToolRuntime, { RUN_CODE_NAME, TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH, defineTool } from '@deepseek-ai/dsh-tools' import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import type { JsonSchemaNode, PostToolDecision, ToolExecutionResult } from '@deepseek-ai/dsh-tools' -import type { Agent } from '@deepseek-ai/dsh-agent' +import type { Agent, AgentCancelCause } from '@deepseek-ai/dsh-agent' import SessionStore, { Session, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' import ApprovalService, { type ApprovalOutcome, type ApprovalRequest } from '@deepseek-ai/dsh-user-approval' import type { SessionEventMap } from '@deepseek-ai/dsh-session' @@ -1602,7 +1602,9 @@ describe('the run_code dispatch bridge', () => { it('aborting the outer signal aborts the in-flight sub-dispatch and abandons queued ones', async () => { const { ctx, runtime } = await setup({ mode: 'ptc' }) + onTestFinished(async () => { await ctx.fiber.dispose() }) const seen: string[] = [] + const entered = Promise.withResolvers() let sawAbort = false ctx.tools.register(defineContentToolFixture({ name: 'slow', @@ -1611,8 +1613,8 @@ describe('the run_code dispatch bridge', () => { async execute(args, exec) { seen.push(args.id) await new Promise((resolve) => { - const timer = setTimeout(resolve, 500) - exec.signal.addEventListener('abort', () => { sawAbort = true; clearTimeout(timer); resolve() }, { once: true }) + exec.signal.addEventListener('abort', () => { sawAbort = true; resolve() }, { once: true }) + entered.resolve(undefined) }) return [{ type: 'text' as const, text: args.id }] }, @@ -1621,7 +1623,8 @@ describe('the run_code dispatch bridge', () => { runtime.behavior = async (request) => { const tools = request.bindings[0]!.functions const calls = [tools.slow!({ id: 'first' }).catch(() => 'rejected'), tools.slow!({ id: 'second' }).catch(() => 'rejected')] - setTimeout(() => { controller.abort('user-cancel') }, 50) + await entered.promise + controller.abort('user-cancel') await Promise.all(calls) // A real runtime would be terminated by the abort; the fake honors the // contract by reporting the abort as the run failure. @@ -1629,7 +1632,7 @@ describe('the run_code dispatch bridge', () => { } const result = await runCode(ctx, 'program', { signal: controller.signal }) expect(result.isError).toBe(true) - expect((result.content[0] as { text: string }).text).toContain('code run failed (abort)') + expect(result.error).toEqual({ message: 'tool call aborted', info: { name: 'AbortError', code: TOOL_ABORTED } }) expect(seen).toEqual(['first']) expect(sawAbort).toBe(true) }) @@ -2045,6 +2048,60 @@ describe('the run_code dispatch bridge', () => { expect(calls).toEqual([]) }) + describe.each(['typescript', 'python'])('%s caller cancellation', (language) => { + it.each([ + { kind: 'user' }, + { kind: 'parent' }, + { kind: 'hook', reason: 'policy stopped the turn' }, + { kind: 'disposed' }, + undefined, + ])('maps reason %j to the native tool abort result', async (cause) => { + const { ctx, runtime } = await setup({ mode: 'ptc', runtime: { language } }) + onTestFinished(async () => { await ctx.fiber.dispose() }) + const controller = new AbortController() + runtime.behavior = async (request) => { + if (cause === undefined) controller.abort() + else controller.abort(cause) + expect(request.signal?.reason).toBe(controller.signal.reason) + const reason: unknown = request.signal?.reason + const message = reason instanceof Error ? reason.message : String(reason) + return { logs: [], error: { kind: 'abort', message } } + } + + const result = await runCode(ctx, 'program', { signal: controller.signal }) + + expect(result).toEqual({ + content: [{ type: 'text', text: 'Error: tool call aborted' }], + isError: true, + error: { + message: 'tool call aborted', + info: { name: 'AbortError', code: TOOL_ABORTED }, + }, + }) + }) + }) + + it.each<[boolean, PtcRunFailure]>([ + [false, { kind: 'abort', message: 'sandbox mode changed' }], + [true, { kind: 'worker-exit', message: 'managed process cleanup failed' }], + [true, { kind: 'timeout', message: 'execution deadline reached' }], + ])('keeps runtime failure details with caller cancellation %s: %j', async (cancelled, failure) => { + const { ctx, runtime } = await setup({ mode: 'ptc' }) + onTestFinished(async () => { await ctx.fiber.dispose() }) + const controller = new AbortController() + runtime.behavior = async () => { + if (cancelled) controller.abort({ kind: 'user' }) + return { logs: ['got this far'], error: failure } + } + + const result = await runCode(ctx, 'program', { signal: controller.signal }) + + expect(result.error).toEqual({ + message: `code run failed (${failure.kind}): ${failure.message}\nCaptured output:\ngot this far`, + info: { name: 'CodeRunFailedError', code: 'CODE_RUN_FAILED' }, + }) + }) + it('a tool/ptc-dispatch event never derives a model message', () => { const session = Session.create(SessionId('ptc-derive')) session.append('user/message', createUserMessage({ diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 1aaec597a0..8f318ab733 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -2178,9 +2178,11 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerProps: [ '/** Native file action selected by an explicit user gesture. */\nexport type PresentedAction = \'open\' | \'reveal\'', - '/** Failure feedback for the shared native opening control. */\nexport type PresentedOpenFailure = \'openError\' | \'revealError\' | null', + '/**\n * Settled result of one native file gesture, for the contributed control to\n * announce: the Host acknowledged an application open or a file-manager\n * reveal, the gesture failed, or the request was deliberately dropped because\n * the same file was already opening or this controller was disposed. Only an\n * acknowledged operation may be announced as a success.\n */\nexport type PresentedOpenOutcome =\n | { readonly kind: \'opened\' }\n | { readonly kind: \'revealed\' }\n | { readonly kind: \'failed\'; readonly failure: PresentedOpenFailure }\n | { readonly kind: \'skipped\' }', + ], + ownerPropsReferences: [ + 'PresentedOpenFailure', ], - ownerPropsReferences: [], standardProps: [ 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', @@ -3546,6 +3548,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'client-ui-chat QuotaNoticeHost id \'chat.quota-notice\'', 'client-ui-context-menu MenuOverlay id \'context-menu\'', 'client-ui-file-drag FileDragFeedback id \'file-drag.feedback\'', + 'client-ui-open-in-app OpenInAppToast id \'open-in-app.toast\'', 'client-ui-plugin-manager PluginRefreshToast id \'plugin-manager.refresh-toast\'', 'client-ui-schedule ScheduleDeleteToast id \'schedule.delete-toast\'', 'client-ui-settings-account DesktopOnboardingEntry id \'desktop-onboarding\'', diff --git a/snapshots/sdk/ptc-cancel/cancel.mjs b/snapshots/sdk/ptc-cancel/cancel.mjs new file mode 100644 index 0000000000..ea2191874d --- /dev/null +++ b/snapshots/sdk/ptc-cancel/cancel.mjs @@ -0,0 +1,10 @@ +/** Cancel after the PTC process completes a nested working_directory call. */ +export const name = 'ptc-cancel-fixture' +export const inject = ['tools'] +export function apply(ctx) { + ctx.on('tools/result', (exec) => { + if (exec.name === 'working_directory' && exec.parent !== undefined && exec.agent !== undefined) { + exec.agent.cancel({ kind: 'user' }) + } + }) +} diff --git a/snapshots/sdk/ptc-cancel/runtime.cordis.yml b/snapshots/sdk/ptc-cancel/runtime.cordis.yml new file mode 100644 index 0000000000..4b3df2b8c7 --- /dev/null +++ b/snapshots/sdk/ptc-cancel/runtime.cordis.yml @@ -0,0 +1,5 @@ +- insert: + - id: ptc-runtime + name: '@deepseek-ai/dsh-ptc-runtime-node' + - id: ptc-cancel-fixture + name: './cancel.mjs' diff --git a/snapshots/sdk/ptc-cancel/session.v4.jsonl b/snapshots/sdk/ptc-cancel/session.v4.jsonl new file mode 100644 index 0000000000..f7826ecbbb --- /dev/null +++ b/snapshots/sdk/ptc-cancel/session.v4.jsonl @@ -0,0 +1,21 @@ +{"type":"session","version":4,"id":"{{session:1}}","createdAt":1785014439563,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0} +{"type":"permission/preset","data":{"preset":"danger-full-access"}} +{"type":"sandbox/mode","data":{"mode":"danger-full-access"}} +{"type":"approval/policy","data":{"policy":"never"}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Start a run_code program, call working_directory, then keep running until I stop it."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]}} +{"type":"turn/start","data":{"turn":1}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","data":{"turn":1,"step":1}} +{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Start a run_code program, call working_directory, then keep running until I stop it."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"session/title","data":{"title":"Start a run_code program, call","messageSeqs":[8],"source":{"kind":"fallback"}}} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"ptc-cancel","name":"run_code","arguments":"{\"code\":\"await tools.working_directory({}); for (;;) {}\",\"description\":\"Run until stopped\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"stream":[{"type":"chunk","time":1792996619936,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1792996619936,"index":0,"dt":[],"id":"ptc-cancel","name":"run_code","args":["{\"code\":\"await tools.working_directory({}); for (;;) {}\",\"description\":\"Run until stopped\"}"]},{"type":"chunk","time":1792996619936,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"ptc-cancel","name":"run_code","arguments":"{\"code\":\"await tools.working_directory({}); for (;;) {}\",\"description\":\"Run until stopped\"}"}}},{"type":"chunk","time":1792996619936,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":1,"callId":"ptc-cancel","name":"run_code","arguments":"{\"code\":\"await tools.working_directory({}); for (;;) {}\",\"description\":\"Run until stopped\"}"}} +{"type":"tool/ptc-dispatch-start","data":{"rootCallId":"ptc-cancel","parentCallId":"ptc-cancel","subCallId":"ptc-cancel:ptc:1","name":"working_directory","arguments":{}}} +{"type":"tool/ptc-dispatch","data":{"rootCallId":"ptc-cancel","parentCallId":"ptc-cancel","subCallId":"ptc-cancel:ptc:1","name":"working_directory","arguments":{},"isError":false,"content":[{"type":"text","text":"{{cwd}}"}]}} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"ptc-cancel"},"toolCallId":"ptc-cancel","content":[{"type":"text","text":"Error: tool call aborted"}],"isError":true,"id":"{{message:5}}"},"error":{"name":"AbortError","code":"ABORTED"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":1}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"aborted","reason":{"kind":"user"}}}} diff --git a/snapshots/sdk/ptc-cancel/snapshot.yml b/snapshots/sdk/ptc-cancel/snapshot.yml new file mode 100644 index 0000000000..50f022e261 --- /dev/null +++ b/snapshots/sdk/ptc-cancel/snapshot.yml @@ -0,0 +1,7 @@ +version: 1 +scenario: ptc-cancel +profile: sdk +composition: ptc +recording: authored +header: + class: ptc diff --git a/snapshots/sdk/sdk.snapshot.ts b/snapshots/sdk/sdk.snapshot.ts index 2f5bbb00b5..1a687411fc 100644 --- a/snapshots/sdk/sdk.snapshot.ts +++ b/snapshots/sdk/sdk.snapshot.ts @@ -159,6 +159,9 @@ const SDK_ASSERTIONS: Readonly> = { patches: [fileURLToPath(new URL('./tool-error-details/runtime.cordis.yml', import.meta.url))], expectedFinalResponse: 'ERROR_DETAILS_OK', }, + 'ptc-cancel': { + patches: [fileURLToPath(new URL('./ptc-cancel/runtime.cordis.yml', import.meta.url))], + }, 'ptc-turn': { patches: [fileURLToPath(new URL('./ptc-turn/runtime.cordis.yml', import.meta.url))], expectedFinalResponse: 'CODE_ONE+CODE_TWO', @@ -1010,6 +1013,20 @@ describe('TypeScript SDK snapshots over the jsonrpc runtime', () => { ) reconcileCatalogCreationTimes(ordered.map(log => log.content), 'validate') const actualContext = contextOf(ordered, cwd) + if (scenario.name === 'ptc-cancel') { + const events = results.flatMap(result => result.events) + expect(events.find(event => event.type === 'tool/result')).toMatchObject({ data: { + message: { + toolCallId: 'ptc-cancel', + isError: true, + content: [{ type: 'text', text: 'Error: tool call aborted' }], + }, + error: { name: 'AbortError', code: 'ABORTED' }, + } }) + expect(events.find(event => event.type === 'turn/end')).toMatchObject({ + data: { reason: { kind: 'aborted', reason: { kind: 'user' } } }, + }) + } if (scenario.name === 'minimal-image-compaction') { const events = results.flatMap(result => result.events) const failures = events.filter(event => event.type === 'turn/end' && event.data.reason.kind === 'error') diff --git a/snapshots/web/present/mobile-open.expected.md b/snapshots/web/present/mobile-open.expected.md new file mode 100644 index 0000000000..9f27337425 --- /dev/null +++ b/snapshots/web/present/mobile-open.expected.md @@ -0,0 +1,11 @@ +## Open + +- alert: Opened on another device + +## Show file location + +- alert: File location shown on another device + +## Missing file + +- alert: Could not open. Try again.