Merge master and share native context menu feedback

This commit is contained in:
zhc committed 2026-10-26 17:31:44 +08:00
commit decd8fb29c
59 files changed
+1036 -430

No files matched your search

+64 -25
View File
@@ -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()
}
})
})
@@ -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
@@ -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`.
@@ -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`。
@@ -2,7 +2,7 @@
* Plain-text reference scan (the plain-text-reference decision;
* see .agents/notes/archived/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md):
* a `/name` or `@name` token whose name is on the trigger's lexicon, and
* syntax-recognizable `@dir/` folder tokens. Pure derivation — the editor's
* syntax-recognizable `@path` file and folder tokens. Pure derivation — the editor's
* text-ref entity transform consumes these ranges; editing the text out of
* match shape simply drops the range next scan.
*/
@@ -10,9 +10,9 @@
/**
* One plain-text reference range (the plain-text-reference decision;
* see .agents/notes/archived/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md):
* a `/name` or `@name` token
* whose name is on the trigger's lexicon. Pure derivation — editing the text
* out of match shape simply drops the range next scan.
* a `/name` or `@name` token whose name is on the trigger's lexicon, or an
* `@path` file or folder token. Pure derivation — editing the text out of
* match shape simply drops the range next scan.
*/
export interface TextRefRange {
readonly start: number
@@ -22,7 +22,42 @@ export interface TextRefRange {
/** Token matcher: a trigger char at line start or after whitespace, then a word-ish name (never crosses \n). */
const TEXT_REF_RE = /(^|\s)([/@])([\w-]+)/g
const FOLDER_REF_RE = /(^|\s)(@(?:"[^"\n]*\/|[^\s"]+\/))/g
/** One `@` path token: a quoted path through its closing quote or line end, or an unquoted run. */
const PATH_TOKEN_RE = /(^|\s)(@(?:"[^"\n]*"?|[^\s"]\S*))/g
/**
* Punctuation that ends prose rather than a path: the characters the
* sent-message file and folder rules in ui-reference strip from the end of an
* unquoted token.
*/
const TRAILING_PUNCTUATION = new Set('.,;:!?,。;:!?')
/** An unquoted token names a path only with a separator or an extension; a bare `@name` stays plain until a lexicon claims it. */
const PATH_LIKE_RE = /^@.*[/\\.]/u
/**
* End of the whole `@path` file or folder token at `start`, so a path is never
* cut at its directory prefix. A closed quote ends at its quote and an
* unquoted token with a separator or extension drops trailing punctuation,
* the spans the sent-message rules render. An open quote (a folder drill in
* progress) ends at its last slash when only trailing punctuation and
* whitespace or the draft end follow it.
* @param draft - draft text.
* @param start - offset of the token's `@`.
* @param token - the path token at `start`.
* @returns the token's end offset, or undefined when it names no path.
*/
function pathEnd(draft: string, start: number, token: string): number | undefined {
if (!token.startsWith('@"')) {
let length = token.length
while (TRAILING_PUNCTUATION.has(token.charAt(length - 1))) length -= 1
return PATH_LIKE_RE.test(token.slice(0, length)) ? start + length : undefined
}
if (token.length > 3 && token.endsWith('"')) return start + token.length
const slash = token.lastIndexOf('/')
if (slash < 0) return undefined
let next = start + slash + 1
while (TRAILING_PUNCTUATION.has(draft.charAt(next))) next += 1
return next === draft.length || /\s/u.test(draft.charAt(next)) ? start + slash + 1 : undefined
}
/**
* What may follow a `/name` token: whitespace or the draft end, the boundary
* the host skill gesture (`dsh-tool-skill`) requires, so `/nfs-hg/xxx`,
@@ -56,13 +91,12 @@ export function scanTextRefs(
out.push({ start, end: start + 1 + name.length, trigger })
}
}
FOLDER_REF_RE.lastIndex = 0
let folder: RegExpExecArray | null
while ((folder = FOLDER_REF_RE.exec(draft)) !== null) {
const token = folder[2] ?? ''
const start = folder.index + (folder[1]?.length ?? 0)
const end = start + token.length
if (!out.some(range => range.start < end && range.end > start)) {
PATH_TOKEN_RE.lastIndex = 0
let path: RegExpExecArray | null
while ((path = PATH_TOKEN_RE.exec(draft)) !== null) {
const start = path.index + (path[1]?.length ?? 0)
const end = pathEnd(draft, start, path[2] ?? '')
if (end !== undefined && !out.some(range => range.start < end && range.end > start)) {
out.push({ start, end, trigger: '@' })
}
}
@@ -844,4 +844,15 @@ describe('plain-text reference catalogs', () => {
{ start: 0, end: 14, trigger: '@' },
])
})
it('matches whole file and folder path tokens, never a directory prefix', () => {
const slices = (text: string) => scanTextRefs(text, new Map()).map(range => text.slice(range.start, range.end))
expect(slices('@src/deep/file.txt, @"/a/nv/SM100 ISA.html" 看 @"a/ b.md"。 @src/.env @C:\\src\\a.ts @src/de'))
.toEqual(['@src/deep/file.txt', '@"/a/nv/SM100 ISA.html"', '@"a/ b.md"', '@src/.env', '@C:\\src\\a.ts', '@src/de'])
expect(slices('@"dir a/" @src/deep/。 @dir/ 请看 @"src/",然后 @"open dir/ x'))
.toEqual(['@"dir a/"', '@src/deep/', '@dir/', '@"src/"', '@"open dir/'])
expect(slices('@person @"" @"a/b')).toEqual([])
expect(slices('@"open @')).toEqual([])
expect(slices('drill @"open dir/。')).toEqual(['@"open dir/'])
})
})
@@ -1938,6 +1938,15 @@ describe('decorations', () => {
expect(shell.snapshot.draft).toBe('see @src/components/')
})
it('a pasted path decorates as one whole file or folder token', async () => {
const { view, shell, textarea } = bench()
const marks = () => [...view.container.querySelectorAll('[data-composer-text-ref]')].map(el => el.textContent)
const text = 'read @"/a/nv/SM100 ISA.html" and @src/deep/file.txt then @src/deep/ first'
fireEvent.paste(textarea, { clipboardData: { items: [], getData: () => text } })
await vi.waitFor(() => { expect(shell.snapshot.draft).toBe(text) })
expect(marks()).toEqual(['@"/a/nv/SM100 ISA.html"', '@src/deep/file.txt', '@src/deep/'])
})
it('a plain-text reference keeps its DOM node while typing ahead of it (bug #2793 regression)', () => {
const { view, shell } = bench()
act(() => { shell.setDraft('see @src/components/ here') })
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 桌面。
### 改动文件卡片
@@ -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; }
}
@@ -5,7 +5,7 @@ import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path'
import { FileTypeIcon, fileExtension } from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { PresentedHost } from '../presented.ts'
import { PRESENTED_SUCCESS_HOLD_MS, PRESENTED_SUCCESS_FADE_MS, type PresentedOpenPhase } from './present-open.ts'
import type { PresentedOpenPhase } from './present-open.ts'
import { basename, type PresentedPath } from './turn-deliverables.ts'
import type { NS } from './locales.ts'
import css from './Deliverables.module.css'
@@ -29,15 +29,13 @@ export function PresentedFileCard({ file, cwd, phase, host, onPreview, onMenu, a
onMenu: (point: { x: number; y: number }) => void
actions: ReactNode
} & PropsLocale<typeof NS>) {
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 <div className={css.file} data-presented-file>
<button type="button" className={css.cardPreview} title={resolveWorkspacePath(cwd, file.path)}
aria-label={t('presented.previewCard', { name: file.path })} onClick={onPreview}
@@ -48,8 +46,7 @@ export function PresentedFileCard({ file, cwd, phase, host, onPreview, onMenu, a
<span className={css.fileName}>{name}</span>
<span className={css.description} data-presented-description role={phase === undefined ? undefined : 'status'}
data-error={phase === 'error' || phase === 'revealError' || phase === 'nativeUnavailable' ? true : undefined}>
<span className={css.secondaryText} data-success={succeeded || undefined}
style={succeeded ? { animationDelay: `${PRESENTED_SUCCESS_HOLD_MS}ms`, animationDuration: `${PRESENTED_SUCCESS_FADE_MS}ms` } : undefined}>
<span className={css.secondaryText}>
{status}
</span>
<span className={css.previewHint}>{t('presented.preview')}</span>
@@ -1,5 +1,5 @@
/** Native file-action contribution shared by delivery cards and change review. */
import type { PresentedOpenFailure } from './present-open.ts'
import type { PresentedOpenOutcome } from './present-open.ts'
import type { PresentedAction } from '../presented.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
@@ -13,8 +13,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
readonly actionUrl: string
readonly available: boolean
readonly pending: boolean
/** Execute the selected native action and publish status on the owning file surface. */
readonly onAction: (action: PresentedAction, application?: string) => Promise<PresentedOpenFailure>
/** Execute the selected native action, publish status on the owning file surface, and report what settled. */
readonly onAction: (action: PresentedAction, application?: string) => Promise<PresentedOpenOutcome>
}
}
/** The same file actions owned by a changed-file review tab. */
@@ -33,6 +33,8 @@ import {
deliverablesDefinition, presentedForClosing, producedFileMentions, selectProducedFiles,
} from './turn-deliverables.ts'
export type { PresentedOpenOutcome, PresentedOpenFailure } from './present-open.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
/** Changed-files card, review tab, delivery card, and file-mention copy. */
@@ -9,8 +9,6 @@ export const zh = {
'presented.revealError': '无法在文件管理器中显示,请重试',
'presented.directoryError': '无法打开所在文件夹,请重试',
'presented.directoryOpening': '正在打开所在文件夹…',
'presented.directoryOpened': '已请求打开所在文件夹',
'presented.revealed': '已请求在文件管理器中显示',
'presented.revealing': '正在文件管理器中显示…',
'presented.unavailable': '此主机没有可用的桌面,无法使用外部程序打开文件或文件夹;文件仍可在侧边栏预览',
'presented.retry': '重试',
@@ -23,7 +21,6 @@ export const zh = {
'presented.collapse': '收起',
'presented.collapseAria': '收起交付文件列表',
'presented.opening': '正在打开…',
'presented.opened': '已请求打开',
'presented.error': '打开失败,点击重试',
'presented.file': '文件',
'row.title': '交付文件',
@@ -73,8 +70,6 @@ export const en: Record<DeliverablesKey, string> = {
'presented.revealError': 'Could not show in file manager. Try again.',
'presented.directoryError': 'Could not open containing folder. Try again.',
'presented.directoryOpening': 'Opening containing folder…',
'presented.directoryOpened': 'Requested opening containing folder',
'presented.revealed': 'Requested display in file manager',
'presented.revealing': 'Showing in file manager…',
'presented.unavailable': 'This Host has no desktop available to open files or folders in external apps. Files can still be previewed in the sidebar.',
'presented.retry': 'Retry',
@@ -87,7 +82,6 @@ export const en: Record<DeliverablesKey, string> = {
'presented.collapse': 'Collapse',
'presented.collapseAria': 'Collapse delivered files',
'presented.opening': 'Opening…',
'presented.opened': 'Open requested',
'presented.error': 'Could not open. Click to retry.',
'presented.file': 'File',
'row.title': 'Present files',
@@ -140,8 +134,6 @@ export const ja = {
'presented.revealError': 'ファイルマネージャーで表示できませんでした。再試行してください。',
'presented.directoryError': '含まれるフォルダーを開けませんでした。再試行してください。',
'presented.directoryOpening': '含まれるフォルダーを開いています…',
'presented.directoryOpened': '含まれるフォルダーの表示をリクエストしました',
'presented.revealed': 'ファイルマネージャーでの表示をリクエストしました',
'presented.revealing': 'ファイルマネージャーで表示しています…',
'presented.unavailable': 'このHostには、外部アプリでファイルやフォルダーを開くためのデスクトップがありません。ファイルはサイドバーで引き続きプレビューできます。',
'presented.retry': '再試行',
@@ -154,7 +146,6 @@ export const ja = {
'presented.collapse': '折りたたむ',
'presented.collapseAria': '配信されたファイルを折りたたむ',
'presented.opening': '開いています…',
'presented.opened': '表示をリクエストしました',
'presented.error': '開けませんでした。クリックして再試行してください。',
'presented.file': 'ファイル',
'row.title': 'ファイルを提示',
@@ -204,8 +195,6 @@ export const es = {
'presented.revealError': 'No se pudo mostrar en el administrador de archivos. Intentar otra vez.',
'presented.directoryError': 'No se pudo abrir la carpeta que lo contiene. Intentar otra vez.',
'presented.directoryOpening': 'Abriendo la carpeta que contiene...',
'presented.directoryOpened': 'Apertura solicitada que contiene la carpeta',
'presented.revealed': 'Visualización solicitada en el administrador de archivos.',
'presented.revealing': 'Mostrando en el administrador de archivos…',
'presented.unavailable': 'Este Host no tiene un escritorio disponible para abrir archivos o carpetas en aplicaciones externas. Los archivos aún se pueden obtener una vista previa en la barra lateral.',
'presented.retry': 'Reintentar',
@@ -218,7 +207,6 @@ export const es = {
'presented.collapse': 'Contraer',
'presented.collapseAria': 'Contraer archivos entregados',
'presented.opening': 'Abriendo…',
'presented.opened': 'Abierto solicitado',
'presented.error': 'No se pudo abrir. Haga clic para volver a intentarlo.',
'presented.file': 'Archivo',
'row.title': 'Presentar archivos',
@@ -268,8 +256,6 @@ export const fr = {
'presented.revealError': "Impossible d'afficher dans le gestionnaire de fichiers. Essayer à nouveau.",
'presented.directoryError': "Impossible d'ouvrir le dossier contenant. Essayer à nouveau.",
'presented.directoryOpening': 'Ouverture du dossier contenant…',
'presented.directoryOpened': 'Ouverture demandée contenant le dossier',
'presented.revealed': 'Affichage demandé dans le gestionnaire de fichiers',
'presented.revealing': 'Affichage dans le gestionnaire de fichiers…',
'presented.unavailable': 'Cet hôte ne dispose pas de bureau disponible pour ouvrir des fichiers ou des dossiers dans des applications externes. Les fichiers peuvent toujours être prévisualisés dans la barre latérale.',
'presented.retry': 'Réessayer',
@@ -282,7 +268,6 @@ export const fr = {
'presented.collapse': 'Réduire',
'presented.collapseAria': 'Réduire les fichiers livrés',
'presented.opening': 'Ouverture…',
'presented.opened': 'Ouvert demandé',
'presented.error': "Impossible d'ouvrir. Cliquez pour réessayer.",
'presented.file': 'Fichier',
'row.title': 'Présenter des fichiers',
@@ -332,8 +317,6 @@ export const de = {
'presented.revealError': 'Konnte im Dateimanager nicht angezeigt werden. Versuchen Sie es erneut.',
'presented.directoryError': 'Der enthaltende Ordner konnte nicht geöffnet werden. Versuchen Sie es erneut.',
'presented.directoryOpening': 'Öffnen des enthaltenden Ordners…',
'presented.directoryOpened': 'Angeforderte Öffnung mit Ordner',
'presented.revealed': 'Angeforderte Anzeige im Dateimanager',
'presented.revealing': 'Wird im Dateimanager angezeigt…',
'presented.unavailable': 'Auf diesem Host steht kein Desktop zum Öffnen von Dateien oder Ordnern in externen Apps zur Verfügung. Dateien können weiterhin in der Seitenleiste in der Vorschau angezeigt werden.',
'presented.retry': 'Erneut versuchen',
@@ -346,7 +329,6 @@ export const de = {
'presented.collapse': 'Einklappen',
'presented.collapseAria': 'Gelieferte Dateien ausblenden',
'presented.opening': 'Eröffnung…',
'presented.opened': 'Öffnung erbeten',
'presented.error': 'Konnte nicht geöffnet werden. Klicken Sie, um es erneut zu versuchen.',
'presented.file': 'Datei',
'row.title': 'Vorhandene Akten',
@@ -396,8 +378,6 @@ export const ru = {
'presented.revealError': 'Не удалось отобразить в файловом менеджере. Попробуйте еще раз.',
'presented.directoryError': 'Не удалось открыть содержащую папку. Попробуйте еще раз.',
'presented.directoryOpening': 'Открытие папки, содержащей…',
'presented.directoryOpened': 'Запрошенное открытие, содержащее папку',
'presented.revealed': 'Запрошенное отображение в файловом менеджере',
'presented.revealing': 'Отображается в файловом менеджере…',
'presented.unavailable': 'У этого хоста нет рабочего стола, на котором можно было бы открывать файлы или папки во внешних приложениях. Файлы по-прежнему можно просматривать на боковой панели.',
'presented.retry': 'Повторить',
@@ -410,7 +390,6 @@ export const ru = {
'presented.collapse': 'Свернуть',
'presented.collapseAria': 'Свернуть доставленные файлы',
'presented.opening': 'Открытие…',
'presented.opened': 'Открыть запрос',
'presented.error': 'Не удалось открыть. Нажмите, чтобы повторить попытку.',
'presented.file': 'Файл',
'row.title': 'Представление файлов',
@@ -460,8 +439,6 @@ export const pt = {
'presented.revealError': 'Não foi possível mostrar no gerenciador de arquivos. Tente novamente.',
'presented.directoryError': 'Não foi possível abrir a pasta que contém. Tente novamente.',
'presented.directoryOpening': 'Abrindo a pasta que contém…',
'presented.directoryOpened': 'Abertura solicitada contendo pasta',
'presented.revealed': 'Exibição solicitada no gerenciador de arquivos',
'presented.revealing': 'Mostrando no gerenciador de arquivos…',
'presented.unavailable': 'Este Host não possui área de trabalho disponível para abrir arquivos ou pastas em aplicativos externos. Os arquivos ainda podem ser visualizados na barra lateral.',
'presented.retry': 'Tentar novamente',
@@ -474,7 +451,6 @@ export const pt = {
'presented.collapse': 'Recolher',
'presented.collapseAria': 'Recolher arquivos entregues',
'presented.opening': 'Abrindo…',
'presented.opened': 'Aberto solicitado',
'presented.error': 'Não foi possível abrir. Clique para tentar novamente.',
'presented.file': 'Arquivo',
'row.title': 'Apresentar arquivos',
@@ -4,16 +4,24 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { changedFileUrl } from '../changes.ts'
import { presentedFileUrl, PRESENT_HOST_ROUTE, isPresentedHost, type PresentedAction, type PresentedHost } from '../presented.ts'
/** Success feedback remains fully visible for five seconds before fading. */
export const PRESENTED_SUCCESS_HOLD_MS = 5000
/** Fade interval shared by the card animation and status expiry. */
export const PRESENTED_SUCCESS_FADE_MS = 200
/** Failure feedback for the shared native opening control. */
export type PresentedOpenFailure = 'openError' | 'revealError' | null
export type PresentedOpenFailure = 'openError' | 'revealError'
/**
* Settled result of one native file gesture, for the contributed control to
* announce: the Host acknowledged an application open or a file-manager
* reveal, the gesture failed, or the request was deliberately dropped because
* the same file was already opening or this controller was disposed. Only an
* acknowledged operation may be announced as a success.
*/
export type PresentedOpenOutcome =
| { readonly kind: 'opened' }
| { readonly kind: 'revealed' }
| { readonly kind: 'failed'; readonly failure: PresentedOpenFailure }
| { readonly kind: 'skipped' }
/** State published on the owning file card. */
export type PresentedOpenPhase = 'opening' | 'opened' | 'revealing' | 'revealed' | 'error' | 'revealError' | 'nativeUnavailable'
export type PresentedOpenPhase = 'opening' | 'revealing' | 'error' | 'revealError' | 'nativeUnavailable'
/** One browser plugin's file-open requests, cancelled when that plugin is disposed. */
export class PresentedOpenController {
@@ -21,11 +29,10 @@ export class PresentedOpenController {
readonly state = createSnapshotStore<Record<string, PresentedOpenPhase | undefined>>({})
/** Native destination metadata, or a retryable read failure. */
readonly host = createSnapshotStore<PresentedHost | 'error' | null>(null)
private readonly expiry = new Map<string, ReturnType<typeof setTimeout>>()
private loading: Promise<void> | undefined
private metadata = new AbortController()
private readonly lifetime = new AbortController()
private readonly pending = new Set<Promise<void | PresentedOpenFailure>>()
private readonly pending = new Set<Promise<void | PresentedOpenOutcome>>()
/**
* Open a declared file once while a request for the same coordinates is pending.
@@ -35,11 +42,12 @@ export class PresentedOpenController {
* @param index - original file index within that event.
* @param action - default application open or file-manager reveal.
* @param application - registered handler identifier for an explicit application choice.
* @returns null after a successful handoff, or the failure to announce after publishing card status.
* @returns the settled outcome after publishing card status; a dropped
* request reports `{ kind: 'skipped' }` and announces nothing.
*/
open(
sessionId: SessionId, seq: number, index: number, action: PresentedAction = 'open', application?: string,
): Promise<PresentedOpenFailure> {
): Promise<PresentedOpenOutcome> {
return this.openUrl(presentedFileUrl(sessionId, seq, index), action, application)
}
@@ -50,18 +58,18 @@ export class PresentedOpenController {
* @param index - original file index within that event.
* @param action - application open or file-manager reveal.
* @param application - registered handler identifier for an explicit application choice.
* @returns null after a successful handoff, or the failure to announce after publishing card status.
* @returns the settled outcome after publishing card status; a dropped
* request reports `{ kind: 'skipped' }` and announces nothing.
*/
openChanged(
sessionId: SessionId, seq: number, index: number, action: PresentedAction = 'open', application?: string,
): Promise<PresentedOpenFailure> {
): Promise<PresentedOpenOutcome> {
return this.openUrl(changedFileUrl(sessionId, seq, index), action, application)
}
private async openUrl(url: string, action: PresentedAction, application?: string): Promise<PresentedOpenFailure> {
private async openUrl(url: string, action: PresentedAction, application?: string): Promise<PresentedOpenOutcome> {
const phase = this.state.getSnapshot()[url]
if (this.lifetime.signal.aborted || phase === 'opening' || phase === 'revealing') return null
this.clearExpiry(url)
if (this.lifetime.signal.aborted || phase === 'opening' || phase === 'revealing') return { kind: 'skipped' }
this.state.update((state) => { state[url] = action === 'open' ? 'opening' : 'revealing' })
const task = this.request(url, action, application)
this.pending.add(task)
@@ -114,21 +122,15 @@ export class PresentedOpenController {
if (!signal.aborted) this.host.set(host)
}
/** Cancel outstanding requests and wait until no request can publish state. */
/** Cancel outstanding requests, suppress their announcements, and wait until they settle. */
async dispose(): Promise<void> {
this.lifetime.abort()
for (const url of this.expiry.keys()) this.clearExpiry(url)
await Promise.all(this.pending)
}
private clearExpiry(url: string): void {
clearTimeout(this.expiry.get(url))
this.expiry.delete(url)
}
private async request(url: string, action: PresentedAction, application?: string): Promise<PresentedOpenFailure> {
private async request(url: string, action: PresentedAction, application?: string): Promise<PresentedOpenOutcome> {
const failure = action === 'open' ? 'error' : 'revealError'
let phase: PresentedOpenPhase = action === 'open' ? 'opened' : 'revealed'
let phase: PresentedOpenPhase | undefined
try {
const target = action === 'reveal' ? `${url}&action=reveal`
: application === undefined ? url : `${url}&application=${encodeURIComponent(application)}`
@@ -139,14 +141,13 @@ export class PresentedOpenController {
phase = failure
}
if (!this.lifetime.signal.aborted) {
if (phase === 'opened' || phase === 'revealed') {
this.expiry.set(url, setTimeout(() => {
this.expiry.delete(url)
this.state.update((state) => { Reflect.deleteProperty(state, url) })
}, PRESENTED_SUCCESS_HOLD_MS + PRESENTED_SUCCESS_FADE_MS))
}
this.state.update((state) => { state[url] = phase })
this.state.update((state) => {
if (phase === undefined) Reflect.deleteProperty(state, url)
else state[url] = phase
})
}
return phase === 'opened' || phase === 'revealed' ? null : action === 'reveal' ? 'revealError' : 'openError'
if (this.lifetime.signal.aborted) return { kind: 'skipped' }
if (phase === undefined) return { kind: action === 'open' ? 'opened' : 'revealed' }
return { kind: 'failed', failure: action === 'reveal' ? 'revealError' : 'openError' }
}
}
@@ -572,7 +572,7 @@ describe('ChangedFiles card', () => {
controller = new PresentedOpenController(), locale = en, matched = { changes, presented: [] as never[] }, summaries = servedStore(),
) {
const props = openProps(controller, summaries)
props.openChanged.mockResolvedValue(null)
props.openChanged.mockResolvedValue({ kind: 'opened' })
const openFile = vi.fn<(path: string) => void>()
const view = render(<Deliverables {...props} matched={matched} openFile={openFile} sessionId={SessionId('child-session')} t={makeTranslate(locale)} />)
return { props, openFile, view }
@@ -933,9 +933,9 @@ describe('plugin registration', () => {
// The replaced connection may reach a Host that no longer serves the summaries read so far.
expect(face.hooks.changesSummary.getSnapshot()).toEqual({})
await face.openPresented(SessionId('child-session'), 2, 0)
expect(face.hooks.presentedOpen.getSnapshot()['api/present.open?sessionId=child-session&seq=2&index=0']).toBe('opened')
expect(face.hooks.presentedOpen.getSnapshot()['api/present.open?sessionId=child-session&seq=2&index=0']).toBeUndefined()
await face.openChanged(SessionId('child-session'), 5, 0)
expect(face.hooks.presentedOpen.getSnapshot()['api/changes.open?sessionId=child-session&seq=5&index=0']).toBe('opened')
expect(face.hooks.presentedOpen.getSnapshot()['api/changes.open?sessionId=child-session&seq=5&index=0']).toBeUndefined()
face.openChangesReview({ sessionId: SessionId('child-session'), seq: 5, turn: 3 }, 1)
expect(openResource).toHaveBeenCalledWith('dsh-resource://changes-review/session/child-session/5/3', { params: { index: 1 } })
expect((registered as { title(address: string): string }).title('dsh-resource://changes-review/session/child-session/5/3')).toBe('Review · turn 3')
@@ -958,7 +958,7 @@ describe('plugin registration', () => {
await tabFace.reloadPresentedHost()
fetcher.mockResolvedValueOnce(new Response(null, { status: 204 }))
await tabFace.openChanged(SessionId('child-session'), 5, 1)
expect(tabFace.hooks.presentedOpen.getSnapshot()['api/changes.open?sessionId=child-session&seq=5&index=1']).toBe('opened')
expect(tabFace.hooks.presentedOpen.getSnapshot()['api/changes.open?sessionId=child-session&seq=5&index=1']).toBeUndefined()
ctx.emit('connection/reset')
expect(tabFace.hooks.changesDiff.getSnapshot()).toEqual({})
// A turn that produced nothing yields no vocabulary at all.
@@ -1020,7 +1020,7 @@ describe('presented files', () => {
const owner = tailOwner(deliverablesOf(value), 3, preview)
const matched = selectDeliverables(owner)!
const props = openProps()
props.openPresented.mockResolvedValue(null)
props.openPresented.mockResolvedValue({ kind: 'opened' })
const view = render(<Deliverables {...props} matched={matched} openFile={owner.openFile} sessionId={SessionId('child-session')} t={makeTranslate(en)} />)
fireEvent.contextMenu(view.getByRole('button', { name: 'Preview report-0.docx in sidebar' }), { clientX: 18, clientY: 29 })
expect(props.openFileMenu).toHaveBeenCalledWith(SessionId('child-session'), 'report-0.docx', { x: 18, y: 29 })
@@ -1101,7 +1101,7 @@ it('lets one delivered file span the complete row without an expansion control',
})
it.each(['opening', 'opened', 'error'] as const)('shows the %s state and permits retries after failure', (phase) => {
it.each(['opening', 'error'] as const)('shows the %s state and permits retries after failure', (phase) => {
const controller = new PresentedOpenController()
controller.state.set({ 'api/present.open?sessionId=session&seq=2&index=0': phase })
const props = openProps(controller)
@@ -1,4 +1,4 @@
/** Delivery gestures share pending state, report failures, and cancel with the plugin. */
/** Delivery gestures share pending state, report their settled outcome, and cancel with the plugin. */
import { afterEach, expect, it, vi } from 'vitest'
import { SessionId } from '@deepseek-ai/dsh-session/types'
import { PresentedOpenController } from '../src/client/present-open.ts'
@@ -14,13 +14,14 @@ it('coalesces concurrent card and mention gestures, then allows another open', a
vi.stubGlobal('fetch', fetcher)
const controller = new PresentedOpenController()
const first = controller.open(id, 2, 1)
await controller.open(id, 2, 1)
// The coalesced gesture reports deliberate silence, so nothing announces a second handoff.
await expect(controller.open(id, 2, 1)).resolves.toEqual({ kind: 'skipped' })
expect(fetcher).toHaveBeenCalledTimes(1)
expect(fetcher).toHaveBeenCalledWith(url, { method: 'POST', signal: expect.any(AbortSignal) as AbortSignal })
expect(controller.state.getSnapshot()[url]).toBe('opening')
reply.resolve(new Response(null, { status: 204 }))
await first
expect(controller.state.getSnapshot()[url]).toBe('opened')
await expect(first).resolves.toEqual({ kind: 'opened' })
expect(controller.state.getSnapshot()[url]).toBeUndefined()
await controller.open(id, 2, 1)
expect(fetcher).toHaveBeenCalledTimes(2)
await controller.dispose()
@@ -30,11 +31,11 @@ it('opens changed files through their own coordinates', async () => {
const fetcher = vi.fn<(url: string, init?: RequestInit) => Promise<Response>>().mockResolvedValue(new Response(null, { status: 204 }))
vi.stubGlobal('fetch', fetcher)
const controller = new PresentedOpenController()
await controller.openChanged(id, 9, 1)
await expect(controller.openChanged(id, 9, 1)).resolves.toEqual({ kind: 'opened' })
expect(fetcher.mock.calls.map(call => call[0])).toEqual(['api/changes.open?sessionId=fork&seq=9&index=1'])
expect(controller.state.getSnapshot()['api/changes.open?sessionId=fork&seq=9&index=1']).toBe('opened')
expect(controller.state.getSnapshot()['api/changes.open?sessionId=fork&seq=9&index=1']).toBeUndefined()
fetcher.mockResolvedValueOnce(new Response(null, { status: 422 }))
await controller.openChanged(id, 9, 1)
await expect(controller.openChanged(id, 9, 1)).resolves.toEqual({ kind: 'failed', failure: 'openError' })
expect(controller.state.getSnapshot()['api/changes.open?sessionId=fork&seq=9&index=1']).toBe('nativeUnavailable')
await controller.dispose()
})
@@ -46,10 +47,10 @@ it.each(['http', 'network'])('publishes retryable %s failures', async (failure)
fetcher.mockResolvedValue(new Response(null, { status: 204 }))
vi.stubGlobal('fetch', fetcher)
const controller = new PresentedOpenController()
await controller.open(id, 2, 1)
await expect(controller.open(id, 2, 1)).resolves.toEqual({ kind: 'failed', failure: 'openError' })
expect(controller.state.getSnapshot()[url]).toBe('error')
await controller.open(id, 2, 1)
expect(controller.state.getSnapshot()[url]).toBe('opened')
expect(controller.state.getSnapshot()[url]).toBeUndefined()
await controller.dispose()
})
@@ -69,12 +70,28 @@ it('awaits cancellation and prevents late state publication or new requests afte
await aborted.promise
expect(disposed).toBe(false)
release.resolve(new Response(null, { status: 204 }))
await Promise.all([open, disposal])
await expect(open).resolves.toEqual({ kind: 'skipped' })
await disposal
expect(controller.state.getSnapshot()).toBe(state)
await controller.open(id, 2, 1)
// A disposed controller drops the gesture instead of claiming a handoff.
await expect(controller.open(id, 2, 1)).resolves.toEqual({ kind: 'skipped' })
expect(fetcher).toHaveBeenCalledOnce()
})
it.each(['open', 'reveal'] as const)('does not report a cancelled %s as a failure', async (action) => {
vi.stubGlobal('fetch', vi.fn((_url: string, { signal }: RequestInit) => new Promise<Response>((_resolve, reject) => {
signal!.addEventListener('abort', () => { reject(new DOMException('The operation was aborted.', 'AbortError')) }, { once: true })
})))
const controller = new PresentedOpenController()
try {
const pending = controller.open(id, 2, 1, action)
const state = controller.state.getSnapshot()
await controller.dispose()
await expect(pending).resolves.toEqual({ kind: 'skipped' })
expect(controller.state.getSnapshot()).toBe(state)
} finally { await controller.dispose() }
})
it('shares pending state across open and reveal and retries the selected action', async () => {
const reply = Promise.withResolvers<Response>()
@@ -87,10 +104,10 @@ it('shares pending state across open and reveal and retries the selected action'
expect(fetcher).toHaveBeenCalledOnce()
expect(fetcher.mock.calls[0]?.[0]).toBe(`${url}&action=reveal`)
reply.resolve(new Response(null, { status: 500 }))
await revealing
await expect(revealing).resolves.toEqual({ kind: 'failed', failure: 'revealError' })
expect(controller.state.getSnapshot()[url]).toBe('revealError')
await controller.open(id, 2, 1, 'reveal')
expect(controller.state.getSnapshot()[url]).toBe('revealed')
expect(controller.state.getSnapshot()[url]).toBeUndefined()
await controller.dispose()
})
@@ -180,62 +197,27 @@ it('encodes an explicit application identifier without changing the file coordin
const fetcher = vi.fn(async () => new Response(null, { status: 204 }))
vi.stubGlobal('fetch', fetcher)
const controller = new PresentedOpenController()
expect(await controller.open(id, 2, 1, 'open', '/Apps/A&B.app')).toBeNull()
expect(await controller.open(id, 2, 1, 'open', '/Apps/A&B.app')).toEqual({ kind: 'opened' })
expect(fetcher).toHaveBeenCalledWith(`${url}&application=%2FApps%2FA%26B.app`, { method: 'POST', signal: expect.any(AbortSignal) as AbortSignal })
await controller.dispose()
})
it.each(['open', 'reveal'] as const)('expires successful %s feedback after five seconds and its fade', async (action) => {
vi.useFakeTimers()
it.each(['open', 'reveal'] as const)('clears %s progress after acknowledgement without retaining card success feedback', async (action) => {
vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 204 })))
const controller = new PresentedOpenController()
await controller.open(id, 2, 1, action)
await vi.advanceTimersByTimeAsync(5000)
expect(controller.state.getSnapshot()[url]).toBe(action === 'open' ? 'opened' : 'revealed')
await vi.advanceTimersByTimeAsync(200)
await expect(controller.open(id, 2, 1, action)).resolves.toEqual({ kind: action === 'open' ? 'opened' : 'revealed' })
expect(controller.state.getSnapshot()[url]).toBeUndefined()
await controller.dispose()
})
it('cancels an earlier success expiry when the next action fails', async () => {
vi.useFakeTimers()
it('keeps a later failure visible after an acknowledged open', async () => {
const fetcher = vi.fn().mockResolvedValueOnce(new Response(null, { status: 204 }))
.mockResolvedValueOnce(new Response(null, { status: 500 }))
vi.stubGlobal('fetch', fetcher)
const controller = new PresentedOpenController()
await controller.open(id, 2, 1)
await vi.advanceTimersByTimeAsync(4000)
await controller.open(id, 2, 1)
await vi.advanceTimersByTimeAsync(6000)
expect(controller.state.getSnapshot()[url]).toBe('error')
await controller.dispose()
})
it('cancels success expiry when its controller is disposed', async () => {
vi.useFakeTimers()
vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 204 })))
const controller = new PresentedOpenController()
await controller.open(id, 2, 1)
const state = controller.state.getSnapshot()
await controller.dispose()
await vi.advanceTimersByTimeAsync(6000)
expect(controller.state.getSnapshot()).toBe(state)
})
it('owns the expiry before notifying subscribers that can dispose the controller', async () => {
vi.useFakeTimers()
vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 204 })))
const controller = new PresentedOpenController()
let disposal: Promise<void> | undefined
const release = controller.state.subscribe(() => {
if (controller.state.getSnapshot()[url] === 'opened') disposal = controller.dispose()
})
await controller.open(id, 2, 1)
await disposal
const state = controller.state.getSnapshot()
await vi.advanceTimersByTimeAsync(6000)
expect(controller.state.getSnapshot()).toBe(state)
release()
})
@@ -17,14 +17,13 @@ const props = () => ({
actions: <button type="button">Native file action</button>,
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(<PresentedFileCard {...p} phase="revealError" t={makeTranslate(zh)} />)
expect(view.getByText(zh['presented.revealError'])).toBeTruthy()
view.rerender(<PresentedFileCard {...p} phase="revealed" host={{ ...p.host, fileManager: 'directory' }} />)
expect(view.getByText(en['presented.directoryOpened'])).toBeTruthy()
view.rerender(<PresentedFileCard {...p} phase="revealed" />)
expect(view.getByText(en['presented.revealed'])).toBeTruthy()
view.rerender(<PresentedFileCard {...p} />)
expect(view.getByText(p.file.description)).toBeTruthy()
expect(view.queryByRole('status')).toBeNull()
})
@@ -160,7 +160,7 @@ describe('ReviewTab', () => {
loadChangesSummary: vi.fn<ReviewInjected['loadChangesSummary']>(() => Promise.resolve()),
loadChangesDiff: vi.fn<ReviewInjected['loadChangesDiff']>(() => Promise.resolve()),
reloadPresentedHost: vi.fn<ReviewInjected['reloadPresentedHost']>(() => Promise.resolve()),
openChanged: vi.fn<ReviewInjected['openChanged']>(() => Promise.resolve(null)),
openChanged: vi.fn<ReviewInjected['openChanged']>(() => Promise.resolve({ kind: 'opened' })),
}
const sessions: SessionListState = {
ids: [SESSION],
@@ -18,11 +18,11 @@
en: 056e2bd9e3c78dd8
zh: be35d2ba53be0442
/deepseek-ai-dsh-client-ui-open-in-app/use-this-package/what-to-expect:
en: 7ec51cb7e0c6ced6
zh: 6a7a8bb56c388a68
en: 6f3934925cfda953
zh: 9e3126bce73cf344
/deepseek-ai-dsh-client-ui-open-in-app/understand-the-implementation:
en: a0304e1bd9b44370
zh: af0fdfd01c296dce
en: ec7a7c56aaeafce6
zh: 4cee0cd2cb7531df
/deepseek-ai-dsh-client-ui-open-in-app/further-exploration:
en: 562c58d589fb7afa
zh: 9ba88b381160cc96
+6 -4
View File
@@ -31,7 +31,9 @@ Mount this plugin in the Web composition beside [`dsh-host-open-in-app`](../../h
The Session header, file-tree header, and document header use the same 24px-high split button with 9px corners. These headers show only the icon; their tooltip names the default application or the reveal action. They show the default action’s icon, mark its application with “(default)” in the menu, disable while their own gesture runs, and report failures through a transient toast. The file-tree opener targets the displayed directory supplied as `absolutePath` and shares the application choice and launch state with the Session header. The directory adapter uses the existing cross-platform application catalog and remembers the last successful choice in `dsh.open-in-app.choice`; a missing choice falls back to the first available application.
The **Open locally** shortcut captures the main Session's directory and the same remembered application as the header button. The effective binding appears in the button tooltip and `aria-keyshortcuts`; Web follows the [shortcut service’s platform defaults](../shortcuts/README.md). The command runs only while the Conversation is selected and the header button has a directory and installed application to open. An in-flight launch also blocks the command. Pointer and keyboard gestures share the controller's launch state, so repeated gestures cannot launch twice or change the remembered choice mid-launch.
On iOS and Android phones and tablets, including installed PWAs, an acknowledged native open shows “Opened on another device”; reveal shows “File location shown on another device”. Desktop clients stay silent on success, including narrow browser windows and installed desktop PWAs. Every client receives a failure toast when the Host refuses or cannot be reached. Success means the Host accepted the native operation, not that the destination application finished loading the file. The feedback uses the existing mobile-device detector rather than viewport width or installation mode.
The **Open locally** shortcut captures the main Session's directory and the same remembered application as the header button. The effective binding appears in the button tooltip and `aria-keyshortcuts`; Web follows the [shortcut service’s platform defaults](../shortcuts/README.md). The command runs only while the Conversation is selected and the header button has a directory and installed application to open. An in-flight launch also blocks the command; its outcome follows the same feedback policy as pointer gestures. Pointer and keyboard gestures share the controller's launch state, so repeated gestures cannot launch twice or change the remembered choice mid-launch.
File menus list the discovered associated applications without a separate “Open in default app” row. “Show file location” stays in a fixed footer separated from the scrolling applications. A successful query with only one available action renders a single button without a dropdown. Initial file-association loading uses a gray skeleton icon. When the Host identifies a default application the menu marks it “(default)” and the main button opens it; otherwise the first associated application takes that place, so the main button reveals the file only when no application is registered. Directory menus have no reveal row. Choosing a file application does not change the system default. An unpreviewable file uses the same menu in a 40px-high split button with an icon and action text; its label is “Open” or “Show file location” according to the default action.
@@ -39,7 +41,7 @@ Mounted controls with the same association reader and file share one query and i
Workspace context menus open the captured folder through the same directory application catalog as the Session header. File-tree directories expose native reveal and Copy folder path; they do not offer file preview or save.
Context menus contribute file preview, native application opening, original-file reveal, and URL navigation and copying through [ui-context-menu](../ui-context-menu/README.md). Session reveal follows the installed file-manager catalog and registers a structured Session contribution at order 500, between Copy session ID and Archive. Its failures use the shared Toast after dismissal. Native file actions resolve Session paths through authorized metadata before querying applications. Missing files disable path actions; image reveal requires an actual Host file and always targets that original. [ui-file-actions](../ui-file-actions/README.md) owns copy, download, save-as, and their completion feedback, including revealing the saved destination from its toast.
Context menus contribute file preview, native application opening, original-file reveal, and URL navigation and copying through [ui-context-menu](../ui-context-menu/README.md). Session reveal follows the installed file-manager catalog and registers a structured Session contribution at order 500, between Copy session ID and Archive. Native context-menu operations share the feature's overlay toast and mobile/desktop feedback policy after dismissal. Native file actions resolve Session paths through authorized metadata before querying applications. Missing files disable path actions; image reveal requires an actual Host file and always targets that original. [ui-file-actions](../ui-file-actions/README.md) owns copy, download, save-as, and their completion feedback, including revealing the saved destination from its toast.
File menus copy absolute paths without line numbers; URL copying preserves query and fragment. Image files also expose image copy and original download, alongside the existing file actions. Successful downloads and saves show the shared Toast; picker cancellation is silent.
@@ -51,9 +53,9 @@ File menus copy absolute paths without line numbers; URL copying preserves query
<details>
<summary>Implementation internals — click to expand</summary>
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.
</details>
+6 -4
View File
@@ -31,7 +31,9 @@ kind: "package-reference"
会话标题栏、文件树标题栏和文档标题栏共用同一个高 24px、圆角 9px 的分体按钮。这些标题栏都只显示图标,悬停提示显示默认应用名称或文件定位动作。它们都显示默认动作的图标,在菜单的默认应用后标注“(默认)”,仅在自己的操作执行期间禁用,并通过短暂提示报告失败。文件树打开按钮以 `absolutePath` 传入的显示目录为目标,并与会话头部共享应用选择和启动状态。目录适配器使用已有的跨平台应用列表,将最后一次成功选择保存在 `dsh.open-in-app.choice` 中;原选择不可用时回退到第一个可用应用。
**在本地打开**快捷键捕获主会话的目录,并使用与头部按钮相同的已记住应用。按钮 tooltip 和 `aria-keyshortcuts` 显示当前有效绑定;Web 遵循[快捷键服务的平台默认值](../shortcuts/README.zh.md)。只有选中会话界面、且头部按钮有可打开的目录和已安装应用时,命令才执行。启动尚未结束时也会阻止命令。鼠标与键盘操作共享 controller 的启动状态,重复操作不会启动两次,也不会在启动期间更改已记住的选择。
在 iOS、Android 手机和平板上(包括已安装的 PWA),原生打开获得确认后显示「已在其它设备打开」,文件定位则显示「已在其它设备显示文件位置」。电脑端成功时不提示,包括窄窗口浏览器与已安装的桌面 PWA。Host 拒绝请求或无法连通时,所有客户端都会收到失败 toast。成功表示 Host 已接受原生操作,不代表目标应用已完成文件加载。反馈复用现有移动设备判定,不依据窗口宽度或安装模式。
**在本地打开**快捷键捕获主会话的目录,并使用与头部按钮相同的已记住应用。按钮 tooltip 和 `aria-keyshortcuts` 显示当前有效绑定;Web 遵循[快捷键服务的平台默认值](../shortcuts/README.zh.md)。只有选中会话界面、且头部按钮有可打开的目录和已安装应用时,命令才执行。启动尚未结束时也会阻止命令;其结果遵循与点击操作相同的反馈规则。鼠标与键盘操作共享 controller 的启动状态,重复操作不会启动两次,也不会在启动期间更改已记住的选择。
文件菜单列出已发现的关联应用,不单列“用默认应用打开”。“显示文件位置”固定在菜单底部,通过分隔线与滚动的应用列表分开。查询成功且只有一个可用操作时显示单按钮,不再显示下拉箭头。文件关联首次加载时使用灰色骨架图标。Host 识别出默认应用时,菜单将该项标注为“(默认)”,主按钮打开该应用;否则第一个关联应用占据该位置,只有在一个关联应用都没有时主按钮才执行文件定位。目录菜单不包含定位项。选择文件应用不修改系统默认应用。无法预览文件时,空态使用同一菜单,按钮增大到 40px 高并显示图标和动作文字;根据默认动作显示“打开”或“显示文件位置”。
@@ -39,7 +41,7 @@ kind: "package-reference"
工作区右键菜单通过与会话标题相同的目录应用列表打开被点击的文件夹。文件树目录提供本机显示位置和复制文件夹路径,不提供文件预览或保存。
右键菜单通过 [ui-context-menu](../ui-context-menu/README.zh.md) 贡献文件预览、原生应用打开、原文件定位以及链接导航与复制。会话定位沿用已安装文件管理器目录,以顺序 500 注册为结构化 Session 贡献,位于复制 Session ID 与归档之间。失败提示通过共享 Toast 在菜单关闭后显示。原生文件操作先通过授权元数据解析会话路径,再查询关联应用。文件不存在时禁用路径操作;图片定位要求实际 Host 文件,并始终针对该原文件。[ui-file-actions](../ui-file-actions/README.zh.md) 负责复制、下载、另存为及其完成提示,包括通过提示中的操作定位保存目标。
右键菜单通过 [ui-context-menu](../ui-context-menu/README.zh.md) 贡献文件预览、原生应用打开、原文件定位以及链接导航与复制。会话定位沿用已安装文件管理器目录,以顺序 500 注册为结构化 Session 贡献,位于复制 Session ID 与归档之间。右键原生操作在菜单关闭后,通过本功能的浮层 toast 沿用移动端与电脑端反馈规则。原生文件操作先通过授权元数据解析会话路径,再查询关联应用。文件不存在时禁用路径操作;图片定位要求实际 Host 文件,并始终针对该原文件。[ui-file-actions](../ui-file-actions/README.zh.md) 负责复制、下载、另存为及其完成提示,包括通过提示中的操作定位保存目标。
文件菜单复制不含行号的绝对路径;网址复制保留查询参数与锚点。图片文件在既有文件操作之外,还提供复制图片与下载原图。下载和保存成功后显示共享 Toast,取消选择器时不提示。
@@ -51,9 +53,9 @@ kind: "package-reference"
<details>
<summary>实现内幕——点击展开</summary>
两个目录插槽均通过显式的 `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` 为交付卡片和变更对比页提供同一控件,其认证路由保留会话文件校验。目录适配器继续使用已有的应用列表路由,文件查询失败或不可用时无需增加平台专用的界面实现。
</details>
@@ -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<readonly SessionWorkspacePathApplication[] | null> {
@@ -20,18 +20,18 @@ async function queryRoute(url: string, signal: AbortSignal): Promise<readonly Se
/**
* Render file actions without bypassing the owning Session's authorization route.
* @param props - authenticated route, desktop availability, and native gesture callback.
* @param props - authenticated route, desktop availability, the native gesture
* callback, and the announcement seat its settled outcome is reported to.
* @returns the shared compact control, or null without a desktop.
*/
export function FileRouteAction(
props: Pick<PropsRuntime<'deliverables.file.actions'>, 'actionUrl' | 'available' | 'pending' | 'onAction'> & PropsLocale<typeof NS>,
props: Pick<PropsRuntime<'deliverables.file.actions'>, 'actionUrl' | 'available' | 'pending' | 'onAction'>
& PropsLocale<typeof NS> & InjectFace<OpenTargetReporter>,
) {
const association = useFileApplications(props.actionUrl, queryRoute, props.available)
if (!props.available) return null
return <OpenTargetButton key={props.actionUrl} kind="file" applications={association.apps}
defaultId={association.apps.find(app => 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)} />
}
@@ -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<readonly string[] | null>
openInAppChoice: ObservableSnapshot<string>
openInAppLaunch: ObservableSnapshot<OpenInAppLaunchState>
shortcuts: ObservableSnapshot<readonly ShortcutCatalogEntry[]>
}
launch: (appId: string, path: string) => Promise<void>
launch: (appId: string, path: string) => Promise<OpenInAppOutcome>
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 (
<OpenTargetButton
key={absolutePath} kind="directory" applications={apps} defaultId={preferred.id} failed={false} t={t}
busy={operation.phase === 'busy'} shortcut={shortcut}
busy={operation.phase === 'busy'} shortcut={shortcut} report={props.report}
execute={async (operation) => {
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
}}
/>
)
@@ -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<boolean | null>
}
loadDesktop: () => Promise<void>
openPath: (path: string, action: OpenInAppPathAction, application?: string) => Promise<OpenInAppPathFailure | null>
openPath: (path: string, action: OpenInAppPathAction, application?: string) => Promise<OpenInAppOutcome>
applications: (path: string, signal: AbortSignal) => Promise<readonly SessionWorkspacePathApplication[] | null>
}
@@ -27,7 +28,7 @@ export type OpenPathActionProps =
& InjectFace<OpenPathInjected>
/** File inputs shared by the document header and its unpreviewable state. */
type FileOpenTargetProps = Pick<OpenPathActionProps, 'absolutePath' | 'useOpenInAppDesktop' | 'loadDesktop' | 'openPath' | 'applications' | 't'> & {
type FileOpenTargetProps = Pick<OpenPathActionProps, 'absolutePath' | 'useOpenInAppDesktop' | 'loadDesktop' | 'openPath' | 'applications' | 'report' | 't'> & {
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)}
/>
@@ -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<ShortcutCatalogEntry, 'keys' | 'aria'> | 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<typeof NS>
readonly execute: (operation: OpenTargetOperation) => Promise<OpenInAppPathFailure | null>
/** Resolve every gesture to an outcome, including Host and transport failures; never reject. */
readonly execute: (operation: OpenTargetOperation) => Promise<OpenInAppOutcome>
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<typeof NS>): {
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 {
? <IconFolderOpenOutlineRegular size={props.prominent ? 18 : 13} />
: <ApplicationIcon key={preferred?.icon} source={preferred?.icon ?? null} size={props.prominent ? 18 : 13} />
return (
<>
<Menu
className={css.menuAnchor}
open={menuOpen && !disabled && hasMenu}
autoFocus
portal
dense
align="end"
onClose={() => { setMenuOpen(false) }}
items={[
...applications.map(app => ({
id: `app:${app.id}`,
icon: <ApplicationIcon key={app.icon} source={app.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: <IconFolderOpenOutlineRegular />,
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={(
<div className={css.split} data-open-target={kind} data-size={props.prominent ? 'large' : 'compact'}
data-open-path={kind === 'file' && !props.prominent ? '' : undefined} data-state={disabled ? 'busy' : 'idle'}>
<Tooltip portal label={primaryLabel} shortcutKeys={props.shortcut?.keys} side="bottom" delayMs={500}>
<button type="button" className={css.main} disabled={disabled} aria-label={props.prominent ? undefined : primaryLabel}
aria-keyshortcuts={props.shortcut?.aria}
data-open-path-open={kind === 'file' && !props.prominent ? '' : undefined}
data-open-path-unpreviewable={props.prominent ? '' : undefined} onClick={primary}>
{icon}{(props.prominent) && (revealDefault ? t('path.reveal') : t('path.open'))}
</button>
</Tooltip>
{hasMenu && <button
type="button" className={css.chevron} disabled={disabled}
aria-haspopup="menu" aria-expanded={menuOpen && !disabled} aria-label={t('path.more')}
data-open-path-more={kind === 'file' && !props.prominent ? '' : undefined}
onClick={() => {
if (!menuOpen) props.refresh?.()
setMenuOpen(value => !value)
}}
>
<IconChevronDownOutlineRegular size={props.prominent ? 14 : 10} />
</button>}
</div>
)}
/>
{toast}
</>
<Menu
className={css.menuAnchor}
open={menuOpen && !disabled && hasMenu}
autoFocus
portal
dense
align="end"
onClose={() => { setMenuOpen(false) }}
items={[
...applications.map(app => ({
id: `app:${app.id}`,
icon: <ApplicationIcon key={app.icon} source={app.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: <IconFolderOpenOutlineRegular />,
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={(
<div className={css.split} data-open-target={kind} data-size={props.prominent ? 'large' : 'compact'}
data-open-path={kind === 'file' && !props.prominent ? '' : undefined} data-state={disabled ? 'busy' : 'idle'}>
<Tooltip portal label={primaryLabel} shortcutKeys={props.shortcut?.keys} side="bottom" delayMs={500}>
<button type="button" className={css.main} disabled={disabled} aria-label={props.prominent ? undefined : primaryLabel}
aria-keyshortcuts={props.shortcut?.aria}
data-open-path-open={kind === 'file' && !props.prominent ? '' : undefined}
data-open-path-unpreviewable={props.prominent ? '' : undefined} onClick={primary}>
{icon}{(props.prominent) && (revealDefault ? t('path.reveal') : t('path.open'))}
</button>
</Tooltip>
{hasMenu && <button
type="button" className={css.chevron} disabled={disabled}
aria-haspopup="menu" aria-expanded={menuOpen && !disabled} aria-label={t('path.more')}
data-open-path-more={kind === 'file' && !props.prominent ? '' : undefined}
onClick={() => {
if (!menuOpen) props.refresh?.()
setMenuOpen(value => !value)
}}
>
<IconChevronDownOutlineRegular size={props.prominent ? 14 : 10} />
</button>}
</div>
)}
/>
)
}
@@ -11,6 +11,7 @@ import type { OpenInAppController } from './controller.ts'
import { fileAddressFor, resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path'
import type { OpenInAppPathController } from './open-path.ts'
import type { OpenInAppKey } from './locales.ts'
import type { OpenInAppToastSource } from './open-toast.tsx'
/**
* Register native opening, original-path reveal, and URL actions.
@@ -18,17 +19,18 @@ import type { OpenInAppKey } from './locales.ts'
* @param paths - existing native file actions.
* @param t - localized action labels.
* @param directories - installed directory applications.
* @param report - native operation feedback shared with the application controls.
*/
export function registerContextMenus(
ctx: Context,
paths: OpenInAppPathController,
t: (key: OpenInAppKey, values?: Record<string, string>) => string,
directories: OpenInAppController,
report: OpenInAppToastSource['report'],
): void {
const fileManager = () => t(directories.apps.getSnapshot()?.includes('finder') ? 'menu.finder' : 'menu.reveal')
const open = async (path: string, action: 'open' | 'reveal', application?: string): Promise<void> => {
const failure = await paths.openPath(path, action, application)
if (failure !== null) ctx.menus.notify(t(`path.${failure}`))
report(await paths.openPath(path, action, application))
}
const resolvePath = async (sessionId: SessionId, path: string, signal: AbortSignal): Promise<string | undefined> => {
try {
@@ -49,7 +51,10 @@ export function registerContextMenus(
const manager = directories.apps.getSnapshot()?.find(id => ['finder', 'explorer', 'filemanager'].includes(id))
return [{ id: 'reveal', icon: <IconFolderOpenOutlineRegular />, label: fileManager(), disabled: manager === undefined,
failureLabel: t('path.revealError'),
...manager === undefined ? {} : { run: () => directories.launch(manager, path) } }]
...manager === undefined ? {} : { run: async () => {
const outcome = await directories.launch(manager, path)
report(outcome.kind === 'failed' ? { kind: 'failed', failure: 'revealError' } : outcome)
} } }]
},
}), 'open-in-app: Workspace context menu')
ctx.effect(() => ctx.menus.register({
@@ -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<void> {
if (this.operation.getSnapshot().phase === 'busy') return
async launch(appId: string, path: string): Promise<OpenInAppOutcome> {
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' }
}
}
@@ -28,7 +28,9 @@ import { OpenInAppPathController } from './open-path.ts'
import { OpenPathAction, type OpenPathInjected } from './OpenPathAction.tsx'
import { FileRouteAction } from './FileRouteAction.tsx'
import { OpenPathEmptyAction } from './OpenPathEmptyAction.tsx'
import type { OpenTargetReporter } from './OpenTargetButton.tsx'
import { en, ja, es, fr, de, ru, pt, NS, zh, type OpenInAppKey } from './locales.ts'
import { createOpenInAppToastSource, OpenInAppToast } from './open-toast.tsx'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
@@ -38,7 +40,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
}
export type { OpenInAppActionInjected, OpenInAppActionProps } from './OpenInAppAction.tsx'
export type { OpenInAppPathAction, OpenInAppPathFailure, OpenInAppPathRemote } from './open-path.ts'
export type { OpenInAppPathAction, OpenInAppPathRemote } from './open-path.ts'
export type { OpenInAppOutcome, OpenInAppPathFailure } from './outcome.ts'
export type { OpenPathActionProps, OpenPathInjected } from './OpenPathAction.tsx'
export type { OpenPathEmptyActionProps } from './OpenPathEmptyAction.tsx'
@@ -56,8 +59,14 @@ export function apply(ctx: ClientContext): void {
const paths = new OpenInAppPathController(ctx.remote)
ctx.effect(() => ctx.locale.register(NS, { zh, en, ja, es, fr, de, ru, pt }), 'open-in-app: dictionaries')
const t = ctx.locale.bind(NS)
registerContextMenus(ctx, paths, t, controller)
registerSessionReveal(ctx, controller, t)
// Keep operation feedback visible after its initiating panel closes.
const announcement = createOpenInAppToastSource()
registerContextMenus(ctx, paths, t, controller, announcement.report)
registerSessionReveal(ctx, controller, t, announcement.report)
ctx.slots.inject('shell.overlay', () => ctx.slots.register({
name: 'shell.overlay', id: 'open-in-app.toast', locale: NS,
inject: () => ({ hooks: announcement.hooks, dismiss: announcement.dismiss }),
}, OpenInAppToast))
const target = () => {
if (ctx.layout.panelInfo.getSnapshot().activePanelId !== null) return undefined
const session = Object.values(ctx.sessions.list.getSnapshot().byId)
@@ -80,9 +89,7 @@ export function apply(ctx: ClientContext): void {
const selected = target()
if (selected === undefined) return { status: 'blocked', reason: t('shortcut.unavailable') }
return { status: 'handled', run: () => {
void controller.launch(selected.appId, selected.path).catch((error: unknown) => {
console.warn('workspace open rejected:', error)
})
void controller.launch(selected.appId, selected.path).then(announcement.report)
} }
},
}), 'open-in-app: workspace command')
@@ -94,6 +101,7 @@ export function apply(ctx: ClientContext): void {
shortcuts: ctx.shortcuts.catalog,
},
launch: (appId, path) => controller.launch(appId, path),
report: announcement.report,
choose: (appId) => { controller.choose(appId) },
iconUrl: appId => `${OPEN_IN_APP_ICON_PREFIX_ROUTE}/${appId}`,
})
@@ -118,6 +126,7 @@ export function apply(ctx: ClientContext): void {
loadDesktop: () => paths.load(),
openPath: (path, action, application) => paths.openPath(path, action, application),
applications,
report: announcement.report,
})
ctx.slots.inject('sidebar.right.tab.document.actions', () => ctx.slots.register({
name: 'sidebar.right.tab.document.actions',
@@ -131,10 +140,11 @@ export function apply(ctx: ClientContext): void {
locale: NS,
inject: pathInjected,
}, OpenPathEmptyAction))
const routeInjected = (): OpenTargetReporter => ({ report: announcement.report })
ctx.slots.inject('deliverables.file.actions', () => ctx.slots.register({
name: 'deliverables.file.actions', id: 'open-in-app', locale: NS,
name: 'deliverables.file.actions', id: 'open-in-app', locale: NS, inject: routeInjected,
}, FileRouteAction))
ctx.slots.inject('deliverables.review.file.actions', () => ctx.slots.register({
name: 'deliverables.review.file.actions', id: 'open-in-app', locale: NS,
name: 'deliverables.review.file.actions', id: 'open-in-app', locale: NS, inject: routeInjected,
}, FileRouteAction))
}
@@ -59,6 +59,8 @@ export const zh = {
'path.open': '打开',
'path.more': '更多打开方式',
'path.reveal': '显示文件位置',
'path.opened': '已在其它设备打开',
'path.revealed': '已在其它设备显示文件位置',
'path.openError': '打开失败,请重试',
'path.revealError': '无法显示文件位置,请重试',
...PRODUCT_NAMES,
@@ -90,6 +92,8 @@ export const en: Record<OpenInAppKey, string> = {
'path.open': 'Open',
'path.more': 'More ways to open',
'path.reveal': 'Show file location',
'path.opened': 'Opened on another device',
'path.revealed': 'File location shown on another device',
'path.openError': 'Could not open. Try again.',
'path.revealError': 'Could not show the file location. Try again.',
...PRODUCT_NAMES,
@@ -125,6 +129,8 @@ export const ja = {
'path.open': '開く',
'path.more': 'その他の開き方',
'path.reveal': 'ファイルの場所を表示',
'path.opened': '別のデバイスで開きました',
'path.revealed': '別のデバイスでファイルの場所を表示しました',
'path.openError': '開けませんでした。もう一度お試しください。',
'path.revealError': 'ファイルの場所を表示できませんでした。もう一度お試しください。',
'app.finder': 'Finder',
@@ -154,6 +160,8 @@ export const es = {
'path.open': 'Abrir',
'path.more': 'Más formas de abrir',
'path.reveal': 'Mostrar ubicación del archivo',
'path.opened': 'Abierto en otro dispositivo',
'path.revealed': 'Ubicación del archivo mostrada en otro dispositivo',
'path.openError': 'No se pudo abrir. Intentar otra vez.',
'path.revealError': 'No se pudo mostrar la ubicación del archivo. Intentar otra vez.',
...PRODUCT_NAMES,
@@ -184,6 +192,8 @@ export const fr = {
'path.open': 'Ouvrir',
'path.more': "Plus de façons d'ouvrir",
'path.reveal': "Afficher l'emplacement du fichier",
'path.opened': 'Ouvert sur un autre appareil',
'path.revealed': 'Emplacement du fichier affiché sur un autre appareil',
'path.openError': "Impossible d'ouvrir. Essayer à nouveau.",
'path.revealError': "Impossible d'afficher l'emplacement du fichier. Essayer à nouveau.",
...PRODUCT_NAMES,
@@ -214,6 +224,8 @@ export const de = {
'path.open': 'Öffnen',
'path.more': 'Mehr Möglichkeiten zum Öffnen',
'path.reveal': 'Dateispeicherort anzeigen',
'path.opened': 'Auf einem anderen Gerät geöffnet',
'path.revealed': 'Dateispeicherort auf einem anderen Gerät angezeigt',
'path.openError': 'Konnte nicht geöffnet werden. Versuchen Sie es erneut.',
'path.revealError': 'Der Dateispeicherort konnte nicht angezeigt werden. Versuchen Sie es erneut.',
...PRODUCT_NAMES,
@@ -244,6 +256,8 @@ export const ru = {
'path.open': 'Открыть',
'path.more': 'Больше способов открыть',
'path.reveal': 'Показать местоположение файла',
'path.opened': 'Открыто на другом устройстве',
'path.revealed': 'Местоположение файла показано на другом устройстве',
'path.openError': 'Не удалось открыть. Попробуйте еще раз.',
'path.revealError': 'Не удалось показать местоположение файла. Попробуйте еще раз.',
...PRODUCT_NAMES,
@@ -274,6 +288,8 @@ export const pt = {
'path.open': 'Abrir',
'path.more': 'Mais maneiras de abrir',
'path.reveal': 'Mostrar localização do arquivo',
'path.opened': 'Aberto em outro dispositivo',
'path.revealed': 'Localização do arquivo exibida em outro dispositivo',
'path.openError': 'Não foi possível abrir. Tente novamente.',
'path.revealError': 'Não foi possível mostrar o local do arquivo. Tente novamente.',
...PRODUCT_NAMES,
@@ -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
: <Toast key={banner.seq} text={banner.text} icon={<IconWarningOutlineRegular />} onDone={dismiss} />,
show: (text) => {
seq.current += 1
setBanner({ seq: seq.current, text })
},
}
}
@@ -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<OpenInAppPathFailure | null> {
async openPath(path: string, action: OpenInAppPathAction, application?: string): Promise<OpenInAppOutcome> {
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 }
}
/**
@@ -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<OpenInAppOutcome, { kind: 'skipped' }> & { 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<OpenInAppToastState | null> }
/**
* 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<OpenInAppToastState | null>(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<typeof NS>
& InjectFace<Omit<OpenInAppToastSource, 'report'>>
/**
* 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 <Toast key={key} text={t(toast.failure === 'revealError' ? 'path.revealError' : 'path.openError')}
icon={<IconWarningOutlineRegular />} onDone={dismiss} />
}
return <Toast key={key} text={t(toast.kind === 'opened' ? 'path.opened' : 'path.revealed')} tone="success" onDone={dismiss} />
}
@@ -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
@@ -3,14 +3,21 @@ import { IconFolderOpenOutlineRegular } from '@deepseek-ai/dsh-client-ui-primiti
import type { Context } from '@deepseek-ai/cordis'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { OpenInAppController } from './controller.ts'
import type { OpenInAppToastSource } from './open-toast.tsx'
/**
* Share directory availability and launch feedback with the existing application controls.
* @param ctx - owning plugin context.
* @param controller - directory application catalog and launcher.
* @param t - localized action and failure labels.
* @param report - native operation feedback shared with the application controls.
*/
export function registerSessionReveal(ctx: Context, controller: OpenInAppController, t: PropsLocale<'open-in-app'>['t']): void {
export function registerSessionReveal(
ctx: Context,
controller: OpenInAppController,
t: PropsLocale<'open-in-app'>['t'],
report: OpenInAppToastSource['report'],
): void {
ctx.effect(() => ctx.menus.register({
id: 'open-in-app.reveal', target: 'session', order: 500,
observe: () => [controller.apps, ctx.sessions.list, ctx.locale],
@@ -21,7 +28,10 @@ export function registerSessionReveal(ctx: Context, controller: OpenInAppControl
return [{
id: 'reveal', label: t(applications?.includes('finder') ? 'menu.finder' : 'menu.reveal'), icon: <IconFolderOpenOutlineRegular />,
disabled: !directory || manager === undefined, failureLabel: t('path.revealError'),
...(directory && manager !== undefined ? { run: () => controller.launch(manager, directory) } : {}),
...(directory && manager !== undefined ? { run: async () => {
const outcome = await controller.launch(manager, directory)
report(outcome.kind === 'failed' ? { kind: 'failed', failure: 'revealError' } : outcome)
} } : {}),
}]
},
}), 'open-in-app: Session directory menu')
@@ -1,5 +1,9 @@
// @vitest-environment jsdom
import { createElement } from 'react'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import { isMobileDevice } from '@deepseek-ai/dsh-client-ui-primitives'
/**
* Browser-half lifecycle over the real SlotRegistry: the dictionary,
* header-slot, and document-preview path registrations with fiber teardown
@@ -11,7 +15,7 @@ import type { ShortcutCommand } from '@deepseek-ai/dsh-client-shortcuts/client'
import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { Context } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { LayoutController, type MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client'
@@ -23,9 +27,21 @@ import { OpenInAppAction } from '../src/client/OpenInAppAction.tsx'
import { OpenPathAction } from '../src/client/OpenPathAction.tsx'
import { OpenPathEmptyAction } from '../src/client/OpenPathEmptyAction.tsx'
import { MenuRegistry } from '@deepseek-ai/dsh-client-ui-context-menu/src/client/registry.ts'
import type { OpenTargetReporter } from '../src/client/OpenTargetButton.tsx'
import { FileRouteAction } from '../src/client/FileRouteAction.tsx'
import type { OpenInAppOutcome } from '../src/client/outcome.ts'
import { OpenInAppToast, type OpenInAppToastProps, type OpenInAppToastSource } from '../src/client/open-toast.tsx'
import { en, NS, zh } from '../src/client/locales.ts'
vi.mock('@deepseek-ai/dsh-client-ui-primitives', async original => ({
...await original<typeof import('@deepseek-ai/dsh-client-ui-primitives')>(),
isMobileDevice: vi.fn(() => false),
}))
beforeEach(() => { vi.mocked(isMobileDevice).mockReturnValue(false) })
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
vi.clearAllMocks()
})
@@ -53,6 +69,7 @@ async function bench() {
'sidebar.right.tab.document.unpreviewable': { kind: 'list', scope: 'session' },
'deliverables.file.actions': { kind: 'list', scope: 'session' },
'deliverables.review.file.actions': { kind: 'list', scope: 'session' },
'shell.overlay': { kind: 'list', scope: 'root' },
},
} as never, () => null)
const list = createSnapshotStore<SessionListState>({ ids: [], byId: {}, phase: 'ready', projectionsBySession: {} })
@@ -85,10 +102,23 @@ function headerEntryIds(ctx: Context): (string | undefined)[] {
return ctx.slots.entries('conversation.session.header.utilities').map(entry => entry.options.id)
}
/** The one announcement store the frame-wide overlay entry exposes. */
function toastSource(ctx: Context): Omit<OpenInAppToastSource, 'report'> {
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<OpenInAppToastSource, 'report'>)()
}
describe('open-in-app browser half', () => {
it('orders Session reveal between copy ID and archive, reports failure, and unregisters on disposal', async () => {
it.each([
[false, true], [true, true], [false, false], [true, false],
])('shares native feedback after Session menu dismissal (mobile=%s, success=%s)', async (mobile, success) => {
vi.mocked(isMobileDevice).mockReturnValue(mobile)
const fetcher = vi.fn(async (input: string | URL) => String(input) === 'open-in-app/apps'
? new Response(JSON.stringify({ apps: ['finder'] })) : new Response('', { status: 502 }))
? new Response(JSON.stringify({ apps: ['finder'] })) : new Response('', { status: success ? 200 : 502 }))
vi.stubGlobal('fetch', fetcher)
const { ctx, fiber, list, notify } = await bench()
const id = 'captured' as SessionId
@@ -96,11 +126,18 @@ describe('open-in-app browser half', () => {
[id]: { id, displayTitle: 'Captured', cwd: '/captured', running: false, blank: false, updatedAt: 0, retainedBy: {} },
} })
const menu = ctx.menus.resolve({ kind: 'session', value: { sessionId: id, displayTitle: 'Captured' } })
const toast = toastSource(ctx)
try {
expect(ctx.slots.entries('sidebar.workspaces.session.menu.item')).toHaveLength(0)
await vi.waitFor(() => { expect(menu.source.getSnapshot()[0]).toMatchObject({ label: 'Show in Finder', disabled: false }) })
menu.select('open-in-app.reveal/reveal')
await vi.waitFor(() => { expect(notify).toHaveBeenCalledWith(en['path.revealError']) })
menu.dispose()
const face: Partial<OpenInAppActionInjected> = ctx.slots.entries('conversation.session.header.utilities')[0]!.inject!()
await vi.waitFor(() => { expect(face.hooks?.openInAppLaunch.getSnapshot().phase).toBe(success ? 'idle' : 'error') })
expect(toast.hooks.toast.getSnapshot()).toEqual(success
? mobile ? { kind: 'opened', seq: 1 } : null
: { kind: 'failed', failure: 'revealError', seq: 1 })
expect(notify).not.toHaveBeenCalled()
expect(fetcher).toHaveBeenCalledWith('open-in-app/open', expect.objectContaining({
body: JSON.stringify({ app: 'finder', path: '/captured' }),
}))
@@ -109,14 +146,13 @@ describe('open-in-app browser half', () => {
expect(removed.source.getSnapshot()).toEqual([])
removed.dispose()
})
it('refuses absent workspaces and reports failed keyboard launches', async () => {
it('refuses absent workspaces and reports failed keyboard launches to the app-wide toast', async () => {
vi.stubGlobal('fetch', vi.fn(async (input: string | URL) => String(input) === 'open-in-app/apps'
? new Response(JSON.stringify({ apps: ['finder'] })) : new Response('failed', { status: 500 })))
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const { fiber, list, commands } = await bench()
const { ctx, fiber, list, commands } = await bench()
try {
const command = commands.get('workspace.openLocal')!
expect(command.label()).toBe('Open locally')
expect(command.label()).toBe(en['open.tooltip'])
const context = { region: 'page', modal: null, target: null } as const
expect(command.resolve(context).status).toBe('blocked')
const id = 'main' as SessionId
@@ -127,9 +163,14 @@ describe('open-in-app browser half', () => {
await vi.waitFor(() => { expect(command.resolve(context).status).toBe('handled') })
const result = command.resolve(context)
if (result.status !== 'handled') throw new Error('Expected an available workspace')
const toast = toastSource(ctx)
expect(toast.hooks.toast.getSnapshot()).toBeNull()
result.run()
await vi.waitFor(() => { expect(warn).toHaveBeenCalledWith('workspace open rejected:', expect.any(Error)) })
} finally { await fiber.dispose(); warn.mockRestore() }
// The keyboard path has no control of its own: its failure reaches the same overlay store.
await vi.waitFor(() => {
expect(toast.hooks.toast.getSnapshot()).toEqual({ kind: 'failed', failure: 'openError', seq: 1 })
})
} finally { await fiber.dispose() }
})
it('captures the main directory and remembered app before dispatch and shares pointer launch occupancy', async () => {
let finish!: (response: Response) => void
@@ -155,7 +196,8 @@ describe('open-in-app browser half', () => {
if (resolution.status !== 'handled') throw new Error('available workspace was blocked')
resolution.run()
expect(command.resolve(context).status).toBe('blocked')
await face.launch('finder', '/second')
// A gesture another launch already holds reports deliberate silence, not a second success.
await expect(face.launch('finder', '/second')).resolves.toEqual({ kind: 'skipped' })
const calls = fetcher.mock.calls.filter(call => call[0] === 'open-in-app/open')
expect(calls).toHaveLength(1)
expect(calls[0]?.[1]?.body).toBe(JSON.stringify({ app: 'cursor', path: '/first' }))
@@ -165,6 +207,52 @@ describe('open-in-app browser half', () => {
expect(commands.size).toBe(0)
})
it.each([
['mobile', true, 'open', { kind: 'opened' }, 'path.opened'],
['mobile', true, 'reveal', { kind: 'revealed' }, 'path.revealed'],
['mobile', true, 'open', { kind: 'failed', failure: 'openError' }, 'path.openError'],
['mobile', true, 'reveal', { kind: 'failed', failure: 'revealError' }, 'path.revealError'],
['desktop', false, 'open', { kind: 'failed', failure: 'openError' }, 'path.openError'],
['desktop', false, 'reveal', { kind: 'failed', failure: 'revealError' }, 'path.revealError'],
] as const)('keeps %s feedback after the initiating card unmounts (mobile=%s, %s, %j)', async (_device, mobile, action, outcome, message) => {
vi.mocked(isMobileDevice).mockReturnValue(mobile)
vi.stubGlobal('fetch', vi.fn(async (input: string | URL) => String(input) === 'open-in-app/apps'
? Response.json({ apps: [] })
: Response.json([{ id: 'editor', name: 'Editor', default: true, icon: null }])))
const { ctx, fiber } = await bench()
const pending = Promise.withResolvers<OpenInAppOutcome>()
try {
const entry = ctx.slots.entries('deliverables.file.actions')[0]!
const report = entry.inject!().report as OpenTargetReporter['report']
const toast = toastSource(ctx)
render(createElement(OpenInAppToast, {
useToast: bindSnapshotSelector(toast.hooks.toast), dismiss: toast.dismiss, t: makeTranslate(en),
} as OpenInAppToastProps))
const onAction = vi.fn(() => pending.promise)
const control = render(createElement(FileRouteAction, {
report, actionUrl: 'api/present.open?sessionId=s&seq=2&index=0',
available: true, pending: false, onAction, t: makeTranslate(en),
}))
await screen.findByRole('button', { name: 'Open in Editor' })
if (action === 'reveal') {
fireEvent.click(screen.getByRole('button', { name: en['path.more'] }))
fireEvent.click(screen.getByRole('menuitem', { name: en['path.reveal'] }))
} else fireEvent.click(screen.getByRole('button', { name: 'Open in Editor' }))
expect(onAction).toHaveBeenCalledWith(action, undefined)
control.unmount()
expect(screen.queryByRole('button')).toBeNull()
expect(screen.queryByRole('alert')).toBeNull()
expect(ctx.slots.entries('shell.overlay')).toHaveLength(1)
await act(async () => { pending.resolve(outcome) })
expect(screen.getByRole('alert').textContent).toBe(en[message])
expect(toast.hooks.toast.getSnapshot()).toEqual({ ...outcome, seq: 1 })
} finally {
await act(async () => { pending.resolve({ kind: 'skipped' }) })
cleanup()
await fiber.dispose()
}
})
it('declares the services it binds', () => {
expect(inject).toEqual(['menus', 'sidebarRight', 'sidebarRightTabs', 'sessions', 'slots', 'locale', 'remote', 'remote.session', 'remote.workspaceFiles', 'shortcuts', 'layout'])
})
@@ -198,6 +286,7 @@ describe('open-in-app browser half', () => {
resolution.run()
await vi.waitFor(() => { expect(face.hooks.openInAppLaunch.getSnapshot().phase).toBe('idle') })
expect(fetcher.mock.calls.filter(([input]) => input === 'open-in-app/open')).toHaveLength(1)
expect(toastSource(ctx).hooks.toast.getSnapshot()).toBeNull()
} finally { await fiber.dispose() }
})
@@ -221,7 +310,7 @@ describe('open-in-app browser half', () => {
await expect(face.applications('/w/clip.mp4', signal)).resolves.toEqual([])
expect(remote.session.workspacePathApplications).toHaveBeenCalledWith({ path: '/w/clip.mp4' }, signal)
expect(face.hooks.openInAppDesktop.getSnapshot()).toBe(true)
expect(await face.openPath('/w/clip.mp4', 'reveal')).toBeNull()
expect(await face.openPath('/w/clip.mp4', 'reveal')).toEqual({ kind: 'revealed' })
expect(remote.session.openWorkspacePath).toHaveBeenLastCalledWith({ path: '/w/clip.mp4', action: 'reveal' })
await fiber.dispose()
expect(ctx.slots.entries('sidebar.right.tab.document.actions').map(entry => entry.options.id)).not.toContain('open-in-app')
@@ -229,6 +318,18 @@ describe('open-in-app browser half', () => {
expect(ctx.slots.entries('deliverables.file.actions')).toHaveLength(0)
})
it('injects the announcement seat into the delivery entries and releases every entry with the fiber', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ apps: [] }), { status: 200 })))
const { ctx, fiber } = await bench()
const entry = ctx.slots.entries('deliverables.review.file.actions')[0]
const face = (entry?.inject as () => OpenTargetReporter & Record<string, unknown>)()
face.report({ kind: 'failed', failure: 'revealError' })
expect(toastSource(ctx).hooks.toast.getSnapshot()).toEqual({ kind: 'failed', failure: 'revealError', seq: 1 })
await fiber.dispose()
expect(ctx.slots.entries('deliverables.review.file.actions')).toHaveLength(0)
expect(ctx.slots.entries('shell.overlay')).toHaveLength(0)
})
it('adapts the Session directory, shares the controller with the file tree, and removes both on teardown', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ apps: [] }), { status: 200 })))
const { ctx, fiber, list } = await bench()
@@ -270,7 +371,7 @@ describe('open-in-app browser half', () => {
const entry = ctx.slots.entries('conversation.session.header.utilities')[0]
const injected: Partial<OpenInAppActionInjected> | undefined = entry?.inject?.()
if (injected?.hooks === undefined || injected.iconUrl === undefined
|| injected.choose === undefined || injected.launch === undefined) {
|| injected.choose === undefined || injected.launch === undefined || injected.report === undefined) {
throw new Error('expected the injected open-in-app actions')
}
const { hooks } = injected
@@ -283,7 +384,11 @@ describe('open-in-app browser half', () => {
injected.choose('cursor')
expect(hooks.openInAppChoice.getSnapshot()).toBe('cursor')
await injected.launch('cursor', '/w/dir')
const toast = toastSource(ctx)
injected.report({ kind: 'revealed' })
expect(toast.hooks.toast.getSnapshot()).toBeNull()
await expect(injected.launch('cursor', '/w/dir')).resolves.toEqual({ kind: 'opened' })
const openCall = fetcher.mock.calls.find(call => call[0] === 'open-in-app/open')
expect(openCall?.[1]).toMatchObject({
method: 'POST',
@@ -293,7 +398,7 @@ describe('open-in-app browser half', () => {
await fiber.dispose()
})
it('publishes an empty availability list when the host read fails, and launches reject on HTTP errors', async () => {
it('publishes an empty availability list when the host read fails, and launches return named failures on HTTP errors', async () => {
vi.stubGlobal('fetch', vi.fn(async (input: string | URL) => {
if (String(input) === 'open-in-app/apps') throw new Error('down')
return new Response('', { status: 502 })
@@ -306,7 +411,7 @@ describe('open-in-app browser half', () => {
await vi.waitFor(() => {
expect(hooks.openInAppApps.getSnapshot()).toEqual([])
})
await expect(injected.launch('finder', '/w/dir')).rejects.toThrow('open failed: HTTP 502')
await expect(injected.launch('finder', '/w/dir')).resolves.toEqual({ kind: 'failed', failure: 'openError' })
await fiber.dispose()
})
@@ -12,6 +12,7 @@ import { OpenInAppPathController } from '../src/client/open-path.ts'
import { OpenInAppController } from '../src/client/controller.ts'
import { registerFileMenus } from '@deepseek-ai/dsh-client-ui-file-actions/src/client/context-menus.tsx'
import { registerContextMenus } from '../src/client/context-menus.tsx'
import type { OpenInAppToastSource } from '../src/client/open-toast.tsx'
const sessionId = SessionId('menu-actions')
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals() })
@@ -20,6 +21,7 @@ async function boot() {
vi.stubGlobal('Blob', NodeBlob)
const ctx = new Context()
const notify = vi.fn<ContextMenus['notify']>(), failure = vi.fn(), writeText = vi.fn(async () => {})
const report = vi.fn<OpenInAppToastSource['report']>()
vi.stubGlobal('navigator', { clipboard: { writeText } })
const registry = new MenuRegistry(failure)
const remote = {
@@ -52,7 +54,7 @@ async function boot() {
ctx.provide('sidebarRight', sidebar as never)
ctx.provide('sidebarRightTabs', { get: browserTab } as never)
const fiber = ctx.plugin({ apply: (plugin: Context) => {
registerContextMenus(plugin, paths, key => key, directories)
registerContextMenus(plugin, paths, key => key, directories, report)
registerFileMenus(plugin, ctx.fileActions, key => key)
} })
await fiber.await()
@@ -73,7 +75,7 @@ async function boot() {
return action
}
return { registry, resolve, choose, remote, fileActions, paths, directories, directoryFetch, sessions,
sidebar, browserTab, notify, failure, writeText }
sidebar, browserTab, notify, failure, writeText, report }
}
it('routes file actions through the captured pane, line, native association and original bytes', async () => {
@@ -84,8 +86,10 @@ it('routes file actions through the captured pane, line, native association and
{ paneId: 'pane', params: { line: 24 } })
await h.choose(file, 'open-in-app.file/open')
expect(h.remote.session.openWorkspacePath).toHaveBeenCalledWith({ path: '/workspace/file.svg', application: 'preview' })
await vi.waitFor(() => { expect(h.report).toHaveBeenLastCalledWith({ kind: 'opened' }) })
await h.choose(file, 'open-in-app.file/reveal')
expect(h.remote.session.openWorkspacePath).toHaveBeenCalledWith({ path: '/workspace/file.svg', action: 'reveal' })
await vi.waitFor(() => { expect(h.report).toHaveBeenLastCalledWith({ kind: 'revealed' }) })
await h.choose(file, 'file-actions.file/copyImage')
await vi.waitFor(() => { expect(h.notify).toHaveBeenCalledWith('menu.copied', true) })
const source = h.fileActions.copyImage.mock.calls[0]![0]
@@ -117,7 +121,7 @@ it('keeps copy-path available on failed metadata and reports native action failu
expect((await h.resolve(file)).source.getSnapshot().find(item => item.id.endsWith('/sidebar'))?.disabled).toBe(true)
h.remote.session.openWorkspacePath.mockResolvedValueOnce({ ok: false } as never)
await h.choose(file, 'open-in-app.file/reveal')
await vi.waitFor(() => { expect(h.notify).toHaveBeenCalledWith('path.revealError') })
await vi.waitFor(() => { expect(h.report).toHaveBeenLastCalledWith({ kind: 'failed', failure: 'revealError' }) })
h.remote.session.workspacePathApplications.mockResolvedValueOnce({ ok: false } as never)
expect((await h.resolve(file)).source.getSnapshot().find(item => item.id.endsWith('/open'))?.disabled).toBe(true)
h.paths.desktop.set(false)
@@ -239,9 +243,10 @@ it('opens the captured Workspace through the directory app catalog and reports l
const target: MenuTarget = { kind: 'workspace', value: { workspaceId: 'workspace' as never, path: '/workspace', displayTitle: 'Workspace' } }
await h.choose(target, 'open-in-app.workspace/reveal')
expect(h.directoryFetch).toHaveBeenLastCalledWith(expect.any(String), expect.objectContaining({ body: JSON.stringify({ app: 'finder', path: '/workspace' }) }))
await vi.waitFor(() => { expect(h.report).toHaveBeenLastCalledWith({ kind: 'opened' }) })
h.directoryFetch.mockResolvedValueOnce(new Response('', { status: 500 }))
await h.choose(target, 'open-in-app.workspace/reveal')
await vi.waitFor(() => { expect(h.failure).toHaveBeenCalledWith('path.revealError') })
await vi.waitFor(() => { expect(h.report).toHaveBeenLastCalledWith({ kind: 'failed', failure: 'revealError' }) })
h.directories.apps.set([])
expect((await h.resolve(target)).source.getSnapshot()[0]?.disabled).toBe(true)
})
@@ -38,7 +38,7 @@ it('offers image actions on file targets, copies absolute paths, and keeps save
const paths = new OpenInAppPathController(remote)
const directories = new OpenInAppController()
const fiber = ctx.plugin({ apply: (plugin: Context) => {
registerContextMenus(plugin, paths, key => key, directories)
registerContextMenus(plugin, paths, key => key, directories, vi.fn())
registerFileMenus(plugin, ctx.fileActions, key => key)
} })
await fiber.await()
@@ -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' })
})
})
@@ -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<OpenInAppOutcome> => ({ 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(<FileRouteAction {...p} />)
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(<FileRouteAction {...p} pending />)
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<OpenInAppOutcome> => ({ kind: 'failed', failure: 'openError' }))
render(<FileRouteAction {...failed} />)
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<OpenInAppOutcome> => ({ kind: 'skipped' }))
render(<FileRouteAction {...dropped} />)
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()
})
@@ -20,6 +20,7 @@ interface Bench {
props: OpenInAppActionProps
launch: ReturnType<typeof vi.fn>
choose: ReturnType<typeof vi.fn>
report: ReturnType<typeof vi.fn>
}
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(<OpenInAppAction {...bench({ apps: ['finder', 'cursor'], choice: 'cursor', absolutePath: '/w' }).props} />)
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(<OpenInAppAction {...bench({ apps: ['finder', 'cursor'], choice: 'vscode', absolutePath: '/w' }).props} />)
@@ -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(<OpenInAppAction {...b.props} launch={async () => ({ 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(<OpenInAppAction {...b.props} />)
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(<OpenInAppAction {...bench({ apps: ['finder'], absolutePath: '/w' }).props} />)
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()
@@ -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<typeof createSnapshotStore<boolean | null>>
loadDesktop: ReturnType<typeof vi.fn>
openPath: ReturnType<typeof vi.fn<(path: string, action: OpenInAppPathAction) => Promise<OpenInAppPathFailure | null>>>
openPath: ReturnType<typeof vi.fn<(path: string, action: OpenInAppPathAction) => Promise<OpenInAppOutcome>>>
report: Mock<(outcome: OpenInAppOutcome) => void>
}
function bench(over: { desktop?: boolean | null; openPath?: Bench['openPath'] } = {}): Bench {
const desktop = createSnapshotStore<boolean | null>(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<OpenInAppOutcome> => ({ kind: 'opened' }))
const report = vi.fn<(outcome: OpenInAppOutcome) => void>()
function useSelector<T, R>(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<OpenInAppPathFailure | null>()
it('disables both halves while its own gesture settles and reports the failure it settled on', async () => {
const settled = Promise.withResolvers<OpenInAppOutcome>()
const b = bench({ openPath: vi.fn(() => settled.promise) })
const { container } = render(<OpenPathAction {...b.props} />)
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<OpenInAppOutcome> => ({ kind: 'revealed' })) })
render(<OpenPathEmptyAction {...revealed.props} applications={async () => []} />)
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<OpenInAppOutcome> => ({ kind: 'failed', failure: 'openError' })) })
render(<OpenPathEmptyAction {...b.props} />)
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(<OpenPathEmptyAction {...b.props} />)
await act(async () => {})
await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.open'] })) })
act(() => { vi.advanceTimersByTime(2_000) })
view.rerender(<OpenPathEmptyAction {...b.props} />)
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<OpenInAppPathFailure | null>()
const pending = Promise.withResolvers<OpenInAppOutcome>()
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' })
})
@@ -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' })
})
})
@@ -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: <T,>(select: (value: OpenInAppToastState | null) => T): T => select(state),
dismiss,
t: makeTranslate(dictionary),
} as OpenInAppToastProps
return { view: render(<OpenInAppToast {...props} />), 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'])
})
})
@@ -16,7 +16,7 @@ it.each([
[['vscode'], '/workspace', 'Show in folder', true],
[null, '/workspace', 'Show in folder', true],
[['finder'], undefined, 'Show in Finder', true],
] as const)('reveals the captured directory with catalog %j and path %s', (apps, cwd, label, disabled) => {
] as const)('reveals the captured directory with catalog %j and path %s', async (apps, cwd, label, disabled) => {
const ctx = new Context()
onTestFinished(() => ctx.fiber.dispose())
const locale = new LocaleRuntime(ctx)
@@ -32,11 +32,17 @@ it.each([
ctx.provide('sessions', { list } as never)
const controller = new OpenInAppController()
controller.apps.set(apps)
const launch = vi.spyOn(controller, 'launch').mockResolvedValue()
registerSessionReveal(ctx, controller, locale.bind(NS))
const launch = vi.spyOn(controller, 'launch').mockResolvedValue({ kind: 'opened' })
const report = vi.fn()
registerSessionReveal(ctx, controller, locale.bind(NS), report)
const menu = registry.resolve({ kind: 'session', value: { sessionId, displayTitle: 'Captured' } })
expect(menu.source.getSnapshot()[0]).toMatchObject({ label, disabled })
menu.select('open-in-app.reveal/reveal')
if (disabled) expect(launch).not.toHaveBeenCalled()
else expect(launch).toHaveBeenCalledWith(apps?.[0], cwd)
if (disabled) {
expect(launch).not.toHaveBeenCalled()
expect(report).not.toHaveBeenCalled()
} else {
expect(launch).toHaveBeenCalledWith(apps?.[0], cwd)
await vi.waitFor(() => { expect(report).toHaveBeenCalledWith({ kind: 'opened' }) })
}
})
@@ -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
@@ -59,6 +59,8 @@ New sub-calls use `<parent>:ptc:<n>` 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.
@@ -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) — 配置校验和作用域注册;工具描述和批次执行。
@@ -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.
+7 -2
View File
@@ -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' : ''}.`
@@ -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<undefined>()
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<void>((resolve) => {
const timer = setTimeout(resolve, 500)
exec.signal.addEventListener('abort', () => { sawAbort = true; clearTimeout(timer); resolve() }, { once: true })
exec.signal.addEventListener('abort', () => { sawAbort = true; resolve() }, { once: true })
entered.resolve(undefined)
})
return [{ type: 'text' as const, text: args.id }]
},
@@ -1621,7 +1623,8 @@ describe('the run_code dispatch bridge', () => {
runtime.behavior = async (request) => {
const tools = request.bindings[0]!.functions
const calls = [tools.slow!({ id: 'first' }).catch(() => 'rejected'), tools.slow!({ id: 'second' }).catch(() => 'rejected')]
setTimeout(() => { controller.abort('user-cancel') }, 50)
await entered.promise
controller.abort('user-cancel')
await Promise.all(calls)
// A real runtime would be terminated by the abort; the fake honors the
// contract by reporting the abort as the run failure.
@@ -1629,7 +1632,7 @@ describe('the run_code dispatch bridge', () => {
}
const result = await runCode(ctx, 'program', { signal: controller.signal })
expect(result.isError).toBe(true)
expect((result.content[0] as { text: string }).text).toContain('code run failed (abort)')
expect(result.error).toEqual({ message: 'tool call aborted', info: { name: 'AbortError', code: TOOL_ABORTED } })
expect(seen).toEqual(['first'])
expect(sawAbort).toBe(true)
})
@@ -2045,6 +2048,60 @@ describe('the run_code dispatch bridge', () => {
expect(calls).toEqual([])
})
describe.each(['typescript', 'python'])('%s caller cancellation', (language) => {
it.each<AgentCancelCause | undefined>([
{ kind: 'user' },
{ kind: 'parent' },
{ kind: 'hook', reason: 'policy stopped the turn' },
{ kind: 'disposed' },
undefined,
])('maps reason %j to the native tool abort result', async (cause) => {
const { ctx, runtime } = await setup({ mode: 'ptc', runtime: { language } })
onTestFinished(async () => { await ctx.fiber.dispose() })
const controller = new AbortController()
runtime.behavior = async (request) => {
if (cause === undefined) controller.abort()
else controller.abort(cause)
expect(request.signal?.reason).toBe(controller.signal.reason)
const reason: unknown = request.signal?.reason
const message = reason instanceof Error ? reason.message : String(reason)
return { logs: [], error: { kind: 'abort', message } }
}
const result = await runCode(ctx, 'program', { signal: controller.signal })
expect(result).toEqual({
content: [{ type: 'text', text: 'Error: tool call aborted' }],
isError: true,
error: {
message: 'tool call aborted',
info: { name: 'AbortError', code: TOOL_ABORTED },
},
})
})
})
it.each<[boolean, PtcRunFailure]>([
[false, { kind: 'abort', message: 'sandbox mode changed' }],
[true, { kind: 'worker-exit', message: 'managed process cleanup failed' }],
[true, { kind: 'timeout', message: 'execution deadline reached' }],
])('keeps runtime failure details with caller cancellation %s: %j', async (cancelled, failure) => {
const { ctx, runtime } = await setup({ mode: 'ptc' })
onTestFinished(async () => { await ctx.fiber.dispose() })
const controller = new AbortController()
runtime.behavior = async () => {
if (cancelled) controller.abort({ kind: 'user' })
return { logs: ['got this far'], error: failure }
}
const result = await runCode(ctx, 'program', { signal: controller.signal })
expect(result.error).toEqual({
message: `code run failed (${failure.kind}): ${failure.message}\nCaptured output:\ngot this far`,
info: { name: 'CodeRunFailedError', code: 'CODE_RUN_FAILED' },
})
})
it('a tool/ptc-dispatch event never derives a model message', () => {
const session = Session.create(SessionId('ptc-derive'))
session.append('user/message', createUserMessage({
@@ -2178,9 +2178,11 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
ownerProps: [
'/** Native file action selected by an explicit user gesture. */\nexport type PresentedAction = \'open\' | \'reveal\'',
'/** Failure feedback for the shared native opening control. */\nexport type PresentedOpenFailure = \'openError\' | \'revealError\' | null',
'/**\n * Settled result of one native file gesture, for the contributed control to\n * announce: the Host acknowledged an application open or a file-manager\n * reveal, the gesture failed, or the request was deliberately dropped because\n * the same file was already opening or this controller was disposed. Only an\n * acknowledged operation may be announced as a success.\n */\nexport type PresentedOpenOutcome =\n | { readonly kind: \'opened\' }\n | { readonly kind: \'revealed\' }\n | { readonly kind: \'failed\'; readonly failure: PresentedOpenFailure }\n | { readonly kind: \'skipped\' }',
],
ownerPropsReferences: [
'PresentedOpenFailure',
],
ownerPropsReferences: [],
standardProps: [
'useRemoteAccess: (action: RemoteEndpoint) => boolean',
'useResource: UseResource',
@@ -3546,6 +3548,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
'client-ui-chat QuotaNoticeHost id \'chat.quota-notice\'',
'client-ui-context-menu MenuOverlay id \'context-menu\'',
'client-ui-file-drag FileDragFeedback id \'file-drag.feedback\'',
'client-ui-open-in-app OpenInAppToast id \'open-in-app.toast\'',
'client-ui-plugin-manager PluginRefreshToast id \'plugin-manager.refresh-toast\'',
'client-ui-schedule ScheduleDeleteToast id \'schedule.delete-toast\'',
'client-ui-settings-account DesktopOnboardingEntry id \'desktop-onboarding\'',
+10
View File
@@ -0,0 +1,10 @@
/** Cancel after the PTC process completes a nested working_directory call. */
export const name = 'ptc-cancel-fixture'
export const inject = ['tools']
export function apply(ctx) {
ctx.on('tools/result', (exec) => {
if (exec.name === 'working_directory' && exec.parent !== undefined && exec.agent !== undefined) {
exec.agent.cancel({ kind: 'user' })
}
})
}
@@ -0,0 +1,5 @@
- insert:
- id: ptc-runtime
name: '@deepseek-ai/dsh-ptc-runtime-node'
- id: ptc-cancel-fixture
name: './cancel.mjs'
+21
View File
@@ -0,0 +1,21 @@
{"type":"session","version":4,"id":"{{session:1}}","createdAt":1785014439563,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0}
{"type":"permission/preset","data":{"preset":"danger-full-access"}}
{"type":"sandbox/mode","data":{"mode":"danger-full-access"}}
{"type":"approval/policy","data":{"policy":"never"}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Start a run_code program, call working_directory, then keep running until I stop it."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]}}
{"type":"turn/start","data":{"turn":1}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
{"type":"step/start","data":{"turn":1,"step":1}}
{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Start a run_code program, call working_directory, then keep running until I stop it."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"}
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
{"type":"session/title","data":{"title":"Start a run_code program, call","messageSeqs":[8],"source":{"kind":"fallback"}}}
{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"ptc-cancel","name":"run_code","arguments":"{\"code\":\"await tools.working_directory({}); for (;;) {}\",\"description\":\"Run until stopped\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"stream":[{"type":"chunk","time":1792996619936,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1792996619936,"index":0,"dt":[],"id":"ptc-cancel","name":"run_code","args":["{\"code\":\"await tools.working_directory({}); for (;;) {}\",\"description\":\"Run until stopped\"}"]},{"type":"chunk","time":1792996619936,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"ptc-cancel","name":"run_code","arguments":"{\"code\":\"await tools.working_directory({}); for (;;) {}\",\"description\":\"Run until stopped\"}"}}},{"type":"chunk","time":1792996619936,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}
{"type":"tool/call","data":{"turn":1,"step":1,"callId":"ptc-cancel","name":"run_code","arguments":"{\"code\":\"await tools.working_directory({}); for (;;) {}\",\"description\":\"Run until stopped\"}"}}
{"type":"tool/ptc-dispatch-start","data":{"rootCallId":"ptc-cancel","parentCallId":"ptc-cancel","subCallId":"ptc-cancel:ptc:1","name":"working_directory","arguments":{}}}
{"type":"tool/ptc-dispatch","data":{"rootCallId":"ptc-cancel","parentCallId":"ptc-cancel","subCallId":"ptc-cancel:ptc:1","name":"working_directory","arguments":{},"isError":false,"content":[{"type":"text","text":"{{cwd}}"}]}}
{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"ptc-cancel"},"toolCallId":"ptc-cancel","content":[{"type":"text","text":"Error: tool call aborted"}],"isError":true,"id":"{{message:5}}"},"error":{"name":"AbortError","code":"ABORTED"}},"sourceEventSeqs":[14],"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":1}}
{"type":"turn/end","data":{"turn":1,"reason":{"kind":"aborted","reason":{"kind":"user"}}}}
+7
View File
@@ -0,0 +1,7 @@
version: 1
scenario: ptc-cancel
profile: sdk
composition: ptc
recording: authored
header:
class: ptc
+17
View File
@@ -159,6 +159,9 @@ const SDK_ASSERTIONS: Readonly<Record<string, SdkAssertions>> = {
patches: [fileURLToPath(new URL('./tool-error-details/runtime.cordis.yml', import.meta.url))],
expectedFinalResponse: 'ERROR_DETAILS_OK',
},
'ptc-cancel': {
patches: [fileURLToPath(new URL('./ptc-cancel/runtime.cordis.yml', import.meta.url))],
},
'ptc-turn': {
patches: [fileURLToPath(new URL('./ptc-turn/runtime.cordis.yml', import.meta.url))],
expectedFinalResponse: 'CODE_ONE+CODE_TWO',
@@ -1010,6 +1013,20 @@ describe('TypeScript SDK snapshots over the jsonrpc runtime', () => {
)
reconcileCatalogCreationTimes(ordered.map(log => log.content), 'validate')
const actualContext = contextOf(ordered, cwd)
if (scenario.name === 'ptc-cancel') {
const events = results.flatMap(result => result.events)
expect(events.find(event => event.type === 'tool/result')).toMatchObject({ data: {
message: {
toolCallId: 'ptc-cancel',
isError: true,
content: [{ type: 'text', text: 'Error: tool call aborted' }],
},
error: { name: 'AbortError', code: 'ABORTED' },
} })
expect(events.find(event => event.type === 'turn/end')).toMatchObject({
data: { reason: { kind: 'aborted', reason: { kind: 'user' } } },
})
}
if (scenario.name === 'minimal-image-compaction') {
const events = results.flatMap(result => result.events)
const failures = events.filter(event => event.type === 'turn/end' && event.data.reason.kind === 'error')
@@ -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.