From 64f4efe31df82eeaafc8bd1e144d7522061f0cd5 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 23 Oct 2026 15:14:06 +0800 Subject: [PATCH 1/8] fix(ui-conversation): decorate only whole folder tokens in the composer A pasted file path such as @src/a.ts or @"dir/a b.md" styled its directory prefix as a folder reference. --- .../src/client/input/decorations.ts | 36 ++++++++++++++----- .../tests/draft-document.client.spec.ts | 7 ++++ 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/packages/client/ui-conversation/src/client/input/decorations.ts b/packages/client/ui-conversation/src/client/input/decorations.ts index d71c4fb252..83be8dec9c 100644 --- a/packages/client/ui-conversation/src/client/input/decorations.ts +++ b/packages/client/ui-conversation/src/client/input/decorations.ts @@ -22,7 +22,28 @@ 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"]+))/g +/** What may follow a folder token: the trailing punctuation the sent-message path rule strips, then whitespace or the draft end. */ +const FOLDER_END_RE = /[.,;:!?,。;:!?]*(?:\s|$)/y + +/** + * End of the whole `@dir/` folder token at `start`, so `@src/a.ts` and + * `@"a/b c.md"` never match their directory prefix. A closed quote must + * directly follow the slash; an unquoted or open-quoted token (a drill in + * progress) ends at its last slash. + * @param draft - draft text. + * @param start - offset of the token's `@`. + * @param token - the path token at `start`. + * @returns the folder token's end offset, or undefined for a non-folder token. + */ +function folderEnd(draft: string, start: number, token: string): number | undefined { + const closed = token.length > 2 && token.startsWith('@"') && token.endsWith('"') + const end = closed ? start + token.length : start + token.lastIndexOf('/') + 1 + if (end === start || (closed && !token.endsWith('/"'))) return undefined + FOLDER_END_RE.lastIndex = end + return FOLDER_END_RE.test(draft) ? end : 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 +77,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 = folderEnd(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 ef737eec06..c7b15cff18 100644 --- a/packages/client/ui-conversation/tests/draft-document.client.spec.ts +++ b/packages/client/ui-conversation/tests/draft-document.client.spec.ts @@ -811,4 +811,11 @@ describe('plain-text reference catalogs', () => { { start: 0, end: 14, trigger: '@' }, ]) }) + + it('matches whole folder tokens, never the directory prefix of a pasted file path', () => { + expect(scanTextRefs('@src/deep/file.txt @"/a/nv/SM100 ISA.html" 看 @"a/ b.md" @src/.env @src/de', new Map())).toEqual([]) + const text = '@"dir a/" @src/deep/。 @dir/ @"open dir/ x' + expect(scanTextRefs(text, new Map()).map(range => text.slice(range.start, range.end))) + .toEqual(['@"dir a/"', '@src/deep/', '@dir/', '@"open dir/']) + }) }) From c403ca4d77172bd71be425713b0d17d9269b4f87 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 23 Oct 2026 15:57:24 +0800 Subject: [PATCH 2/8] fix(ui-conversation): end closed quoted folder tokens at the quote A closed @"dir/" followed directly by text stays a folder reference, as in the sent-message rule; add a paste regression for the composer marks. --- .../src/client/input/decorations.ts | 22 +++++++++++-------- .../tests/draft-document.client.spec.ts | 4 ++-- .../tests/input-bar.client.spec.tsx | 9 ++++++++ 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/packages/client/ui-conversation/src/client/input/decorations.ts b/packages/client/ui-conversation/src/client/input/decorations.ts index 83be8dec9c..372fe57d0d 100644 --- a/packages/client/ui-conversation/src/client/input/decorations.ts +++ b/packages/client/ui-conversation/src/client/input/decorations.ts @@ -24,25 +24,29 @@ export interface TextRefRange { const TEXT_REF_RE = /(^|\s)([/@])([\w-]+)/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"]+))/g -/** What may follow a folder token: the trailing punctuation the sent-message path rule strips, then whitespace or the draft end. */ +/** + * What may follow an unquoted or open-quoted folder token's slash: trailing + * punctuation, then whitespace or the draft end. The class matches the + * punctuation that `folderReferenceRule` in ui-reference strips from a sent token. + */ const FOLDER_END_RE = /[.,;:!?,。;:!?]*(?:\s|$)/y /** * End of the whole `@dir/` folder token at `start`, so `@src/a.ts` and - * `@"a/b c.md"` never match their directory prefix. A closed quote must - * directly follow the slash; an unquoted or open-quoted token (a drill in - * progress) ends at its last slash. + * `@"a/b c.md"` never match their directory prefix. A closed quote ends the + * token when it directly follows the slash; an unquoted or open-quoted token + * (a drill in progress) ends at its last slash. * @param draft - draft text. * @param start - offset of the token's `@`. * @param token - the path token at `start`. * @returns the folder token's end offset, or undefined for a non-folder token. */ function folderEnd(draft: string, start: number, token: string): number | undefined { - const closed = token.length > 2 && token.startsWith('@"') && token.endsWith('"') - const end = closed ? start + token.length : start + token.lastIndexOf('/') + 1 - if (end === start || (closed && !token.endsWith('/"'))) return undefined - FOLDER_END_RE.lastIndex = end - return FOLDER_END_RE.test(draft) ? end : undefined + if (token.startsWith('@"') && token.endsWith('/"')) return start + token.length + const slash = token.lastIndexOf('/') + if (slash < 0 || token.endsWith('"')) return undefined + FOLDER_END_RE.lastIndex = start + slash + 1 + return FOLDER_END_RE.test(draft) ? start + slash + 1 : undefined } /** * What may follow a `/name` token: whitespace or the draft end, the boundary 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 c7b15cff18..d637f58ddd 100644 --- a/packages/client/ui-conversation/tests/draft-document.client.spec.ts +++ b/packages/client/ui-conversation/tests/draft-document.client.spec.ts @@ -814,8 +814,8 @@ describe('plain-text reference catalogs', () => { it('matches whole folder tokens, never the directory prefix of a pasted file path', () => { expect(scanTextRefs('@src/deep/file.txt @"/a/nv/SM100 ISA.html" 看 @"a/ b.md" @src/.env @src/de', new Map())).toEqual([]) - const text = '@"dir a/" @src/deep/。 @dir/ @"open dir/ x' + const text = '@"dir a/" @src/deep/。 @dir/ 请看 @"src/",然后 @"open dir/ x' expect(scanTextRefs(text, new Map()).map(range => text.slice(range.start, range.end))) - .toEqual(['@"dir a/"', '@src/deep/', '@dir/', '@"open dir/']) + .toEqual(['@"dir a/"', '@src/deep/', '@dir/', '@"src/"', '@"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 a6968a7055..fa01087a3b 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -1875,6 +1875,15 @@ describe('decorations', () => { expect(shell.snapshot.draft).toBe('see @src/components/') }) + it('a pasted file path stays plain: only a whole folder token decorates', 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(['@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') }) From b7849a5eea43bcdf0e8dbab97727bae25c7272f0 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 26 Oct 2026 14:40:33 +0800 Subject: [PATCH 3/8] fix(ptc): render structured cancellation reasons readably --- .../ptc-runtime-node/README.i18n.yaml | 4 ++-- .../ptc-runtime/ptc-runtime-node/README.md | 2 ++ .../ptc-runtime/ptc-runtime-node/README.zh.md | 2 ++ .../ptc-runtime-node/src/worker.ts | 4 +++- .../tests/host-failures.spec.ts | 15 +++++++++++++ .../ptc-runtime-node/tests/runtime.spec.ts | 11 +++++++--- snapshots/sdk/ptc-cancel/cancel.mjs | 10 +++++++++ snapshots/sdk/ptc-cancel/runtime.cordis.yml | 5 +++++ snapshots/sdk/ptc-cancel/session.v4.jsonl | 21 +++++++++++++++++++ snapshots/sdk/ptc-cancel/snapshot.yml | 7 +++++++ snapshots/sdk/sdk.snapshot.ts | 14 +++++++++++++ 11 files changed, 89 insertions(+), 6 deletions(-) create mode 100644 snapshots/sdk/ptc-cancel/cancel.mjs create mode 100644 snapshots/sdk/ptc-cancel/runtime.cordis.yml create mode 100644 snapshots/sdk/ptc-cancel/session.v4.jsonl create mode 100644 snapshots/sdk/ptc-cancel/snapshot.yml diff --git a/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml b/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml index 1ecdc1933e..a0ee6f5e6f 100644 --- a/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml +++ b/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml @@ -30,8 +30,8 @@ en: 3e7ab293fac35688 zh: 617ed4930bbef6c7 /deepseek-ai-dsh-ptc-runtime-node/use-this-package/failures: - en: 34a221d0f26b8e47 - zh: 2fd20ac9c9953c7f + en: 66fbe8baf4dc4506 + zh: b8adee47561365da /deepseek-ai-dsh-ptc-runtime-node/understand-the-implementation: en: 50e6cd38e298da31 zh: c8b252b3acf59147 diff --git a/packages/ptc-runtime/ptc-runtime-node/README.md b/packages/ptc-runtime/ptc-runtime-node/README.md index a7fe249acf..7c032381da 100644 --- a/packages/ptc-runtime/ptc-runtime-node/README.md +++ b/packages/ptc-runtime/ptc-runtime-node/README.md @@ -109,6 +109,8 @@ Omitting `timeoutMs` uses the configured elapsed default; numeric requests are v Program parse errors and thrown exceptions are `exception`; deadline expiry is `timeout`; cancellation is `abort`; unparsable or oversized control traffic and invalid program messages are `protocol`; unavailable confinement is `sandbox-unavailable`; early process exit, lost control transport or failed managed cleanup is `worker-exit`. Control I/O failures retain the transport diagnostic alongside the observed process exit status. The substrate-independent failure name remains `worker-exit` for process providers. Lossy completions are `invalid-output`, and an oversized outer result is `output-limit`, retaining the fitting log prefix. Invalid or unsupported options and calls after disposal reject as caller misuse. +Temporary environments keep a string or `Error` message supplied by the caller; other cancellation reasons, including structured Agent causes, report `execution canceled`. Retained environments report `execution canceled; retained variables were cleared`. + ----- diff --git a/packages/ptc-runtime/ptc-runtime-node/README.zh.md b/packages/ptc-runtime/ptc-runtime-node/README.zh.md index 7a2503ce1f..e20f6f7a7c 100644 --- a/packages/ptc-runtime/ptc-runtime-node/README.zh.md +++ b/packages/ptc-runtime/ptc-runtime-node/README.zh.md @@ -109,6 +109,8 @@ PTC 消费方按 [dsh-tool-executor-ptc](../../core/tool-executor-ptc/README.zh. 程序解析错误与抛出异常为 `exception`;截止到期为 `timeout`;取消为 `abort`;无法解析或超量的控制通信以及无效的程序消息为 `protocol`;约束不可用为 `sandbox-unavailable`;进程提前退出、控制通道丢失或受管清理失败为 `worker-exit`。控制通道 I/O 失败时,在观测到的进程退出状态之外保留通道诊断原因。进程提供方保留与执行基底无关的失败名 `worker-exit`。有损完成值为 `invalid-output`,外层结果超限为 `output-limit`,并保留能容纳的日志前缀。无效或不支持的选项,以及资源释放后的调用,以调用方误用拒绝。 +临时环境保留调用方提供的字符串或 `Error` 消息;其他取消原因(包括结构化 Agent 原因)显示为 `execution canceled`。保留状态的环境显示为 `execution canceled; retained variables were cleared`。 + ----- diff --git a/packages/ptc-runtime/ptc-runtime-node/src/worker.ts b/packages/ptc-runtime/ptc-runtime-node/src/worker.ts index ff5307c320..20814d19d2 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/worker.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/worker.ts @@ -107,7 +107,9 @@ export class ProgramWorker { } private abortMessage(signal: AbortSignal | undefined): string { - return this.retainsState ? 'execution canceled; retained variables were cleared' : messageOf(signal?.reason) + if (this.retainsState) return 'execution canceled; retained variables were cleared' + const reason: unknown = signal?.reason + return typeof reason === 'string' || reason instanceof Error ? messageOf(reason) : 'execution canceled' } private async evaluate(spec: PtcRunSpec, bindings: Map): Promise { diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/host-failures.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/host-failures.spec.ts index 5a5aad761e..da669c3a45 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/host-failures.spec.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/host-failures.spec.ts @@ -129,6 +129,21 @@ describe('Node runtime host failures', () => { expect(h.resolveExecutable).not.toHaveBeenCalled() }) + it.each<[unknown, string]>([ + [{ kind: 'user' }, 'execution canceled'], + [{ kind: 'parent' }, 'execution canceled'], + [{ kind: 'hook', reason: 'policy stopped the turn' }, 'execution canceled'], + [{ kind: 'disposed' }, 'execution canceled'], + [null, 'execution canceled'], + [new Error('stop requested'), 'stop requested'], + ['stop requested', 'stop requested'], + ])('renders pre-start cancellation %j as %s', async (reason, message) => { + const h = await setup() + const result = await h.start({ ...request, signal: AbortSignal.abort(reason) }, NO_INITIAL_FRAME) + expect(result.error).toEqual({ kind: 'abort', message }) + expect(h.resolveExecutable).not.toHaveBeenCalled() + }) + it('keeps a null-deadline run active past the numeric ceiling until cancellation', async () => { const h = await setup({ timeoutMs: 20, maxTimeoutMs: 40 }) const booted = Promise.withResolvers() diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/runtime.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/runtime.spec.ts index 03688e1ae4..ec7bae7fe4 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/runtime.spec.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/runtime.spec.ts @@ -186,9 +186,14 @@ describe('Node program process', () => { const entered = Promise.withResolvers() const controller = new AbortController() const active = run({ ...timing, program: 'void tools.enter({}); for (;;) {}', signal: controller.signal, bindings: bindings({ enter: async () => { entered.resolve(undefined); return null } }) }) - await entered.promise - controller.abort('stop') - expect((await active).error).toEqual({ kind: 'abort', message: 'stop' }) + try { + await entered.promise + controller.abort({ kind: 'user' }) + expect((await active).error).toEqual({ kind: 'abort', message: 'execution canceled' }) + } finally { + controller.abort('test cleanup') + await active + } }) it('closes a still-running descendant before returning the program result', async () => { diff --git a/snapshots/sdk/ptc-cancel/cancel.mjs b/snapshots/sdk/ptc-cancel/cancel.mjs new file mode 100644 index 0000000000..0f7e8ebb9d --- /dev/null +++ b/snapshots/sdk/ptc-cancel/cancel.mjs @@ -0,0 +1,10 @@ +/** Cancel only after a real PTC process has dispatched its readiness tool. */ +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.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..cf5deccb0a --- /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: code run failed (abort): execution canceled\nFile sandbox: danger-full-access."}],"isError":true,"id":"{{message:5}}"},"error":{"name":"CodeRunFailedError","code":"CODE_RUN_FAILED"}},"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..587996d653 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,17 @@ 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: code run failed (abort): execution canceled\nFile sandbox: danger-full-access.' }], + } } }) + 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') From aa455f5a40d45bf6cce0d89cdc4b4c6b77a244da Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 26 Oct 2026 15:03:19 +0800 Subject: [PATCH 4/8] fix(ptc): preserve structured cancellation details --- .../ptc-runtime-node/README.i18n.yaml | 4 +-- .../ptc-runtime/ptc-runtime-node/README.md | 2 +- .../ptc-runtime/ptc-runtime-node/README.zh.md | 2 +- .../ptc-runtime-node/src/worker.ts | 8 ++--- .../tests/host-failures.spec.ts | 29 +++++++++++++++---- .../tests/persistent-host.spec.ts | 4 +-- .../ptc-runtime-node/tests/persistent.spec.ts | 16 +++++++--- .../ptc-runtime-node/tests/runtime.spec.ts | 2 +- snapshots/sdk/ptc-cancel/session.v4.jsonl | 2 +- snapshots/sdk/sdk.snapshot.ts | 2 +- 10 files changed, 49 insertions(+), 22 deletions(-) diff --git a/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml b/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml index a0ee6f5e6f..d9d40d1475 100644 --- a/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml +++ b/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml @@ -30,8 +30,8 @@ en: 3e7ab293fac35688 zh: 617ed4930bbef6c7 /deepseek-ai-dsh-ptc-runtime-node/use-this-package/failures: - en: 66fbe8baf4dc4506 - zh: b8adee47561365da + en: d4d3294f357b4afc + zh: 69992be84c94cd4a /deepseek-ai-dsh-ptc-runtime-node/understand-the-implementation: en: 50e6cd38e298da31 zh: c8b252b3acf59147 diff --git a/packages/ptc-runtime/ptc-runtime-node/README.md b/packages/ptc-runtime/ptc-runtime-node/README.md index 7c032381da..012e1d3f75 100644 --- a/packages/ptc-runtime/ptc-runtime-node/README.md +++ b/packages/ptc-runtime/ptc-runtime-node/README.md @@ -109,7 +109,7 @@ Omitting `timeoutMs` uses the configured elapsed default; numeric requests are v Program parse errors and thrown exceptions are `exception`; deadline expiry is `timeout`; cancellation is `abort`; unparsable or oversized control traffic and invalid program messages are `protocol`; unavailable confinement is `sandbox-unavailable`; early process exit, lost control transport or failed managed cleanup is `worker-exit`. Control I/O failures retain the transport diagnostic alongside the observed process exit status. The substrate-independent failure name remains `worker-exit` for process providers. Lossy completions are `invalid-output`, and an oversized outer result is `output-limit`, retaining the fitting log prefix. Invalid or unsupported options and calls after disposal reject as caller misuse. -Temporary environments keep a string or `Error` message supplied by the caller; other cancellation reasons, including structured Agent causes, report `execution canceled`. Retained environments report `execution canceled; retained variables were cleared`. +Host failure diagnostics keep strings and `Error` messages unchanged and use Node inspection for other values, including objects with circular references. Cancellation diagnostics retain structured cause fields such as `kind` and a hook's `reason`; retained environments append `; retained variables were cleared`. ----- diff --git a/packages/ptc-runtime/ptc-runtime-node/README.zh.md b/packages/ptc-runtime/ptc-runtime-node/README.zh.md index e20f6f7a7c..d936d9d33f 100644 --- a/packages/ptc-runtime/ptc-runtime-node/README.zh.md +++ b/packages/ptc-runtime/ptc-runtime-node/README.zh.md @@ -109,7 +109,7 @@ PTC 消费方按 [dsh-tool-executor-ptc](../../core/tool-executor-ptc/README.zh. 程序解析错误与抛出异常为 `exception`;截止到期为 `timeout`;取消为 `abort`;无法解析或超量的控制通信以及无效的程序消息为 `protocol`;约束不可用为 `sandbox-unavailable`;进程提前退出、控制通道丢失或受管清理失败为 `worker-exit`。控制通道 I/O 失败时,在观测到的进程退出状态之外保留通道诊断原因。进程提供方保留与执行基底无关的失败名 `worker-exit`。有损完成值为 `invalid-output`,外层结果超限为 `output-limit`,并保留能容纳的日志前缀。无效或不支持的选项,以及资源释放后的调用,以调用方误用拒绝。 -临时环境保留调用方提供的字符串或 `Error` 消息;其他取消原因(包括结构化 Agent 原因)显示为 `execution canceled`。保留状态的环境显示为 `execution canceled; retained variables were cleared`。 +Host 失败诊断保留原始字符串与 `Error` 消息,并用 Node 检查格式化其他值,包括含有循环引用的对象。取消诊断保留结构化原因的字段,如 `kind` 与 hook 的 `reason`;保留状态的环境会追加 `; retained variables were cleared`。 ----- diff --git a/packages/ptc-runtime/ptc-runtime-node/src/worker.ts b/packages/ptc-runtime/ptc-runtime-node/src/worker.ts index 20814d19d2..2806de8b24 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/worker.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/worker.ts @@ -1,5 +1,6 @@ /** Managed Node process shared by temporary and owner-retained evaluations. */ import { AsyncLocalStorage } from 'node:async_hooks' +import { inspect } from 'node:util' import { Context } from '@deepseek-ai/cordis' import type { PtcRunSpec, PtcRunResult, PtcRunSandbox, PtcRunFailure, PtcBindingNamespace, PtcJsonValue } from '@deepseek-ai/dsh-ptc-runtime' import { SandboxUnavailableError, classifyRunnerFailure, isRunnerSpawnFailure } from '@deepseek-ai/dsh-sandbox' @@ -18,7 +19,8 @@ import { OutputLedger } from './output-ledger.ts' import { drainOutput, terminateProcess } from './output-stream.ts' import { readProgramResult, type ProgramBootData, type ProgramMethods, type HostMethods } from './protocol.ts' -const messageOf = (error: unknown): string => error instanceof Error ? error.message : String(error) +const messageOf = (error: unknown): string => error instanceof Error ? error.message + : typeof error === 'string' ? error : inspect(error, { customInspect: false }) interface Evaluation { settled: boolean @@ -107,9 +109,7 @@ export class ProgramWorker { } private abortMessage(signal: AbortSignal | undefined): string { - if (this.retainsState) return 'execution canceled; retained variables were cleared' - const reason: unknown = signal?.reason - return typeof reason === 'string' || reason instanceof Error ? messageOf(reason) : 'execution canceled' + return this.message(messageOf(signal?.reason)) } private async evaluate(spec: PtcRunSpec, bindings: Map): Promise { diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/host-failures.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/host-failures.spec.ts index da669c3a45..929ff480b9 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/host-failures.spec.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/host-failures.spec.ts @@ -130,11 +130,11 @@ describe('Node runtime host failures', () => { }) it.each<[unknown, string]>([ - [{ kind: 'user' }, 'execution canceled'], - [{ kind: 'parent' }, 'execution canceled'], - [{ kind: 'hook', reason: 'policy stopped the turn' }, 'execution canceled'], - [{ kind: 'disposed' }, 'execution canceled'], - [null, 'execution canceled'], + [{ kind: 'user' }, "{ kind: 'user' }"], + [{ kind: 'parent' }, "{ kind: 'parent' }"], + [{ kind: 'hook', reason: 'policy stopped the turn' }, "{ kind: 'hook', reason: 'policy stopped the turn' }"], + [{ kind: 'disposed' }, "{ kind: 'disposed' }"], + [null, 'null'], [new Error('stop requested'), 'stop requested'], ['stop requested', 'stop requested'], ])('renders pre-start cancellation %j as %s', async (reason, message) => { @@ -144,6 +144,25 @@ describe('Node runtime host failures', () => { expect(h.resolveExecutable).not.toHaveBeenCalled() }) + it('keeps structured startup failure details', async () => { + const h = await setup() + h.resolveExecutable.mockRejectedValue({ code: 'EXECUTABLE_UNAVAILABLE', executable: 'node' }) + const result = await h.start() + expect(result.error).toEqual({ + kind: 'worker-exit', message: "{ code: 'EXECUTABLE_UNAVAILABLE', executable: 'node' }", + }) + expect(h.spawn).not.toHaveBeenCalled() + }) + + it('renders a circular cancellation reason without interrupting settlement', async () => { + const h = await setup() + const reason: { kind: string; cause?: object } = { kind: 'hook' } + reason.cause = reason + const result = await h.start({ ...request, signal: AbortSignal.abort(reason) }, NO_INITIAL_FRAME) + expect(result.error).toEqual({ kind: 'abort', message: " { kind: 'hook', cause: [Circular *1] }" }) + expect(h.resolveExecutable).not.toHaveBeenCalled() + }) + it('keeps a null-deadline run active past the numeric ceiling until cancellation', async () => { const h = await setup({ timeoutMs: 20, maxTimeoutMs: 40 }) const booted = Promise.withResolvers() diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/persistent-host.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/persistent-host.spec.ts index 26298852a6..39b6508d4d 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/persistent-host.spec.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/persistent-host.spec.ts @@ -316,11 +316,11 @@ describe('persistent PTC host protocol', () => { h.direct.resolve({ exitCode: 9, signal: null }) await Promise.resolve() const controller = new AbortController() - if (outcome === 'abort') controller.abort() + if (outcome === 'abort') controller.abort({ kind: 'parent' }) if (outcome === 'cleanup') h.waitForExit.mockRejectedValue(new Error('exit cleanup failed')) const result = await h.runtime.run(h.runtime.resolve({ ...request, program: '2', signal: controller.signal })) expect(result.error?.kind).toBe(outcome === 'abort' ? 'abort' : 'worker-exit') - expect(result.error?.message).toContain(outcome === 'abort' ? 'execution canceled' : outcome === 'cleanup' ? 'exit cleanup failed' : 'exited (9, signal null)') + expect(result.error?.message).toContain(outcome === 'abort' ? "{ kind: 'parent' }; retained variables were cleared" : outcome === 'cleanup' ? 'exit cleanup failed' : 'exited (9, signal null)') expect(h.messages).toHaveLength(1) expect(h.messages[0]).toMatchObject({ t: 'q', m: 'run' }) expect(h.spawn).toHaveBeenCalledTimes(1) diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/persistent.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/persistent.spec.ts index 67cdcc7c8a..428b162a61 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/persistent.spec.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/persistent.spec.ts @@ -207,10 +207,18 @@ describe.each([ signal: controller.signal, bindings: bindings(async () => { entered.resolve(true); return null }), }) - await entered.promise - controller.abort() - expect((await pending).error?.kind).toBe('abort') - expect((await run('typeof app')).value).toBe('undefined') + try { + await entered.promise + controller.abort({ kind: 'hook', reason: 'policy stopped the turn' }) + expect((await pending).error).toEqual({ + kind: 'abort', + message: "{ kind: 'hook', reason: 'policy stopped the turn' }; retained variables were cleared", + }) + expect((await run('typeof app')).value).toBe('undefined') + } finally { + controller.abort('test cleanup') + await pending + } }) it('resets a timed-out environment and does not reuse an exited process', async () => { diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/runtime.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/runtime.spec.ts index ec7bae7fe4..6c70755fea 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/runtime.spec.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/runtime.spec.ts @@ -189,7 +189,7 @@ describe('Node program process', () => { try { await entered.promise controller.abort({ kind: 'user' }) - expect((await active).error).toEqual({ kind: 'abort', message: 'execution canceled' }) + expect((await active).error).toEqual({ kind: 'abort', message: "{ kind: 'user' }" }) } finally { controller.abort('test cleanup') await active diff --git a/snapshots/sdk/ptc-cancel/session.v4.jsonl b/snapshots/sdk/ptc-cancel/session.v4.jsonl index cf5deccb0a..e143054de1 100644 --- a/snapshots/sdk/ptc-cancel/session.v4.jsonl +++ b/snapshots/sdk/ptc-cancel/session.v4.jsonl @@ -16,6 +16,6 @@ {"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: code run failed (abort): execution canceled\nFile sandbox: danger-full-access."}],"isError":true,"id":"{{message:5}}"},"error":{"name":"CodeRunFailedError","code":"CODE_RUN_FAILED"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"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: code run failed (abort): { kind: 'user' }\nFile sandbox: danger-full-access."}],"isError":true,"id":"{{message:5}}"},"error":{"name":"CodeRunFailedError","code":"CODE_RUN_FAILED"}},"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/sdk.snapshot.ts b/snapshots/sdk/sdk.snapshot.ts index 587996d653..3bd8b0965b 100644 --- a/snapshots/sdk/sdk.snapshot.ts +++ b/snapshots/sdk/sdk.snapshot.ts @@ -1018,7 +1018,7 @@ describe('TypeScript SDK snapshots over the jsonrpc runtime', () => { expect(events.find(event => event.type === 'tool/result')).toMatchObject({ data: { message: { toolCallId: 'ptc-cancel', isError: true, - content: [{ type: 'text', text: 'Error: code run failed (abort): execution canceled\nFile sandbox: danger-full-access.' }], + content: [{ type: 'text', text: "Error: code run failed (abort): { kind: 'user' }\nFile sandbox: danger-full-access." }], } } }) expect(events.find(event => event.type === 'turn/end')).toMatchObject({ data: { reason: { kind: 'aborted', reason: { kind: 'user' } } }, From 9e979a71e6af1a0db1005c63cc96916f5ec40602 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 26 Oct 2026 15:43:05 +0800 Subject: [PATCH 5/8] fix(ptc): align caller cancellation with native tools --- .../ptc-caller-cancellation/guide.i18n.yaml | 16 +++++ .../ptc-caller-cancellation/guide.md | 20 ++++++ .../ptc-caller-cancellation/guide.zh.md | 20 ++++++ .../core/tool-executor-ptc/README.i18n.yaml | 4 +- packages/core/tool-executor-ptc/README.md | 2 + packages/core/tool-executor-ptc/README.zh.md | 2 + packages/core/tool-executor-ptc/src/errors.ts | 3 +- packages/core/tool-executor-ptc/src/ptc.ts | 9 ++- .../core/tool-executor-ptc/tests/ptc.spec.ts | 65 +++++++++++++++++-- .../ptc-runtime-node/README.i18n.yaml | 4 +- .../ptc-runtime/ptc-runtime-node/README.md | 2 - .../ptc-runtime/ptc-runtime-node/README.zh.md | 2 - .../ptc-runtime-node/src/worker.ts | 6 +- .../tests/host-failures.spec.ts | 34 ---------- .../tests/persistent-host.spec.ts | 4 +- .../ptc-runtime-node/tests/persistent.spec.ts | 16 ++--- .../ptc-runtime-node/tests/runtime.spec.ts | 11 +--- snapshots/sdk/ptc-cancel/session.v4.jsonl | 2 +- snapshots/sdk/sdk.snapshot.ts | 13 ++-- 19 files changed, 151 insertions(+), 84 deletions(-) create mode 100644 docs/upgrade-guide/v0.2.1-rc.3/ptc-caller-cancellation/guide.i18n.yaml create mode 100644 docs/upgrade-guide/v0.2.1-rc.3/ptc-caller-cancellation/guide.md create mode 100644 docs/upgrade-guide/v0.2.1-rc.3/ptc-caller-cancellation/guide.zh.md 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/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..d0c3a400af 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,54 @@ describe('the run_code dispatch bridge', () => { expect(calls).toEqual([]) }) + it.each([ + { kind: 'user' }, + { kind: 'parent' }, + { kind: 'hook', reason: 'policy stopped the turn' }, + { kind: 'disposed' }, + ])('maps a $kind caller cancellation to the native tool abort result', async (cause) => { + const { ctx, runtime } = await setup({ mode: 'ptc' }) + onTestFinished(async () => { await ctx.fiber.dispose() }) + const controller = new AbortController() + runtime.behavior = async (request) => { + controller.abort(cause) + expect(request.signal?.reason).toBe(cause) + return { logs: [], error: { kind: 'abort', message: '[object Object]' } } + } + + 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/ptc-runtime/ptc-runtime-node/README.i18n.yaml b/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml index d9d40d1475..1ecdc1933e 100644 --- a/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml +++ b/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml @@ -30,8 +30,8 @@ en: 3e7ab293fac35688 zh: 617ed4930bbef6c7 /deepseek-ai-dsh-ptc-runtime-node/use-this-package/failures: - en: d4d3294f357b4afc - zh: 69992be84c94cd4a + en: 34a221d0f26b8e47 + zh: 2fd20ac9c9953c7f /deepseek-ai-dsh-ptc-runtime-node/understand-the-implementation: en: 50e6cd38e298da31 zh: c8b252b3acf59147 diff --git a/packages/ptc-runtime/ptc-runtime-node/README.md b/packages/ptc-runtime/ptc-runtime-node/README.md index 012e1d3f75..a7fe249acf 100644 --- a/packages/ptc-runtime/ptc-runtime-node/README.md +++ b/packages/ptc-runtime/ptc-runtime-node/README.md @@ -109,8 +109,6 @@ Omitting `timeoutMs` uses the configured elapsed default; numeric requests are v Program parse errors and thrown exceptions are `exception`; deadline expiry is `timeout`; cancellation is `abort`; unparsable or oversized control traffic and invalid program messages are `protocol`; unavailable confinement is `sandbox-unavailable`; early process exit, lost control transport or failed managed cleanup is `worker-exit`. Control I/O failures retain the transport diagnostic alongside the observed process exit status. The substrate-independent failure name remains `worker-exit` for process providers. Lossy completions are `invalid-output`, and an oversized outer result is `output-limit`, retaining the fitting log prefix. Invalid or unsupported options and calls after disposal reject as caller misuse. -Host failure diagnostics keep strings and `Error` messages unchanged and use Node inspection for other values, including objects with circular references. Cancellation diagnostics retain structured cause fields such as `kind` and a hook's `reason`; retained environments append `; retained variables were cleared`. - ----- diff --git a/packages/ptc-runtime/ptc-runtime-node/README.zh.md b/packages/ptc-runtime/ptc-runtime-node/README.zh.md index d936d9d33f..7a2503ce1f 100644 --- a/packages/ptc-runtime/ptc-runtime-node/README.zh.md +++ b/packages/ptc-runtime/ptc-runtime-node/README.zh.md @@ -109,8 +109,6 @@ PTC 消费方按 [dsh-tool-executor-ptc](../../core/tool-executor-ptc/README.zh. 程序解析错误与抛出异常为 `exception`;截止到期为 `timeout`;取消为 `abort`;无法解析或超量的控制通信以及无效的程序消息为 `protocol`;约束不可用为 `sandbox-unavailable`;进程提前退出、控制通道丢失或受管清理失败为 `worker-exit`。控制通道 I/O 失败时,在观测到的进程退出状态之外保留通道诊断原因。进程提供方保留与执行基底无关的失败名 `worker-exit`。有损完成值为 `invalid-output`,外层结果超限为 `output-limit`,并保留能容纳的日志前缀。无效或不支持的选项,以及资源释放后的调用,以调用方误用拒绝。 -Host 失败诊断保留原始字符串与 `Error` 消息,并用 Node 检查格式化其他值,包括含有循环引用的对象。取消诊断保留结构化原因的字段,如 `kind` 与 hook 的 `reason`;保留状态的环境会追加 `; retained variables were cleared`。 - ----- diff --git a/packages/ptc-runtime/ptc-runtime-node/src/worker.ts b/packages/ptc-runtime/ptc-runtime-node/src/worker.ts index 2806de8b24..ff5307c320 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/worker.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/worker.ts @@ -1,6 +1,5 @@ /** Managed Node process shared by temporary and owner-retained evaluations. */ import { AsyncLocalStorage } from 'node:async_hooks' -import { inspect } from 'node:util' import { Context } from '@deepseek-ai/cordis' import type { PtcRunSpec, PtcRunResult, PtcRunSandbox, PtcRunFailure, PtcBindingNamespace, PtcJsonValue } from '@deepseek-ai/dsh-ptc-runtime' import { SandboxUnavailableError, classifyRunnerFailure, isRunnerSpawnFailure } from '@deepseek-ai/dsh-sandbox' @@ -19,8 +18,7 @@ import { OutputLedger } from './output-ledger.ts' import { drainOutput, terminateProcess } from './output-stream.ts' import { readProgramResult, type ProgramBootData, type ProgramMethods, type HostMethods } from './protocol.ts' -const messageOf = (error: unknown): string => error instanceof Error ? error.message - : typeof error === 'string' ? error : inspect(error, { customInspect: false }) +const messageOf = (error: unknown): string => error instanceof Error ? error.message : String(error) interface Evaluation { settled: boolean @@ -109,7 +107,7 @@ export class ProgramWorker { } private abortMessage(signal: AbortSignal | undefined): string { - return this.message(messageOf(signal?.reason)) + return this.retainsState ? 'execution canceled; retained variables were cleared' : messageOf(signal?.reason) } private async evaluate(spec: PtcRunSpec, bindings: Map): Promise { diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/host-failures.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/host-failures.spec.ts index 929ff480b9..5a5aad761e 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/host-failures.spec.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/host-failures.spec.ts @@ -129,40 +129,6 @@ describe('Node runtime host failures', () => { expect(h.resolveExecutable).not.toHaveBeenCalled() }) - it.each<[unknown, string]>([ - [{ kind: 'user' }, "{ kind: 'user' }"], - [{ kind: 'parent' }, "{ kind: 'parent' }"], - [{ kind: 'hook', reason: 'policy stopped the turn' }, "{ kind: 'hook', reason: 'policy stopped the turn' }"], - [{ kind: 'disposed' }, "{ kind: 'disposed' }"], - [null, 'null'], - [new Error('stop requested'), 'stop requested'], - ['stop requested', 'stop requested'], - ])('renders pre-start cancellation %j as %s', async (reason, message) => { - const h = await setup() - const result = await h.start({ ...request, signal: AbortSignal.abort(reason) }, NO_INITIAL_FRAME) - expect(result.error).toEqual({ kind: 'abort', message }) - expect(h.resolveExecutable).not.toHaveBeenCalled() - }) - - it('keeps structured startup failure details', async () => { - const h = await setup() - h.resolveExecutable.mockRejectedValue({ code: 'EXECUTABLE_UNAVAILABLE', executable: 'node' }) - const result = await h.start() - expect(result.error).toEqual({ - kind: 'worker-exit', message: "{ code: 'EXECUTABLE_UNAVAILABLE', executable: 'node' }", - }) - expect(h.spawn).not.toHaveBeenCalled() - }) - - it('renders a circular cancellation reason without interrupting settlement', async () => { - const h = await setup() - const reason: { kind: string; cause?: object } = { kind: 'hook' } - reason.cause = reason - const result = await h.start({ ...request, signal: AbortSignal.abort(reason) }, NO_INITIAL_FRAME) - expect(result.error).toEqual({ kind: 'abort', message: " { kind: 'hook', cause: [Circular *1] }" }) - expect(h.resolveExecutable).not.toHaveBeenCalled() - }) - it('keeps a null-deadline run active past the numeric ceiling until cancellation', async () => { const h = await setup({ timeoutMs: 20, maxTimeoutMs: 40 }) const booted = Promise.withResolvers() diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/persistent-host.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/persistent-host.spec.ts index 39b6508d4d..26298852a6 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/persistent-host.spec.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/persistent-host.spec.ts @@ -316,11 +316,11 @@ describe('persistent PTC host protocol', () => { h.direct.resolve({ exitCode: 9, signal: null }) await Promise.resolve() const controller = new AbortController() - if (outcome === 'abort') controller.abort({ kind: 'parent' }) + if (outcome === 'abort') controller.abort() if (outcome === 'cleanup') h.waitForExit.mockRejectedValue(new Error('exit cleanup failed')) const result = await h.runtime.run(h.runtime.resolve({ ...request, program: '2', signal: controller.signal })) expect(result.error?.kind).toBe(outcome === 'abort' ? 'abort' : 'worker-exit') - expect(result.error?.message).toContain(outcome === 'abort' ? "{ kind: 'parent' }; retained variables were cleared" : outcome === 'cleanup' ? 'exit cleanup failed' : 'exited (9, signal null)') + expect(result.error?.message).toContain(outcome === 'abort' ? 'execution canceled' : outcome === 'cleanup' ? 'exit cleanup failed' : 'exited (9, signal null)') expect(h.messages).toHaveLength(1) expect(h.messages[0]).toMatchObject({ t: 'q', m: 'run' }) expect(h.spawn).toHaveBeenCalledTimes(1) diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/persistent.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/persistent.spec.ts index 428b162a61..67cdcc7c8a 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/persistent.spec.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/persistent.spec.ts @@ -207,18 +207,10 @@ describe.each([ signal: controller.signal, bindings: bindings(async () => { entered.resolve(true); return null }), }) - try { - await entered.promise - controller.abort({ kind: 'hook', reason: 'policy stopped the turn' }) - expect((await pending).error).toEqual({ - kind: 'abort', - message: "{ kind: 'hook', reason: 'policy stopped the turn' }; retained variables were cleared", - }) - expect((await run('typeof app')).value).toBe('undefined') - } finally { - controller.abort('test cleanup') - await pending - } + await entered.promise + controller.abort() + expect((await pending).error?.kind).toBe('abort') + expect((await run('typeof app')).value).toBe('undefined') }) it('resets a timed-out environment and does not reuse an exited process', async () => { diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/runtime.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/runtime.spec.ts index 6c70755fea..03688e1ae4 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/runtime.spec.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/runtime.spec.ts @@ -186,14 +186,9 @@ describe('Node program process', () => { const entered = Promise.withResolvers() const controller = new AbortController() const active = run({ ...timing, program: 'void tools.enter({}); for (;;) {}', signal: controller.signal, bindings: bindings({ enter: async () => { entered.resolve(undefined); return null } }) }) - try { - await entered.promise - controller.abort({ kind: 'user' }) - expect((await active).error).toEqual({ kind: 'abort', message: "{ kind: 'user' }" }) - } finally { - controller.abort('test cleanup') - await active - } + await entered.promise + controller.abort('stop') + expect((await active).error).toEqual({ kind: 'abort', message: 'stop' }) }) it('closes a still-running descendant before returning the program result', async () => { diff --git a/snapshots/sdk/ptc-cancel/session.v4.jsonl b/snapshots/sdk/ptc-cancel/session.v4.jsonl index e143054de1..f7826ecbbb 100644 --- a/snapshots/sdk/ptc-cancel/session.v4.jsonl +++ b/snapshots/sdk/ptc-cancel/session.v4.jsonl @@ -16,6 +16,6 @@ {"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: code run failed (abort): { kind: 'user' }\nFile sandbox: danger-full-access."}],"isError":true,"id":"{{message:5}}"},"error":{"name":"CodeRunFailedError","code":"CODE_RUN_FAILED"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"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/sdk.snapshot.ts b/snapshots/sdk/sdk.snapshot.ts index 3bd8b0965b..1a687411fc 100644 --- a/snapshots/sdk/sdk.snapshot.ts +++ b/snapshots/sdk/sdk.snapshot.ts @@ -1015,11 +1015,14 @@ describe('TypeScript SDK snapshots over the jsonrpc runtime', () => { 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: code run failed (abort): { kind: 'user' }\nFile sandbox: danger-full-access." }], - } } }) + 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' } } }, }) From deebbf319109eee2d2084b8dadb311f370dfd3bd Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 26 Oct 2026 16:33:21 +0800 Subject: [PATCH 6/8] fix(ui-conversation): decorate pasted file paths as whole tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closed @"…" paths and unquoted @ tokens with a separator or extension now decorate in full, matching the spans the sent message renders as references. --- .../src/client/input/decorations.ts | 50 +++++++++++-------- .../tests/draft-document.client.spec.ts | 12 +++-- .../tests/input-bar.client.spec.tsx | 4 +- 3 files changed, 40 insertions(+), 26 deletions(-) diff --git a/packages/client/ui-conversation/src/client/input/decorations.ts b/packages/client/ui-conversation/src/client/input/decorations.ts index 372fe57d0d..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 @@ -23,30 +23,40 @@ 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 /** One `@` path token: a quoted path through its closing quote or line end, or an unquoted run. */ -const PATH_TOKEN_RE = /(^|\s)(@(?:"[^"\n]*"?|[^\s"]+))/g +const PATH_TOKEN_RE = /(^|\s)(@(?:"[^"\n]*"?|[^\s"]\S*))/g /** - * What may follow an unquoted or open-quoted folder token's slash: trailing - * punctuation, then whitespace or the draft end. The class matches the - * punctuation that `folderReferenceRule` in ui-reference strips from a sent token. + * 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 FOLDER_END_RE = /[.,;:!?,。;:!?]*(?:\s|$)/y +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 `@dir/` folder token at `start`, so `@src/a.ts` and - * `@"a/b c.md"` never match their directory prefix. A closed quote ends the - * token when it directly follows the slash; an unquoted or open-quoted token - * (a drill in progress) ends at its last slash. + * 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 folder token's end offset, or undefined for a non-folder token. + * @returns the token's end offset, or undefined when it names no path. */ -function folderEnd(draft: string, start: number, token: string): number | undefined { - if (token.startsWith('@"') && token.endsWith('/"')) return start + token.length +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 || token.endsWith('"')) return undefined - FOLDER_END_RE.lastIndex = start + slash + 1 - return FOLDER_END_RE.test(draft) ? start + slash + 1 : undefined + 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 @@ -85,7 +95,7 @@ export function scanTextRefs( let path: RegExpExecArray | null while ((path = PATH_TOKEN_RE.exec(draft)) !== null) { const start = path.index + (path[1]?.length ?? 0) - const end = folderEnd(draft, start, path[2] ?? '') + 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 d637f58ddd..cffced410d 100644 --- a/packages/client/ui-conversation/tests/draft-document.client.spec.ts +++ b/packages/client/ui-conversation/tests/draft-document.client.spec.ts @@ -812,10 +812,14 @@ describe('plain-text reference catalogs', () => { ]) }) - it('matches whole folder tokens, never the directory prefix of a pasted file path', () => { - expect(scanTextRefs('@src/deep/file.txt @"/a/nv/SM100 ISA.html" 看 @"a/ b.md" @src/.env @src/de', new Map())).toEqual([]) - const text = '@"dir a/" @src/deep/。 @dir/ 请看 @"src/",然后 @"open dir/ x' - expect(scanTextRefs(text, new Map()).map(range => text.slice(range.start, range.end))) + 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 fa01087a3b..0112d60abb 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -1875,13 +1875,13 @@ describe('decorations', () => { expect(shell.snapshot.draft).toBe('see @src/components/') }) - it('a pasted file path stays plain: only a whole folder token decorates', async () => { + 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(['@src/deep/']) + 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)', () => { From 1576a9f7eb1183493112d499633589900ecb6980 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 26 Oct 2026 16:40:17 +0800 Subject: [PATCH 7/8] test(ptc): cover reviewed cancellation cases --- .../core/tool-executor-ptc/tests/ptc.spec.ts | 50 +++++++++++-------- snapshots/sdk/ptc-cancel/cancel.mjs | 4 +- 2 files changed, 30 insertions(+), 24 deletions(-) diff --git a/packages/core/tool-executor-ptc/tests/ptc.spec.ts b/packages/core/tool-executor-ptc/tests/ptc.spec.ts index d0c3a400af..99c9dd38c1 100644 --- a/packages/core/tool-executor-ptc/tests/ptc.spec.ts +++ b/packages/core/tool-executor-ptc/tests/ptc.spec.ts @@ -2048,30 +2048,36 @@ describe('the run_code dispatch bridge', () => { expect(calls).toEqual([]) }) - it.each([ - { kind: 'user' }, - { kind: 'parent' }, - { kind: 'hook', reason: 'policy stopped the turn' }, - { kind: 'disposed' }, - ])('maps a $kind caller cancellation to the native tool abort result', async (cause) => { - const { ctx, runtime } = await setup({ mode: 'ptc' }) - onTestFinished(async () => { await ctx.fiber.dispose() }) - const controller = new AbortController() - runtime.behavior = async (request) => { - controller.abort(cause) - expect(request.signal?.reason).toBe(cause) - return { logs: [], error: { kind: 'abort', message: '[object Object]' } } - } + 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 }) + 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 }, - }, + expect(result).toEqual({ + content: [{ type: 'text', text: 'Error: tool call aborted' }], + isError: true, + error: { + message: 'tool call aborted', + info: { name: 'AbortError', code: TOOL_ABORTED }, + }, + }) }) }) diff --git a/snapshots/sdk/ptc-cancel/cancel.mjs b/snapshots/sdk/ptc-cancel/cancel.mjs index 0f7e8ebb9d..ea2191874d 100644 --- a/snapshots/sdk/ptc-cancel/cancel.mjs +++ b/snapshots/sdk/ptc-cancel/cancel.mjs @@ -1,9 +1,9 @@ -/** Cancel only after a real PTC process has dispatched its readiness tool. */ +/** 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) { + if (exec.name === 'working_directory' && exec.parent !== undefined && exec.agent !== undefined) { exec.agent.cancel({ kind: 'user' }) } }) From 1cc040b535ae1d380f8152e8d707a6fd9011d205 Mon Sep 17 00:00:00 2001 From: Feiyu Xia <22588993+Stevring@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:16:44 +0800 Subject: [PATCH 8/8] fix(client): clarify mobile native-open feedback (#6660) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ui-open-in-app): announce settled open outcomes app-wide The shared file and directory opener reported a nullable failure, so it could not tell a handoff the Host acknowledged from a gesture an adapter deliberately dropped — a duplicate already in flight, or a controller disposed before the gesture ran. Its notice also lived inside the control that asked for the open, so it died with the panel, tab, or card the gesture started in. Replace that with a discriminated outcome on both controllers and adapters, and report each settled gesture into one `shell.overlay` entry that renders the app-wide Toast: the Session-header command, the files-tab and Session-header buttons, both document-preview controls, and both delivery-card controls. `{ kind: 'skipped' }` announces nothing, because no handoff happened, so card status and the toast never disagree about what settled. Name the action for what it does on every surface — "Open on your computer" — instead of after whichever application would take the handoff. The application stays supplementary, in the tooltip beside the shortcut it runs under, which is what the accessible name, visible text, and command label now share. * fix(client): unify open-in-app feedback * fix(client): report native open outcomes accurately * fix(client): tighten open feedback outcomes and lifecycle tests * test(ptc): synchronize hot-loop log capture Observe a synchronous child marker before cancellation so the console retention assertion does not depend on completing Node startup within one second. * test(bench): interleave scaling samples * fix(client): scope native-open feedback to mobile Explain native opens on another device for mobile and PWA clients while keeping desktop success silent. Preserve failure feedback after the initiating surface closes and ignore skipped requests. Remove unrelated benchmark and PTC test changes and retain existing button labels. Refs #6097 --------- Co-authored-by: EricMoin Co-authored-by: EricMoin <61308504+EricMoin@users.noreply.github.com> --- apps/web/tests/present.e2e.ts | 89 ++++++++---- .../client/ui-deliverables/README.i18n.yaml | 4 +- packages/client/ui-deliverables/README.md | 2 +- packages/client/ui-deliverables/README.zh.md | 2 +- .../src/client/Deliverables.module.css | 6 - .../src/client/PresentedFileCard.tsx | 11 +- .../src/client/file-actions.ts | 6 +- .../ui-deliverables/src/client/index.ts | 2 + .../ui-deliverables/src/client/locales.ts | 24 ---- .../src/client/present-open.ts | 67 ++++----- .../tests/deliverables.client.spec.tsx | 12 +- .../tests/present-open.client.spec.ts | 86 +++++------ .../tests/presented-file-card.client.spec.tsx | 9 +- .../tests/review-tab.client.spec.tsx | 2 +- .../client/ui-open-in-app/README.i18n.yaml | 8 +- packages/client/ui-open-in-app/README.md | 8 +- packages/client/ui-open-in-app/README.zh.md | 8 +- .../src/client/FileRouteAction.tsx | 16 +-- .../src/client/OpenInAppAction.tsx | 22 ++- .../src/client/OpenPathAction.tsx | 13 +- .../src/client/OpenTargetButton.tsx | 135 +++++++++--------- .../ui-open-in-app/src/client/controller.ts | 15 +- .../client/ui-open-in-app/src/client/index.ts | 22 ++- .../ui-open-in-app/src/client/locales.ts | 16 +++ .../src/client/open-failure-toast.tsx | 26 ---- .../ui-open-in-app/src/client/open-path.ts | 12 +- .../ui-open-in-app/src/client/open-toast.tsx | 65 +++++++++ .../ui-open-in-app/src/client/outcome.ts | 8 ++ .../tests/browser-plugin.client.spec.ts | 120 ++++++++++++++-- .../tests/controller.client.spec.ts | 13 +- .../tests/file-route-action.client.spec.tsx | 40 +++++- .../tests/open-in-app-action.client.spec.tsx | 25 +++- .../tests/open-path-action.client.spec.tsx | 71 ++++----- .../tests/open-path.client.spec.ts | 10 +- .../tests/open-toast.client.spec.tsx | 107 ++++++++++++++ .../src/client/slot-catalog.ts | 7 +- snapshots/web/present/mobile-open.expected.md | 11 ++ 37 files changed, 717 insertions(+), 383 deletions(-) delete mode 100644 packages/client/ui-open-in-app/src/client/open-failure-toast.tsx create mode 100644 packages/client/ui-open-in-app/src/client/open-toast.tsx create mode 100644 packages/client/ui-open-in-app/src/client/outcome.ts create mode 100644 packages/client/ui-open-in-app/tests/open-toast.client.spec.tsx create mode 100644 snapshots/web/present/mobile-open.expected.md 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/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index e18a4ecc9a..ff6839eee5 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 a82b593ea8..6727c12776 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 52169d6b34..45484627dd 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 f0dd26035b..b40bfa40a0 100644 --- a/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx +++ b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx @@ -4,7 +4,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' @@ -27,15 +27,13 @@ export function PresentedFileCard({ file, cwd, phase, host, onPreview, actions, onPreview: () => 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 64d58358e3..ea5f40f785 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: 42f7977b585f9c3a - zh: a13772312d52b97f + en: 940e8891c458dee1 + zh: 8fd2fcee25065b23 /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 05a1825a98..0ba07f3edf 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. @@ -45,9 +47,9 @@ Mounted controls with the same association reader and file share one query and i
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 b71fff7fd8..ac2371c621 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 高并显示图标和动作文字;根据默认动作显示“打开”或“显示文件位置”。 @@ -45,9 +47,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/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 8da9f15f9e..a661daedca 100644 --- a/packages/client/ui-open-in-app/src/client/index.ts +++ b/packages/client/ui-open-in-app/src/client/index.ts @@ -25,7 +25,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 { @@ -35,7 +37,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' @@ -53,6 +56,12 @@ 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) + // Keep operation feedback visible after its initiating panel closes. + const announcement = createOpenInAppToastSource() + 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) @@ -75,9 +84,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') @@ -89,6 +96,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}`, }) @@ -113,6 +121,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', @@ -126,10 +135,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 737b027d00..95497e6b96 100644 --- a/packages/client/ui-open-in-app/src/client/locales.ts +++ b/packages/client/ui-open-in-app/src/client/locales.ts @@ -48,6 +48,8 @@ export const zh = { 'path.open': '打开', 'path.more': '更多打开方式', 'path.reveal': '显示文件位置', + 'path.opened': '已在其它设备打开', + 'path.revealed': '已在其它设备显示文件位置', 'path.openError': '打开失败,请重试', 'path.revealError': '无法显示文件位置,请重试', ...PRODUCT_NAMES, @@ -68,6 +70,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, @@ -92,6 +96,8 @@ export const ja = { 'path.open': '開く', 'path.more': 'その他の開き方', 'path.reveal': 'ファイルの場所を表示', + 'path.opened': '別のデバイスで開きました', + 'path.revealed': '別のデバイスでファイルの場所を表示しました', 'path.openError': '開けませんでした。もう一度お試しください。', 'path.revealError': 'ファイルの場所を表示できませんでした。もう一度お試しください。', 'app.finder': 'Finder', @@ -111,6 +117,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, @@ -131,6 +139,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, @@ -151,6 +161,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, @@ -171,6 +183,8 @@ export const ru = { 'path.open': 'Открыть', 'path.more': 'Больше способов открыть', 'path.reveal': 'Показать местоположение файла', + 'path.opened': 'Открыто на другом устройстве', + 'path.revealed': 'Местоположение файла показано на другом устройстве', 'path.openError': 'Не удалось открыть. Попробуйте еще раз.', 'path.revealError': 'Не удалось показать местоположение файла. Попробуйте еще раз.', ...PRODUCT_NAMES, @@ -191,6 +205,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/tests/browser-plugin.client.spec.ts b/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts index 43b988f2c9..6bd3cf0642 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' @@ -22,9 +26,21 @@ import { apply as nodeApply } from '../src/index.ts' import { OpenInAppAction } from '../src/client/OpenInAppAction.tsx' import { OpenPathAction } from '../src/client/OpenPathAction.tsx' import { OpenPathEmptyAction } from '../src/client/OpenPathEmptyAction.tsx' +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() }) @@ -51,6 +67,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: {} }) @@ -75,14 +92,24 @@ 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('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(typeof command.label === 'function' ? command.label() : 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 @@ -93,9 +120,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 @@ -121,7 +153,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' })) @@ -131,6 +164,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(['sessions', 'slots', 'locale', 'remote', 'remote.session', 'shortcuts', 'layout']) }) @@ -164,6 +243,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() } }) @@ -187,7 +267,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') @@ -195,6 +275,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() @@ -236,7 +328,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 @@ -249,7 +341,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', @@ -259,7 +355,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 }) @@ -272,7 +368,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/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/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index c9481a6ebf..fd11e59cdf 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -2134,9 +2134,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', @@ -3501,6 +3503,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [ 'client-ui-chat QuotaNoticeHost id \'chat.quota-notice\'', '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/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.