diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml
index 7ea790985c..8f8941ac01 100644
--- a/packages/client/ui-deliverables/README.i18n.yaml
+++ b/packages/client/ui-deliverables/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-deliverables/README.md
-README.md: 71f3545b8fb3fed3ecd678258e6c50bbfa4b580c
-README.zh.md: 3e59ed2b7d4ac7e9d64b7d1f2f3c477e3ca1c50e
+README.md: 9073659f5b6aeb11112aaa1c4e7834bf055944e7
+README.zh.md: 7a622b6f0bcf4bc75027542cc90fe6c2e2b86471
diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md
index 71f3545b8f..9073659f5b 100644
--- a/packages/client/ui-deliverables/README.md
+++ b/packages/client/ui-deliverables/README.md
@@ -30,9 +30,9 @@ Mount this plugin alongside `ui-conversation` and the Host [workspace-changes](.
### Explicit deliveries
-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/tool-present/README.md) owns file-count limits and Session declarations. The closing turn shows one delivery as a full-width card and multiple deliveries in a two-column grid with 10px gaps. A list longer than four files starts collapsed and provides a control that reveals or hides the complete list. Each 60px-high card uses 8px vertical and 10px horizontal inset spacing, a 20px shared `FileTypeIcon` in a 40px frame, 13px filename text, 10px secondary text, and a 12px Open action. It shows the basename and description, or the file type when no description exists; a trailing parenthesized suffix in the description is omitted, and hovering the card replaces that line with the Sidebar-preview action. Clicking the card or the left side of its split Open control previews the file in the right Sidebar. The chevron opens the standard menu for the Host default application plus Show in Finder on macOS, Show in File Explorer on Windows and WSL, or Open containing folder through the default Linux file manager. Matching inline-code references preview the same source files in the right Sidebar; native opening requires an explicit card-menu action. Repeated declaration of a path selects its latest description before the closing reply.
+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/tool-present/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. 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. Duplicate declarations use the last description before the closing reply.
-The `present` tool row retains the delivery-document glyph while showing running, delivered, failed, or interrupted status; expanding a settled row reveals its recorded result. The collapsible card grid retains every delivered file. Both menu actions share pending state and show progress, acknowledgement, or an action-specific retryable error. Desktop information is read when delivery cards appear and invalidated on connection replacement; responses from a replaced connection cannot publish metadata. Selecting a native menu action returns keyboard focus to the available Sidebar Open button. Pending actions close the menu until another explicit gesture. A missing desktop disables the Open menu; a failed desktop-information read offers Retry. It requires a desktop and a suitable default application on the serving Host; a remote browser does not open applications on its own device.
+The `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. 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`, supplied by [ui-open-in-app](../ui-open-in-app/README.md). Association GET requests and native POST actions both validate the recorded file coordinates and Session filesystem mapping before using the Host desktop.
### The changed-files card
diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md
index 3e59ed2b7d..7a622b6f0b 100644
--- a/packages/client/ui-deliverables/README.zh.md
+++ b/packages/client/ui-deliverables/README.zh.md
@@ -30,9 +30,9 @@ kind: "package-reference"
### 显式交付
-Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交付会话文件系统可访问的最终文件,包括通过 Bash 创建的文件。创建文件后,以 `files: [{ path, description? }]` 调用。[present 工具](../../deliverables/tool-present/README.zh.md)拥有文件数量限制和会话声明。收尾轮次把单个交付显示为横向占满内容区的卡片,把多个交付显示为间距 10px 的双列网格。文件超过四个时,列表默认收起,并提供显示或隐藏完整列表的控件。每张卡片高 60px,上下内边距为 8px、左右为 10px;40px 图标框内使用 20px 的共享 `FileTypeIcon`,文件名为 13px、次要文本为 10px,“打开”操作为 12px。卡片显示 basename 与说明;没有说明时显示文件类型,说明末尾的括号后缀会被省略,悬停卡片时该行切换为侧栏预览提示。点击卡片或分段“打开”控件的左侧会在右侧 Sidebar 中预览文件;右侧箭头打开标准菜单,其中提供 Host 默认应用,以及 macOS 上的“在 Finder 中显示”、Windows 和 WSL 上的“在文件资源管理器中显示”或 Linux 默认文件管理器的“打开所在文件夹”。匹配的行内代码引用在右侧 Sidebar 中预览相同源文件;原生打开需要显式选择卡片菜单中的操作。同一路径重复声明时,选择收尾回复之前最近一次的说明。
+Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present`,用于声明会话文件系统可访问的最终文件,包括通过 Bash 创建的文件。创建后以 `files: [{ path, description? }]` 调用。[present 工具](../../deliverables/tool-present/README.zh.md)拥有文件数量限制和会话声明。单个交付占满一行,多个交付使用双列网格,超过四个时默认收起。卡片显示文件名、说明或文件类型,以及共享的原生打开控件。点击卡片会在右侧 Sidebar 预览文件。控件打开默认应用,并列出关联应用,文件定位始终放在最后。匹配的行内代码引用也打开 Sidebar 预览。重复声明使用收尾回复之前最近一次的说明。
-`present` 工具行在正在交付、已交付、失败或中断状态下都保留交付文档图标;展开已结束的调用可查看其记录的结果。可折叠卡片网格保留全部交付文件。菜单中的两个操作共享等待状态,并显示进度、成功确认或各自可重试的错误。交付卡片出现时读取桌面信息,连接更换时清除缓存,旧连接的响应不能更新元数据。选择原生菜单操作后,键盘焦点回到仍可用的侧边栏“打开”按钮。等待操作完成时关闭菜单,用户再次点击才会打开。Host 没有桌面时禁用“打开”菜单;桌面信息读取失败时提供“重试”。服务 Host 必须具备桌面和合适的默认应用;远程浏览器不会打开其所在设备上的应用。
+`present` 工具行在执行、成功、失败和中断状态下保留文档图标及记录的结果。原生操作共享等待状态,并在卡片显示进度或可重试错误。卡片挂载时读取桌面信息,连接更换时清除缓存。Host 没有桌面时,卡片保留 Sidebar 预览并隐藏原生控件;元数据读取失败时提供重试。交付卡片和变更对比页通过 `deliverables.file.actions` 使用 [ui-open-in-app](../ui-open-in-app/README.zh.md) 提供的控件。关联应用 GET 查询与原生 POST 操作都会先校验记录中的文件坐标及会话文件系统映射,再调用 Host 桌面。
### 改动文件卡片
diff --git a/packages/client/ui-deliverables/src/client/Deliverables.module.css b/packages/client/ui-deliverables/src/client/Deliverables.module.css
index c772e6d01c..eac1f29ea4 100644
--- a/packages/client/ui-deliverables/src/client/Deliverables.module.css
+++ b/packages/client/ui-deliverables/src/client/Deliverables.module.css
@@ -20,16 +20,9 @@
.previewHint { display: none; }
.file:hover .secondaryText { display: none; }
.file:hover .previewHint { display: inline; }
-.split { display: inline-flex; flex: none; align-items: stretch; box-sizing: border-box; height: 28px; overflow: hidden; pointer-events: auto; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 10px; background: var(--dsw-alias-button-floating-fill); }
-.menuAnchor { align-self: stretch; }
-.open, .chevron { display: inline-flex; align-items: center; justify-content: center; border: 0; background: none; color: var(--dsw-alias-label-primary); cursor: pointer; font-family: var(--dsw-font-family); }
-.open { padding: 4px 8px; font-size: 12px; line-height: 18px; }
-.chevron { padding: 4px 5px; border-left: 0.5px solid var(--dsw-alias-border-l3); color: var(--dsw-alias-label-secondary); }
-.open:hover, .open:focus-visible, .chevron:hover:not(:disabled), .chevron:focus-visible { background: var(--dsw-alias-interactive-bg-hover); }
-.chevron:disabled { color: var(--dsw-alias-label-dimmed); cursor: not-allowed; }
-.menuActionIcon { display: block; width: 16px; height: 16px; }
.toggle { align-self: center; display: inline-flex; align-items: center; gap: 4px; min-width: 0; padding: 1px 11px; border: 0; border-radius: 8px; background: transparent; color: var(--dsw-alias-label-tertiary); cursor: pointer; font: inherit; font-size: 12px; line-height: 18px; }
.toggle:hover { background: var(--dsw-alias-interactive-bg-hover); }
.toggle svg { flex: none; width: 14px; height: 14px; }
@container (max-width: 620px) { .presented { grid-template-columns: minmax(0, 1fr); } }
@media (pointer: coarse) { .split { min-height: 44px; } .open, .chevron { min-width: 44px; } }
+.actions { display: inline-flex; flex: none; pointer-events: auto; }
diff --git a/packages/client/ui-deliverables/src/client/Deliverables.tsx b/packages/client/ui-deliverables/src/client/Deliverables.tsx
index ed7c034941..fa6c167b1e 100644
--- a/packages/client/ui-deliverables/src/client/Deliverables.tsx
+++ b/packages/client/ui-deliverables/src/client/Deliverables.tsx
@@ -2,7 +2,7 @@
import { useEffect, useState } from 'react'
import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client'
import { Button, IconChevronDownOutlineRegular, IconChevronUpOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
-import type { GlobalStandardProps, InjectFace, PropsLocale, PropsRuntime, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
+import type { GlobalStandardProps, InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
import type { PresentedOpenController } from './present-open.ts'
import type { ChangesSummaryStore } from './changes-summary.ts'
@@ -50,7 +50,7 @@ export function selectDeliverables(owner: TurnTailOwnerProps): DeliverablesMatch
* @param props - closing Turn, file actions, and localized copy.
* @returns file rows, or null when the Turn declares none.
*/
-export function DeliverablesTail(props: PropsRuntime<'conversation.chat.turnTail'> & PropsLocale & InjectFace) {
+export function DeliverablesTail(props: PropsRuntime<'conversation.chat.turnTail'> & PropsLocale & InjectFace & PropsRenderSlots<'deliverables.file.actions'>) {
const matched = selectDeliverables(props)
return matched === null ? null :
}
@@ -64,10 +64,10 @@ export function DeliverablesTail(props: PropsRuntime<'conversation.chat.turnTail
*/
export function Deliverables({
matched, openFile, t, sessionId, useSessions, openPresented, openChangesReview, usePresentedOpen, usePresentedHost,
- useChangesSummary, reloadPresentedHost, loadChangesSummary, useShowCodeDiff,
+ useChangesSummary, reloadPresentedHost, loadChangesSummary, useShowCodeDiff, renderSlot,
}: Pick & {
matched: DeliverablesMatch
-} & PropsLocale & Pick & Pick & InjectFace) {
+} & PropsLocale & Pick & Pick & InjectFace & PropsRenderSlots<'deliverables.file.actions'>) {
const [expanded, setExpanded] = useState(false)
const showCodeDiff = useShowCodeDiff(value => value)
const cwd = useSessions(state => state.byId[sessionId]?.cwd)
@@ -105,7 +105,13 @@ export function Deliverables({
phase={states[presentedFileUrl(sessionId, file.seq, file.index)]}
host={host === 'error' ? null : host} t={t}
onPreview={() => { openFile(file.path) }}
- onAction={(action) => { void openPresented(sessionId, file.seq, file.index, action) }} />)}
+ actions={renderSlot('deliverables.file.actions', {
+ actionUrl: presentedFileUrl(sessionId, file.seq, file.index),
+ available: host !== null && host !== 'error' && host.available,
+ pending: states[presentedFileUrl(sessionId, file.seq, file.index)] === 'opening'
+ || states[presentedFileUrl(sessionId, file.seq, file.index)] === 'revealing',
+ onAction: (action, application) => openPresented(sessionId, file.seq, file.index, action, application),
+ })} />)}
{collapsible &&
}
- {file !== undefined && native &&
- { void openChanged(sessionId, seq, index) }}>
- }
+ {file !== undefined && renderSlot('deliverables.file.actions', {
+ actionUrl: changedFileUrl(sessionId, seq, index), available: native,
+ pending: phase === 'opening' || phase === 'revealing',
+ onAction: (action, application) => openChanged(sessionId, seq, index, action, application),
+ })}
{summaryState === 'loading' &&
{t('diff.loading')}
}
diff --git a/packages/client/ui-deliverables/src/client/file-actions.ts b/packages/client/ui-deliverables/src/client/file-actions.ts
new file mode 100644
index 0000000000..b39ab16a41
--- /dev/null
+++ b/packages/client/ui-deliverables/src/client/file-actions.ts
@@ -0,0 +1,21 @@
+/** Native file-action contribution shared by delivery cards and change review. */
+import type { PresentedOpenFailure } from './present-open.ts'
+import type { PresentedAction } from '../presented.ts'
+
+declare module '@deepseek-ai/dsh-client-ui-slots' {
+ interface SlotMap {
+ /** Open one file through its authorized Session event coordinates. */
+ 'deliverables.file.actions': {
+ kind: 'list'
+ scope: 'session'
+ owner: {
+ /** Authenticated document-relative action route carrying Session event coordinates. */
+ 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
+ }
+ }
+ }
+}
diff --git a/packages/client/ui-deliverables/src/client/index.ts b/packages/client/ui-deliverables/src/client/index.ts
index e61596ea4b..da1e38c5e6 100644
--- a/packages/client/ui-deliverables/src/client/index.ts
+++ b/packages/client/ui-deliverables/src/client/index.ts
@@ -8,6 +8,7 @@
* cap, and copy — so composing this plugin out of cordis.yml removes every
* surface; the owning view renders an empty list and inert prose at zero cost.
*/
+import './file-actions.ts'
import type { Context as ClientContext } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-api-remotes/client'
import type {} from '@deepseek-ai/dsh-client-connection/client'
@@ -63,13 +64,14 @@ export function apply(ctx: ClientContext): void {
name: 'conversation.chat.turnTail',
id: '@deepseek-ai/dsh-client-ui-deliverables',
locale: NS,
+ children: { 'deliverables.file.actions': { kind: 'list', scope: 'session' } },
inject: (): DeliverablesInjected => ({
hooks: { presentedOpen: opener.state, presentedHost: opener.host, changesSummary: summaries.state,
showCodeDiff: ctx.settingsScope.developerTools.enabled },
reloadPresentedHost: () => opener.loadHost(),
loadChangesSummary: (sessionId, seq) => summaries.load(sessionId, seq),
- openPresented: (sessionId, seq, index, action) => opener.open(sessionId, seq, index, action),
- openChanged: (sessionId, seq, index) => opener.openChanged(sessionId, seq, index),
+ openPresented: (sessionId, seq, index, action, application) => opener.open(sessionId, seq, index, action, application),
+ openChanged: (sessionId, seq, index, action, application) => opener.openChanged(sessionId, seq, index, action, application),
openChangesReview: (coordinates, index) => {
ctx.sidebarRight.openResource(changesReviewAddress(coordinates), { params: { index } })
},
@@ -84,12 +86,13 @@ export function apply(ctx: ClientContext): void {
ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register(
{
name: 'sidebar.right.pane.tab', key: CHANGES_REVIEW_ID, locale: NS, store: createReviewStore(),
+ children: { 'deliverables.file.actions': { kind: 'list', scope: 'session' } },
inject: (): ReviewInjected => ({
hooks: { changesSummary: summaries.state, changesDiff: diffs.state, presentedOpen: opener.state, presentedHost: opener.host },
loadChangesSummary: (sessionId, seq) => summaries.load(sessionId, seq),
loadChangesDiff: (sessionId, seq, index) => diffs.load(sessionId, seq, index),
reloadPresentedHost: () => opener.loadHost(),
- openChanged: (sessionId, seq, index) => opener.openChanged(sessionId, seq, index),
+ openChanged: (sessionId, seq, index, action, application) => opener.openChanged(sessionId, seq, index, action, application),
}),
},
ReviewTab,
diff --git a/packages/client/ui-deliverables/src/client/locales.ts b/packages/client/ui-deliverables/src/client/locales.ts
index 7eacc52c34..4ed0595bc3 100644
--- a/packages/client/ui-deliverables/src/client/locales.ts
+++ b/packages/client/ui-deliverables/src/client/locales.ts
@@ -15,12 +15,6 @@ export const zh = {
'presented.unavailable': '此主机没有可用的桌面,无法使用外部程序打开文件或文件夹;文件仍可在侧边栏预览',
'presented.retry': '重试',
'presented.hostError': '无法读取主机桌面信息',
- 'presented.directory': '打开所在文件夹',
- 'presented.explorer': '在文件资源管理器中显示',
- 'presented.finder': '在 Finder 中显示',
- 'presented.defaultApp': '用默认应用打开',
- 'presented.more': '{name} 的更多文件操作',
- 'presented.action': '打开',
'presented.preview': '在侧边栏预览',
'presented.previewButton': '在侧边栏打开 {name}',
'presented.previewCard': '在侧边栏预览 {name}',
@@ -59,7 +53,6 @@ export const zh = {
'review.wrapAria': '自动换行',
'review.openFile': '在侧边栏打开整个文件',
'review.openFileAria': '在侧边栏打开 {name}',
- 'diff.openNativeAria': '用默认应用打开 {name}',
'diff.loading': '正在读取改动…',
'diff.missing': '这轮改动的内容已不可用',
'diff.error': '无法读取改动',
@@ -70,8 +63,6 @@ export const zh = {
'diff.unchanged': '两侧内容相同',
'diff.coarse': '逐行对比超时,按整个文件替换显示',
'diff.truncated': '只显示前 {count} 行',
- 'diff.openNative': '用默认应用打开',
- 'diff.openNativeError': '打开失败,点击重试',
}
/** English dictionary (same key set). */
@@ -86,12 +77,6 @@ export const en: Record = {
'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',
'presented.hostError': 'Could not read the Host desktop information',
- 'presented.directory': 'Open containing folder',
- 'presented.explorer': 'Show in File Explorer',
- 'presented.finder': 'Show in Finder',
- 'presented.defaultApp': 'Open in default app',
- 'presented.more': 'More file actions for {name}',
- 'presented.action': 'Open',
'presented.preview': 'Preview in sidebar',
'presented.previewButton': 'Open {name} in sidebar',
'presented.previewCard': 'Preview {name} in sidebar',
@@ -130,7 +115,6 @@ export const en: Record = {
'review.wrapAria': 'Line wrap',
'review.openFile': 'Open the whole file in the sidebar',
'review.openFileAria': 'Open {name} in sidebar',
- 'diff.openNativeAria': 'Open {name} in default app',
'diff.loading': 'Reading changes…',
'diff.missing': 'The contents of this turn’s changes are no longer available',
'diff.error': 'Could not read the changes',
@@ -141,8 +125,6 @@ export const en: Record = {
'diff.unchanged': 'Both sides hold the same lines',
'diff.coarse': 'Line comparison timed out; shown as a whole-file replacement',
'diff.truncated': 'Showing the first {count} lines',
- 'diff.openNative': 'Open in default app',
- 'diff.openNativeError': 'Could not open. Click to retry.',
}
/** Union of this namespace's dictionary keys. */
diff --git a/packages/client/ui-deliverables/src/client/present-open.ts b/packages/client/ui-deliverables/src/client/present-open.ts
index d724816d19..92764884ef 100644
--- a/packages/client/ui-deliverables/src/client/present-open.ts
+++ b/packages/client/ui-deliverables/src/client/present-open.ts
@@ -4,7 +4,10 @@ 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'
-/** State of the latest explicit open gesture for one saved file. */
+/** Failure feedback for the shared native opening control. */
+export type PresentedOpenFailure = 'openError' | 'revealError' | null
+
+/** State published on the owning file card. */
export type PresentedOpenPhase = 'opening' | 'opened' | 'revealing' | 'revealed' | 'error' | 'revealError' | 'nativeUnavailable'
/** One browser plugin's file-open requests, cancelled when that plugin is disposed. */
@@ -16,7 +19,7 @@ export class PresentedOpenController {
private loading: Promise | undefined
private metadata = new AbortController()
private readonly lifetime = new AbortController()
- private readonly pending = new Set>()
+ private readonly pending = new Set>()
/**
* Open a declared file once while a request for the same coordinates is pending.
@@ -25,10 +28,13 @@ export class PresentedOpenController {
* @param seq - durable delivery event sequence.
* @param index - original file index within that event.
* @param action - default application open or file-manager reveal.
- * @returns after the Host acknowledges opening or the error state is published.
+ * @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.
*/
- open(sessionId: SessionId, seq: number, index: number, action: PresentedAction = 'open'): Promise {
- return this.openUrl(presentedFileUrl(sessionId, seq, index), action)
+ open(
+ sessionId: SessionId, seq: number, index: number, action: PresentedAction = 'open', application?: string,
+ ): Promise {
+ return this.openUrl(presentedFileUrl(sessionId, seq, index), action, application)
}
/**
@@ -36,20 +42,24 @@ export class PresentedOpenController {
* @param sessionId - viewed Session.
* @param seq - durable workspace/changes event sequence.
* @param index - original file index within that event.
- * @returns after the Host acknowledges opening or the error state is published.
+ * @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.
*/
- openChanged(sessionId: SessionId, seq: number, index: number): Promise {
- return this.openUrl(changedFileUrl(sessionId, seq, index), 'open')
+ openChanged(
+ sessionId: SessionId, seq: number, index: number, action: PresentedAction = 'open', application?: string,
+ ): Promise {
+ return this.openUrl(changedFileUrl(sessionId, seq, index), action, application)
}
- private async openUrl(url: string, action: PresentedAction): Promise {
+ private async openUrl(url: string, action: PresentedAction, application?: string): Promise {
const phase = this.state.getSnapshot()[url]
- if (this.lifetime.signal.aborted || phase === 'opening' || phase === 'revealing') return
+ if (this.lifetime.signal.aborted || phase === 'opening' || phase === 'revealing') return null
this.state.update((state) => { state[url] = action === 'open' ? 'opening' : 'revealing' })
- const task = this.request(url, action)
+ const task = this.request(url, action, application)
this.pending.add(task)
try {
- await task
+ return await task
} finally {
this.pending.delete(task)
}
@@ -103,16 +113,19 @@ export class PresentedOpenController {
await Promise.all(this.pending)
}
- private async request(url: string, action: PresentedAction): Promise {
+ private async request(url: string, action: PresentedAction, application?: string): Promise {
const failure = action === 'open' ? 'error' : 'revealError'
let phase: PresentedOpenPhase = action === 'open' ? 'opened' : 'revealed'
try {
- const response = await fetch(action === 'open' ? url : `${url}&action=reveal`, { method: 'POST', signal: this.lifetime.signal })
+ const target = action === 'reveal' ? `${url}&action=reveal`
+ : application === undefined ? url : `${url}&application=${encodeURIComponent(application)}`
+ const response = await fetch(target, { method: 'POST', signal: this.lifetime.signal })
if (!response.ok) phase = response.status === 422 ? 'nativeUnavailable' : failure
} catch {
// Transport failures share the retryable card state with Host open failures.
phase = failure
}
if (!this.lifetime.signal.aborted) this.state.update((state) => { state[url] = phase })
+ return phase === 'opened' || phase === 'revealed' ? null : action === 'reveal' ? 'revealError' : 'openError'
}
}
diff --git a/packages/client/ui-deliverables/src/present-open.ts b/packages/client/ui-deliverables/src/present-open.ts
index 56b88f0651..6023625644 100644
--- a/packages/client/ui-deliverables/src/present-open.ts
+++ b/packages/client/ui-deliverables/src/present-open.ts
@@ -34,12 +34,12 @@ export function registerPresentOpen(ctx: Context): void {
await Promise.allSettled(pending)
})
const routes = [
- [PRESENT_OPEN_PATH, 'POST', handlePresentOpen], [CHANGES_OPEN_PATH, 'POST', handleChangesOpen], [CHANGES_DIFF_PATH, 'GET', handleChangesDiff],
+ [PRESENT_OPEN_PATH, ['GET', 'POST'], handlePresentOpen], [CHANGES_OPEN_PATH, ['GET', 'POST'], handleChangesOpen], [CHANGES_DIFF_PATH, ['GET'], handleChangesDiff],
] as const
- for (const [path, method, handler] of routes) {
+ for (const [path, methods, handler] of routes) {
ctx.connection.fetch.register({
path,
- methods: [method],
+ methods: [...methods],
requestBody: 'buffered',
fetch: (request) => {
const task = handler(ctx, new Request(request, {
@@ -91,7 +91,13 @@ async function openVerified(ctx: Context, request: Request, path: string, action
return new Response('Path has no verified Host path.', { status: 422 })
}
request.signal.throwIfAborted()
- await ctx.sessionController.openWorkspacePath({ path, ...(action === 'reveal' ? { action } : {}) }, request.signal)
+ if (request.method === 'GET') {
+ return Response.json(await ctx.sessionController.workspacePathApplications({ path }, request.signal),
+ { headers: { 'cache-control': 'no-store' } })
+ }
+ const application = new URL(request.url).searchParams.get('application')
+ await ctx.sessionController.openWorkspacePath({ path, ...(action === 'reveal' ? { action }
+ : application === null ? {} : { application }) }, request.signal)
return new Response(null, { status: 204, headers: { 'cache-control': 'no-store' } })
}
@@ -159,6 +165,8 @@ async function handleChangesDiff(ctx: Context, request: Request): Promise {
+ const action = new URL(request.url).searchParams.get('action') ?? 'open'
+ if (action !== 'open' && action !== 'reveal') return new Response('Invalid file action.', { status: 400 })
const coordinates = changedFileCoordinates(request)
if (coordinates instanceof Response) return coordinates
const { id, seq, index } = coordinates
@@ -171,7 +179,7 @@ async function handleChangesOpen(ctx: Context, request: Request): Promise ({ opened: true as const }))
- ctx.provide('sessionController', { openWorkspacePath: opener, workspaceDesktop: () => ({ name: 'desktop', available: true, fileManager: 'finder' }) } as never)
+ const applications = vi.fn(async () => [{ id: 'player', name: 'Player', default: true, icon: null }])
+ ctx.provide('sessionController', { workspacePathApplications: applications, openWorkspacePath: opener, workspaceDesktop: () => ({ name: 'desktop', available: true, fileManager: 'finder' }) } as never)
const connection = new HostConnectionService(ctx, [], {} as BrowserAuth)
await ctx.plugin({
inject: ['connection', 'sessionQuery', 'sessionController', 'workspaceFiles', 'fs', 'sandboxPolicy', 'workspaceChanges'],
@@ -71,7 +72,7 @@ async function fixture() {
const open = (query = '?sessionId=owner&seq=9&index=0') => handler.fetch(new Request(`http://localhost${CHANGES_OPEN_PATH}${query}`, { method: 'POST' }))
const read = (query = '?sessionId=owner&seq=9') => handler.fetch(new Request(`http://localhost${CHANGED_FILES_PATH}${query}`))
const compare = (query = '?sessionId=owner&seq=9&index=0') => handler.fetch(new Request(`http://localhost${CHANGES_DIFF_PATH}${query}`))
- return { root, cwd, ctx, data, readEvent, open, read, compare, comparison, diff, opener, outside, summary }
+ return { handler, applications, root, cwd, ctx, data, readEvent, open, read, compare, comparison, diff, opener, outside, summary }
}
describe('change summary route', () => {
@@ -199,3 +200,19 @@ describe('changed files native open route', () => {
expect(isChangesEvent(null)).toBe(false)
})
})
+
+
+it('queries handlers only after file authorization and forwards explicit application choices', async () => {
+ const { handler, applications, opener, open, ctx } = await fixture()
+ const url = 'http://localhost/api/changes.open?sessionId=owner&seq=9&index=0'
+ const response = await handler.fetch(new Request(url))
+ expect(response.status).toBe(200)
+ expect(await response.json()).toEqual([{ id: 'player', name: 'Player', default: true, icon: null }])
+ expect(opener).not.toHaveBeenCalled()
+ expect((await open('?sessionId=owner&seq=9&index=0&application=player')).status).toBe(204)
+ expect(opener.mock.lastCall?.[0]).toMatchObject({ application: 'player' })
+ expect((await handler.fetch(new Request(url.replace('index=0', 'index=999')))).status).toBe(404)
+ vi.spyOn(ctx.fs, 'processPathFromHostPath').mockReturnValue(undefined)
+ expect((await handler.fetch(new Request(url))).status).toBe(422)
+ expect(applications).toHaveBeenCalledOnce()
+})
diff --git a/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx b/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx
index c6cb256373..65ead43368 100644
--- a/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx
+++ b/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx
@@ -6,6 +6,7 @@
* registrations' fiber-teardown removal (HMR safety) against the real
* SlotRegistry.
*/
+import { renderFileActions } from './file-actions.tsx'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import { Context } from '@deepseek-ai/cordis'
import { cleanup, fireEvent, render, within } from '@testing-library/react'
@@ -41,6 +42,7 @@ function openProps(controller = new PresentedOpenController(), summaries = new C
controller.host.set({ name: 'desktop', available: true, fileManager: 'finder' })
const sessions: SessionListState = { ids: [], byId: {}, phase: 'ready', projectionsBySession: {}, jobsBySession: {} }
return {
+ renderSlot: renderFileActions,
useShowCodeDiff: (select: (value: boolean) => T): T => select(true),
useSessions: (select: (state: SessionListState) => T): T => select(sessions),
reloadPresentedHost: vi.fn(() => controller.loadHost()),
@@ -494,7 +496,7 @@ describe('ChangedFiles card', () => {
controller = new PresentedOpenController(), locale = en, matched = { changes, presented: [] as never[] }, summaries = servedStore(),
) {
const props = openProps(controller, summaries)
- props.openChanged.mockResolvedValue(undefined)
+ props.openChanged.mockResolvedValue(null)
const openFile = vi.fn<(path: string) => void>()
const view = render()
return { props, openFile, view }
@@ -845,7 +847,7 @@ describe('presented files', () => {
const owner = tailOwner(deliverablesOf(value), 3, preview)
const matched = selectDeliverables(owner)!
const props = openProps()
- props.openPresented.mockResolvedValue(undefined)
+ props.openPresented.mockResolvedValue(null)
const view = render()
expect(view.container.querySelectorAll('[data-presented-file]')).toHaveLength(4)
const expand = view.getByRole('button', { name: 'Show all 8 delivered files' })
@@ -855,12 +857,10 @@ describe('presented files', () => {
expect(view.getByRole('button', { name: 'Collapse delivered files' }).getAttribute('aria-expanded')).toBe('true')
expect(view.queryByRole('link')).toBeNull()
fireEvent.click(view.getByRole('button', { name: 'Preview report-0.docx in sidebar' }))
- fireEvent.click(view.getByRole('button', { name: 'Open report-0.docx in sidebar' }))
- expect(preview).toHaveBeenCalledTimes(2)
+ expect(preview).toHaveBeenCalledTimes(1)
expect(preview).toHaveBeenLastCalledWith('report-0.docx')
- fireEvent.click(view.getByRole('button', { name: 'More file actions for report-0.docx' }))
- fireEvent.click(view.getByRole('menuitem', { name: 'Open in default app' }))
- expect(props.openPresented).toHaveBeenCalledWith('child-session', 2, 0, 'open')
+ fireEvent.click(view.getAllByRole('button', { name: 'Native file action' })[0]!)
+ expect(props.openPresented).toHaveBeenCalledWith('child-session', 2, 0, 'open', undefined)
fireEvent.click(view.getByRole('button', { name: 'Collapse delivered files' }))
expect(view.container.querySelectorAll('[data-presented-file]')).toHaveLength(4)
expect(view.container.querySelector('[data-changed-files]')).toBeNull()
@@ -934,7 +934,7 @@ it.each(['opening', 'opened', 'error'] as const)('shows the %s state and permits
{ path: 'report.txt', seq: 2, index: 0 },
] }} openFile={() => {}} sessionId={SessionId('session')} t={makeTranslate(en)} />)
expect(view.getByText(en[`presented.${phase}`])).toBeTruthy()
- expect((view.getByRole('button', { name: 'More file actions for report.txt' }) as HTMLButtonElement).disabled).toBe(phase === 'opening')
+ expect((view.getByRole('button', { name: 'Native file action' }) as HTMLButtonElement).disabled).toBe(phase === 'opening')
})
diff --git a/packages/client/ui-deliverables/tests/file-actions.tsx b/packages/client/ui-deliverables/tests/file-actions.tsx
new file mode 100644
index 0000000000..964b7b88b1
--- /dev/null
+++ b/packages/client/ui-deliverables/tests/file-actions.tsx
@@ -0,0 +1,8 @@
+/** Capture delivery action ownership without duplicating the opening-control implementation. */
+import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
+import type {} from '../src/client/index.ts'
+
+/** Slot renderer exposing the owning route and gesture for component tests. */
+export const renderFileActions: PropsRenderSlots<'deliverables.file.actions'>['renderSlot'] = (_name, props) =>
+ props.available ? { void props.onAction('open') }}>Native file action : null
diff --git a/packages/client/ui-deliverables/tests/present-open.host.spec.ts b/packages/client/ui-deliverables/tests/present-open.host.spec.ts
index 072529cdde..b4e80a0b0e 100644
--- a/packages/client/ui-deliverables/tests/present-open.host.spec.ts
+++ b/packages/client/ui-deliverables/tests/present-open.host.spec.ts
@@ -46,7 +46,8 @@ async function fixture() {
})
ctx.provide('sessionQuery', { readEvent } as never)
const opener = vi.fn(async (_request: { path: string; action?: 'reveal' }, _signal: AbortSignal) => ({ opened: true as const }))
- ctx.provide('sessionController', { resolveAgent, openWorkspacePath: opener, workspaceDesktop: () => ({ name: 'desktop', available: true, fileManager: 'finder' }) } as never)
+ const applications = vi.fn(async () => [{ id: 'player', name: 'Player', default: true, icon: null }])
+ ctx.provide('sessionController', { workspacePathApplications: applications, resolveAgent, openWorkspacePath: opener, workspaceDesktop: () => ({ name: 'desktop', available: true, fileManager: 'finder' }) } as never)
const connection = new HostConnectionService(ctx, [], {} as BrowserAuth)
const fiber = ctx.plugin({ inject: ['connection', 'sessionQuery', 'sessionController', 'workspaceFiles', 'fs', 'sandboxPolicy'], apply: registerPresentOpen })
await fiber
@@ -54,7 +55,7 @@ async function fixture() {
const open = (query = '?sessionId=owner&seq=7&index=0', signal?: AbortSignal) => handler.fetch(new Request(
`http://localhost${PRESENT_OPEN_PATH}${query}`, { method: 'POST', signal: signal ?? null },
))
- return { root, cwd, ctx, fiber, file, session, readEvent, open, opener, handler, resolveAgent }
+ return { applications, root, cwd, ctx, fiber, file, session, readEvent, open, opener, handler, resolveAgent }
}
describe('Presented workspace file native open route', () => {
@@ -233,3 +234,19 @@ it('uses the deployment workspace root when the viewed Session has no cwd', asyn
expect((await open()).status).toBe(204)
expect(opener.mock.lastCall?.[0].path).toBe(await realpath(join(cwd, file.path)))
})
+
+
+it('queries handlers only after file authorization and forwards explicit application choices', async () => {
+ const { handler, applications, opener, open, ctx } = await fixture()
+ const url = 'http://localhost/api/present.open?sessionId=owner&seq=7&index=0'
+ const response = await handler.fetch(new Request(url))
+ expect(response.status).toBe(200)
+ expect(await response.json()).toEqual([{ id: 'player', name: 'Player', default: true, icon: null }])
+ expect(opener).not.toHaveBeenCalled()
+ expect((await open('?sessionId=owner&seq=7&index=0&application=player')).status).toBe(204)
+ expect(opener.mock.lastCall?.[0]).toMatchObject({ application: 'player' })
+ expect((await handler.fetch(new Request(url.replace('index=0', 'index=999')))).status).toBe(404)
+ vi.spyOn(ctx.fs, 'processPathFromHostPath').mockReturnValue(undefined)
+ expect((await handler.fetch(new Request(url))).status).toBe(422)
+ expect(applications).toHaveBeenCalledOnce()
+})
diff --git a/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx b/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx
index 28207b5a29..62a017f33a 100644
--- a/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx
+++ b/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx
@@ -13,66 +13,9 @@ const props = () => ({
host: { name: 'remote-desktop', available: true, fileManager: 'finder' as const },
phase: undefined,
onPreview: vi.fn(),
- onAction: vi.fn(),
+ actions: Native file action,
t: makeTranslate(en),
})
-
-it.each([
- ['finder', 'Show in Finder'], ['explorer', 'Show in File Explorer'], ['directory', 'Open containing folder'],
-] as const)('uses the Host %s action and closes the menu after selection', (fileManager, label) => {
- const p = props()
- const view = render()
- fireEvent.click(view.getByRole('button', { name: 'More file actions for out/report.pdf' }))
- fireEvent.click(view.getByRole('menuitem', { name: new RegExp(label) }))
- expect(p.onAction).toHaveBeenCalledWith('reveal')
- expect(view.queryByRole('menu')).toBeNull()
- fireEvent.click(view.getByRole('button', { name: 'More file actions for out/report.pdf' }))
- fireEvent.click(view.getByRole('menuitem', { name: /Open in default app/ }))
- expect(p.onAction).toHaveBeenLastCalledWith('open')
- expect(p.onAction).toHaveBeenCalledTimes(2)
-})
-
-it('dismisses the menu with Escape or an outside click without launching anything', () => {
- const p = props()
- const view = render()
- const trigger = view.getByRole('button', { name: 'More file actions for out/report.pdf' })
- fireEvent.click(trigger)
- fireEvent.keyDown(document, { key: 'Escape' })
- expect(view.queryByRole('menu')).toBeNull()
- fireEvent.click(trigger)
- fireEvent.pointerDown(document.body)
- expect(view.queryByRole('menu')).toBeNull()
- expect(p.onAction).not.toHaveBeenCalled()
-})
-
-it.each(['opening', 'revealing'] as const)('keeps sidebar previews available while the native action is %s', (phase) => {
- const p = props()
- const view = render()
- expect((view.getByRole('button', { name: 'More file actions for out/report.pdf' }) as HTMLButtonElement).disabled).toBe(true)
- fireEvent.click(view.getByRole('button', { name: 'Preview out/report.pdf in sidebar' }))
- fireEvent.click(view.getByRole('button', { name: 'Open out/report.pdf in sidebar' }))
- expect(p.onPreview).toHaveBeenCalledTimes(2)
- expect(p.onAction).not.toHaveBeenCalled()
-})
-
-it('keeps the native menu disabled until a desktop is available', () => {
- const p = props()
- const view = render()
- expect((view.getByRole('button', { name: 'More file actions for out/report.pdf' }) as HTMLButtonElement).disabled).toBe(true)
- expect((view.getByRole('button', { name: 'Open out/report.pdf in sidebar' }) as HTMLButtonElement).disabled).toBe(false)
- view.rerender()
- expect((view.getByRole('button', { name: 'More file actions for out/report.pdf' }) as HTMLButtonElement).disabled).toBe(true)
-})
-
-it('opens the right sidebar from either the card or its primary button', () => {
- const p = props()
- const view = render()
- fireEvent.click(view.getByRole('button', { name: 'Preview out/report.pdf in sidebar' }))
- fireEvent.click(view.getByRole('button', { name: 'Open out/report.pdf in sidebar' }))
- expect(p.onPreview).toHaveBeenCalledTimes(2)
- expect(p.onAction).not.toHaveBeenCalled()
-})
-
it('localizes reveal failures and accurately reports a directory-only action', () => {
const p = props()
const view = render()
@@ -84,36 +27,12 @@ it('localizes reveal failures and accurately reports a directory-only action', (
})
-it('supports keyboard selection and returns focus to the trigger on Escape', () => {
- const view = render()
- const trigger = view.getByRole('button', { name: 'More file actions for out/report.pdf' })
- fireEvent.click(trigger)
- const items = view.getAllByRole('menuitem')
- expect(document.activeElement).toBe(items[0])
- fireEvent.keyDown(document.activeElement!, { key: 'ArrowDown' })
- expect(document.activeElement).toBe(items[1])
- fireEvent.keyDown(document.activeElement!, { key: 'ArrowDown' })
- expect(document.activeElement).toBe(items[0])
- fireEvent.keyDown(document.activeElement!, { key: 'End' })
- expect(document.activeElement).toBe(items[1])
- fireEvent.keyDown(document.activeElement!, { key: 'ArrowUp' })
- expect(document.activeElement).toBe(items[0])
- fireEvent.keyDown(document.activeElement!, { key: 'Home' })
- expect(document.activeElement).toBe(items[0])
- fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
- expect(document.activeElement).toBe(trigger)
-})
-
-
it('shows the basename while retaining the full location for hover and actions', () => {
const p = props()
const path = '/work/reports/result.pdf'
const view = render()
expect(view.getByTitle(path)).toBeTruthy()
expect(view.getByText('result.pdf')).toBeTruthy()
- fireEvent.click(view.getByRole('button', { name: `More file actions for ${path}` }))
- fireEvent.click(view.getByRole('menuitem', { name: 'Open in default app' }))
- expect(p.onAction).toHaveBeenCalledWith('open')
view.rerender()
expect(view.getByTitle('/work/out/report.pdf')).toBeTruthy()
expect(view.getByText('report.pdf')).toBeTruthy()
@@ -130,31 +49,6 @@ it.each([
})
-it('does not reopen a menu after a shared native request settles', () => {
- const p = props()
- const view = render()
- fireEvent.click(view.getByRole('button', { name: 'More file actions for out/report.pdf' }))
- expect(view.getByRole('menu')).toBeTruthy()
- view.rerender()
- expect(view.queryByRole('menu')).toBeNull()
- view.rerender()
- expect(view.queryByRole('menu')).toBeNull()
-})
-
-it('keeps focus on the available preview button after selecting a native action', () => {
- const p = props()
- const view = render()
- fireEvent.click(view.getByRole('button', { name: 'More file actions for out/report.pdf' }))
- const item = view.getByRole('menuitem', { name: 'Show in Finder' })
- item.focus()
- fireEvent.click(item)
- view.rerender()
- const preview = view.getByRole('button', { name: 'Open out/report.pdf in sidebar' })
- expect(document.activeElement).toBe(preview)
- view.rerender()
- expect(document.activeElement).toBe(preview)
-})
-
it.each([en, zh])('distinguishes directory-only progress and errors in each locale', (dictionary) => {
const p = { ...props(), t: makeTranslate(dictionary) }
const view = render()
@@ -164,3 +58,15 @@ it.each([en, zh])('distinguishes directory-only progress and errors in each loca
view.rerender()
expect(view.getByText(dictionary['presented.directoryError'])).toBeTruthy()
})
+
+
+it('renders the supplied action independently from the card preview', () => {
+ const p = props()
+ const native = vi.fn()
+ const view = render(Native file action} />)
+ fireEvent.click(view.getByRole('button', { name: 'Native file action' }))
+ expect(native).toHaveBeenCalledOnce()
+ expect(p.onPreview).not.toHaveBeenCalled()
+ fireEvent.click(view.getByRole('button', { name: 'Preview out/report.pdf in sidebar' }))
+ expect(p.onPreview).toHaveBeenCalledOnce()
+})
diff --git a/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx b/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx
index 9cdc89ec65..7d412a09cc 100644
--- a/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx
+++ b/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx
@@ -1,5 +1,6 @@
// @vitest-environment jsdom
/** The review tab type: its addresses, its store, the row pairing of the split view, and the states its body draws. */
+import { renderFileActions } from './file-actions.tsx'
import { useSyncExternalStore } from 'react'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -158,11 +159,17 @@ describe('ReviewTab', () => {
loadChangesSummary: vi.fn(() => Promise.resolve()),
loadChangesDiff: vi.fn(() => Promise.resolve()),
reloadPresentedHost: vi.fn(() => Promise.resolve()),
- openChanged: vi.fn(() => Promise.resolve()),
+ openChanged: vi.fn(() => Promise.resolve(null)),
+ }
+ const sessions: SessionListState = {
+ ids: [SESSION],
+ byId: { [SESSION]: { id: SESSION, displayTitle: 'Workspace', cwd: options.cwd ?? '/work/app',
+ running: false, retainedBy: {}, blank: false, updatedAt: 0 } },
+ phase: 'ready', projectionsBySession: {}, jobsBySession: {},
}
- const sessions = { byId: { [SESSION]: { cwd: options.cwd ?? '/work/app' } } } as unknown as SessionListState
const navigation = { address: options.address ?? ADDRESS, params: options.params, revision: options.revision ?? 1 }
const runtime = {
+ renderSlot: renderFileActions,
useTabInfo: () => ({
sidebar: { expanded: true, fullscreen: false }, panel: { id: 'pane-1' },
tab: { id: TAB, kind: 'changes-review', contentId: options.address ?? ADDRESS, title: 'Review', visible: true, navigation, signal: aborter.signal, actions: tabActions },
@@ -177,7 +184,7 @@ describe('ReviewTab', () => {
usePresentedHost: hookOf(controller.host),
t: makeTranslate(options.locale ?? en),
...injected,
- } as unknown as ReviewTabProps
+ } as ReviewTabProps
const view = render()
return {
view, injected, tabActions, store, aborter, summaries, diffs, controller,
@@ -313,19 +320,18 @@ describe('ReviewTab', () => {
expect(view.container.querySelector('[data-review-tool="open-file"] svg')?.getAttribute('width')).toBe('12')
fireEvent.click(view.getByRole('button', { name: 'Open ~/out/big.bin in sidebar' }))
expect(tabActions.openResource).toHaveBeenCalledWith(fileAddressFor(SESSION, '/work/app', '/tmp/out/big.bin'))
- fireEvent.click(view.getByRole('button', { name: 'Open ~/out/big.bin in default app' }))
- expect(injected.openChanged).toHaveBeenCalledWith('viewed', 5, 1)
+ fireEvent.click(view.getByRole('button', { name: 'Native file action' }))
+ expect(injected.openChanged).toHaveBeenCalledWith('viewed', 5, 1, 'open', undefined)
act(() => { controller.state.set({ 'api/changes.open?sessionId=viewed&seq=5&index=1': 'opening' }) })
- expect((view.getByRole('button', { name: 'Open ~/out/big.bin in default app' }) as HTMLButtonElement).disabled).toBe(true)
+ expect((view.getByRole('button', { name: 'Native file action' }) as HTMLButtonElement).disabled).toBe(true)
act(() => { controller.state.set({ 'api/changes.open?sessionId=viewed&seq=5&index=1': 'error' }) })
- expect(view.getByRole('button', { name: 'Open ~/out/big.bin in default app' }).hasAttribute('data-error')).toBe(true)
act(() => { controller.state.set({ 'api/changes.open?sessionId=viewed&seq=5&index=1': 'nativeUnavailable' }) })
- expect(view.queryByRole('button', { name: 'Open ~/out/big.bin in default app' })).toBeNull()
+ expect(view.queryByRole('button', { name: 'Native file action' })).toBeNull()
act(() => {
controller.state.set({})
controller.host.set({ name: 'server', available: false, fileManager: null })
})
- expect(view.queryByRole('button', { name: 'Open ~/out/big.bin in default app' })).toBeNull()
+ expect(view.queryByRole('button', { name: 'Native file action' })).toBeNull()
expect(view.getByText(en['changes.oversized'])).toBeTruthy()
})
diff --git a/packages/client/ui-open-in-app/README.i18n.yaml b/packages/client/ui-open-in-app/README.i18n.yaml
index a52f9a0e85..9f87aff18b 100644
--- a/packages/client/ui-open-in-app/README.i18n.yaml
+++ b/packages/client/ui-open-in-app/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-open-in-app/README.md
-README.md: 035315fa4c770b51614c7cf90064bed9ee00951e
-README.zh.md: 248ed5335a30e5101fb10aa95a7a77a6eb73725e
+README.md: 010f86f19297db33511b40129d6caf36c71e49f9
+README.zh.md: ec0fe7741356c71b73f4d5de6f5dc0bb1b3be887
diff --git a/packages/client/ui-open-in-app/README.md b/packages/client/ui-open-in-app/README.md
index 035315fa4c..010f86f192 100644
--- a/packages/client/ui-open-in-app/README.md
+++ b/packages/client/ui-open-in-app/README.md
@@ -45,7 +45,7 @@ File associations refresh when the file changes or its menu opens; cancelled que
The plugin registers the split button on `conversation.session.header.utilities` through the standard slot/inject currency and 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 both stores 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. In-flight launches are guarded by a ref — repeat clicks and menu picks during a launch are ignored whole (a pick would otherwise persist a choice the gesture never opened) — and the busy/error dress is timer-driven around the `launch` promise.
-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. 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, 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`; their authenticated routes retain Session file authorization. A failed or unavailable file query therefore needs no platform-specific UI implementation.
diff --git a/packages/client/ui-open-in-app/README.zh.md b/packages/client/ui-open-in-app/README.zh.md
index 248ed5335a..ec0fe77413 100644
--- a/packages/client/ui-open-in-app/README.zh.md
+++ b/packages/client/ui-open-in-app/README.zh.md
@@ -45,7 +45,7 @@ kind: "package-reference"
插件通过标准 slot/inject 机制把分体按钮注册到 `conversation.session.header.utilities`,并以一个 effect 注册 `open-in-app` 词典。一个页面生命周期的 controller([`src/client/controller.ts`](src/client/controller.ts))拥有每页一次的可用性读取、持久化选择的 snapshot store 与启动 POST;组件经 inject 的 `hooks` 隔间接收两个 store,因此所有会话头部共享同一份事实。文档相对的路由形式与 wire 载荷类型来自主机包的浏览器安全子路径 `@deepseek-ai/dsh-host-open-in-app/shared`。飞行中的启动由 ref 守卫——启动期间的重复点击与菜单选择被整体忽略(否则会持久化一个该手势从未打开的选择)——busy/error 视觉由围绕 `launch` promise 的定时器驱动。
-目录和文件适配器把应用信息与操作交给 [`OpenTargetButton`](src/client/OpenTargetButton.tsx),由它统一管理菜单顺序、默认标记、图标、尺寸和操作反馈。文件标题栏和空态共用 `FileOpenTarget`,`OpenPathInjected.applications` 通过 [`open-path.ts`](src/client/open-path.ts) 查询 `session.workspacePathApplications`。打开操作使用 `session.openWorkspacePath`,Host 在启动前重新验证指定的关联应用。目录适配器继续使用已有的应用列表路由,文件查询失败或不可用时无需增加平台专用的界面实现。
+目录和文件适配器把应用信息与操作交给 [`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` 为交付卡片和变更对比页提供同一控件,其认证路由保留会话文件校验。目录适配器继续使用已有的应用列表路由,文件查询失败或不可用时无需增加平台专用的界面实现。
diff --git a/packages/client/ui-open-in-app/package.json b/packages/client/ui-open-in-app/package.json
index 3b9e784afb..fde52fc3e9 100644
--- a/packages/client/ui-open-in-app/package.json
+++ b/packages/client/ui-open-in-app/package.json
@@ -70,6 +70,8 @@
"@deepseek-ai/dsh-host-open-in-app": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@types/react": "~18.3.1",
- "react": "^18.2.0"
+ "react": "^18.2.0",
+ "@deepseek-ai/dsh-client-ui-deliverables": "workspace:^",
+ "@deepseek-ai/dsh-native-command": "workspace:^"
}
}
diff --git a/packages/client/ui-open-in-app/src/client/FileRouteAction.tsx b/packages/client/ui-open-in-app/src/client/FileRouteAction.tsx
new file mode 100644
index 0000000000..287b4b3956
--- /dev/null
+++ b/packages/client/ui-open-in-app/src/client/FileRouteAction.tsx
@@ -0,0 +1,37 @@
+/** 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 {} 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 type { NS } from './locales.ts'
+
+async function queryRoute(url: string, signal: AbortSignal): Promise {
+ try {
+ const response = await fetch(url, { signal })
+ if (!response.ok) return null
+ return parseNativeFileApplications(await response.json())
+ } catch (_error) {
+ // Unavailable routes and malformed responses leave file reveal as the fallback.
+ return null
+ }
+}
+
+/**
+ * Render file actions without bypassing the owning Session's authorization route.
+ * @param props - authenticated route, desktop availability, and native gesture callback.
+ * @returns the shared compact control, or null without a desktop.
+ */
+export function FileRouteAction(
+ props: Pick, 'actionUrl' | 'available' | 'pending' | 'onAction'> & PropsLocale,
+) {
+ const association = useFileApplications(props.actionUrl, queryRoute, props.available)
+ if (!props.available) return null
+ return app.default)?.id} failed={association.failed}
+ loading={association.loading || 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)
+ }} />
+}
diff --git a/packages/client/ui-open-in-app/src/client/OpenPathAction.tsx b/packages/client/ui-open-in-app/src/client/OpenPathAction.tsx
index 869aa8e041..2b6008d9b1 100644
--- a/packages/client/ui-open-in-app/src/client/OpenPathAction.tsx
+++ b/packages/client/ui-open-in-app/src/client/OpenPathAction.tsx
@@ -1,11 +1,12 @@
/** File association adapter for the shared file/directory opening control. */
-import { useEffect, useState } from 'react'
+import { useEffect } from 'react'
import type { ReactNode } from 'react'
import type { SessionWorkspacePathApplication } from '@deepseek-ai/dsh-api-session-controller/types'
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 { useFileApplications } from './file-applications.ts'
import { OpenTargetButton } from './OpenTargetButton.tsx'
import type { NS } from './locales.ts'
@@ -37,30 +38,18 @@ type FileOpenTargetProps = Pick value)
- const [revision, setRevision] = useState(0)
- const [association, setAssociation] = useState<{
- path: string
- apps: readonly SessionWorkspacePathApplication[] | null
- } | null>(null)
+ const association = useFileApplications(props.absolutePath, props.applications, desktop === true)
useEffect(() => {
if (desktop === null) void props.loadDesktop()
}, [desktop, props.loadDesktop])
- useEffect(() => {
- if (desktop !== true) return
- const controller = new AbortController()
- void props.applications(props.absolutePath, controller.signal).then((apps) => {
- if (!controller.signal.aborted) setAssociation({ path: props.absolutePath, apps })
- })
- return () => { controller.abort() }
- }, [desktop, props.absolutePath, props.applications, revision])
if (desktop !== true) return null
- const apps = association?.path === props.absolutePath ? association.apps : null
+ const { apps } = association
return (
app.default)?.id}
- loading={association?.path !== props.absolutePath} failed={association?.path === props.absolutePath && apps === null}
+ 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={() => { setRevision(value => value + 1) }}
+ refresh={association.refresh}
execute={operation => props.openPath(props.absolutePath, operation.kind === 'reveal' ? 'reveal' : 'open',
operation.kind === 'application' ? operation.id : undefined)}
/>
diff --git a/packages/client/ui-open-in-app/src/client/file-applications.ts b/packages/client/ui-open-in-app/src/client/file-applications.ts
new file mode 100644
index 0000000000..00bb9331f2
--- /dev/null
+++ b/packages/client/ui-open-in-app/src/client/file-applications.ts
@@ -0,0 +1,37 @@
+/** File association reads shared by path previews and authorized delivery routes. */
+import { useEffect, useState } from 'react'
+import type { SessionWorkspacePathApplication } from '@deepseek-ai/dsh-api-session-controller/types'
+
+/**
+ * Refresh associations on target changes and explicit menu opening; discard cancelled reads.
+ * @param target - path or authenticated route identifying the current file.
+ * @param query - target-specific association reader.
+ * @param enabled - whether a native desktop is available.
+ * @returns metadata, initial loading state, failure state, and a refresh callback.
+ */
+export function useFileApplications(
+ target: string,
+ query: (target: string, signal: AbortSignal) => Promise,
+ enabled: boolean,
+) {
+ const [revision, setRevision] = useState(0)
+ const [association, setAssociation] = useState<{
+ target: string
+ apps: readonly SessionWorkspacePathApplication[] | null
+ } | null>(null)
+ useEffect(() => {
+ if (!enabled) return
+ const controller = new AbortController()
+ void query(target, controller.signal).then((apps) => {
+ if (!controller.signal.aborted) setAssociation({ target, apps })
+ })
+ return () => { controller.abort() }
+ }, [enabled, target, query, revision])
+ const apps = association?.target === target ? association.apps : null
+ return {
+ apps: apps ?? [],
+ loading: association?.target !== target,
+ failed: association?.target === target && apps === null,
+ refresh: () => { setRevision(value => value + 1) },
+ }
+}
diff --git a/packages/client/ui-open-in-app/src/client/index.ts b/packages/client/ui-open-in-app/src/client/index.ts
index 44572cdf3d..e80e58189a 100644
--- a/packages/client/ui-open-in-app/src/client/index.ts
+++ b/packages/client/ui-open-in-app/src/client/index.ts
@@ -22,6 +22,7 @@ import { OpenInAppController } from './controller.ts'
import { OpenInAppAction, type OpenInAppActionInjected } from './OpenInAppAction.tsx'
import { OpenInAppPathController } from './open-path.ts'
import { OpenPathAction, type OpenPathInjected } from './OpenPathAction.tsx'
+import { FileRouteAction } from './FileRouteAction.tsx'
import { OpenPathEmptyAction } from './OpenPathEmptyAction.tsx'
import { en, NS, zh, type OpenInAppKey } from './locales.ts'
@@ -83,4 +84,7 @@ export function apply(ctx: ClientContext): void {
locale: NS,
inject: pathInjected,
}, OpenPathEmptyAction))
+ ctx.slots.inject('deliverables.file.actions', () => ctx.slots.register({
+ name: 'deliverables.file.actions', id: 'open-in-app', locale: NS,
+ }, FileRouteAction))
}
diff --git a/packages/client/ui-open-in-app/src/client/locales.ts b/packages/client/ui-open-in-app/src/client/locales.ts
index 29845dc875..e8aedbdbf9 100644
--- a/packages/client/ui-open-in-app/src/client/locales.ts
+++ b/packages/client/ui-open-in-app/src/client/locales.ts
@@ -40,17 +40,11 @@ const PRODUCT_NAMES = {
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'open.title': '用 {app} 打开',
- 'open.error': '打开失败',
- 'menu.toggle': '选择打开方式',
- 'menu.aria': '打开方式',
'path.appDefault': '{app}(默认)',
'path.appsError': '无法获取应用列表',
'path.open': '打开',
- 'path.open.tooltip': '用默认应用打开',
'path.more': '更多打开方式',
- 'path.defaultApp': '用默认应用打开',
'path.reveal': '显示文件位置',
- 'path.unpreviewable': '用默认应用打开',
'path.openError': '打开失败,请重试',
'path.revealError': '无法显示文件位置,请重试',
...PRODUCT_NAMES,
@@ -63,17 +57,11 @@ export const zh = {
/** English dictionary, key-identical to the Chinese source of truth. */
export const en: Record = {
'open.title': 'Open in {app}',
- 'open.error': 'Failed to open',
- 'menu.toggle': 'Choose an app to open in',
- 'menu.aria': 'Open in',
'path.appDefault': '{app} (default)',
'path.appsError': 'Could not load applications',
'path.open': 'Open',
- 'path.open.tooltip': 'Open in default app',
'path.more': 'More ways to open',
- 'path.defaultApp': 'Open in default app',
'path.reveal': 'Show file location',
- 'path.unpreviewable': 'Open in default app',
'path.openError': 'Could not open. Try again.',
'path.revealError': 'Could not show the file location. Try again.',
...PRODUCT_NAMES,
diff --git a/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts b/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts
index da5f8f1c9b..e538dc4fd2 100644
--- a/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts
+++ b/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts
@@ -40,6 +40,7 @@ async function bench(): Promise<{ ctx: Context; fiber: ReturnType null)
ctx.provide('sessions', {})
@@ -67,6 +68,7 @@ describe('open-in-app browser half', () => {
const empty = ctx.slots.entries('sidebar.right.tab.document.unpreviewable')[0]
expect(header?.component).toBe(OpenPathAction)
expect(empty?.component).toBe(OpenPathEmptyAction)
+ expect(ctx.slots.entries('deliverables.file.actions')).toHaveLength(1)
expect(header?.options).toMatchObject({ id: 'open-in-app' })
const face = (header?.inject as unknown as () => OpenPathInjected)()
const emptyFace = (empty?.inject as unknown as () => OpenPathInjected)()
@@ -83,6 +85,7 @@ describe('open-in-app browser half', () => {
await fiber.dispose()
expect(ctx.slots.entries('sidebar.right.tab.document.actions').map(entry => entry.options.id)).not.toContain('open-in-app')
expect(ctx.slots.entries('sidebar.right.tab.document.unpreviewable').map(entry => entry.options.id)).not.toContain('open-in-app')
+ expect(ctx.slots.entries('deliverables.file.actions')).toHaveLength(0)
})
it('registers the header split button, and fiber teardown removes it (HMR safety)', async () => {
@@ -147,11 +150,11 @@ describe('open-in-app browser half', () => {
const { ctx, fiber } = await bench()
ctx.locale.setLocale('zh')
const translate = ctx.locale.bind(NS)
- expect(translate('menu.aria')).toBe(zh['menu.aria'])
+ expect(translate('path.more')).toBe(zh['path.more'])
ctx.locale.setLocale('en')
- expect(translate('menu.aria')).toBe(en['menu.aria'])
+ expect(translate('path.more')).toBe(en['path.more'])
await fiber.dispose()
- expect(translate('menu.aria')).not.toBe(en['menu.aria'])
+ expect(translate('path.more')).not.toBe(en['path.more'])
})
it('keeps the English dictionary key-identical to the Chinese source of truth', () => {
diff --git a/packages/client/ui-open-in-app/tests/file-route-action.client.spec.tsx b/packages/client/ui-open-in-app/tests/file-route-action.client.spec.tsx
new file mode 100644
index 0000000000..6404ec8b9e
--- /dev/null
+++ b/packages/client/ui-open-in-app/tests/file-route-action.client.spec.tsx
@@ -0,0 +1,53 @@
+// @vitest-environment jsdom
+/** Authorized file routes use the same application menu and failure feedback 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'
+
+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) })
+
+it('queries the authorized route, selects a handler, and keeps reveal last', async () => {
+ const fetcher = vi.fn(async () => Response.json(apps))
+ vi.stubGlobal('fetch', fetcher)
+ const p = props()
+ render()
+ await act(async () => {})
+ expect(fetcher).toHaveBeenCalledWith(p.actionUrl, { signal: expect.any(AbortSignal) })
+ fireEvent.mouseEnter(screen.getByRole('button', { name: 'Open in Music' }))
+ expect(await screen.findByRole('tooltip')).toHaveProperty('textContent', 'Open in 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.onAction).toHaveBeenLastCalledWith('open', undefined)
+})
+
+it.each(['http', 'transport', 'invalid'] as const)('keeps reveal available after a %s query failure', async (failure) => {
+ vi.stubGlobal('fetch', vi.fn(async () => {
+ if (failure === 'transport') throw new Error('offline')
+ return failure === 'http' ? new Response(null, { status: 404 }) : Response.json({ invalid: true })
+ }))
+ const p = props()
+ render()
+ await act(async () => {})
+ await act(async () => { fireEvent.click(screen.getByRole('button', { name: en['path.reveal'] })) })
+ expect(p.onAction).toHaveBeenCalledWith('reveal', undefined)
+})
+
+it('does not query without a desktop and honors another pending gesture', async () => {
+ const fetcher = vi.fn(async () => Response.json(apps))
+ vi.stubGlobal('fetch', fetcher)
+ const p = props()
+ const view = render()
+ expect(view.container.innerHTML).toBe('')
+ expect(fetcher).not.toHaveBeenCalled()
+ view.rerender()
+ await act(async () => {})
+ expect(screen.getByRole('button', { name: 'Open in Music' })).toHaveProperty('disabled', true)
+})
diff --git a/packages/client/ui-open-in-app/tsconfig.json b/packages/client/ui-open-in-app/tsconfig.json
index 1f0c094732..e37925c8a4 100644
--- a/packages/client/ui-open-in-app/tsconfig.json
+++ b/packages/client/ui-open-in-app/tsconfig.json
@@ -46,6 +46,12 @@
},
{
"path": "../ui-slots"
+ },
+ {
+ "path": "../ui-deliverables/tsconfig.client.json"
+ },
+ {
+ "path": "../../util/native-command"
}
]
}
diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
index 9d8b9b731d..08f13fbf40 100644
--- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
+++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
@@ -1564,6 +1564,65 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.view\', () => ctx.slots.register(\n { name: \'conversation.view\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:181',
},
+ {
+ key: 'deliverables.file.actions',
+ kind: 'list',
+ scope: 'session',
+ summary: 'Open one file through its authorized Session event coordinates.',
+ doc: 'Open one file through its authorized Session event coordinates.',
+ registerOptions: [
+ {
+ name: 'id',
+ requirement: 'required',
+ type: 'string',
+ doc: 'Your cell key. Use an id of your own: a fresh id is added beside the shipped entries, while reusing a shipped id puts you in THAT cell and replaces it. Owners that filter by id address you by it.',
+ },
+ {
+ name: 'order',
+ requirement: 'optional',
+ type: 'number',
+ doc: 'Position among the entries, ascending (default 0).',
+ },
+ {
+ name: 'label',
+ requirement: 'optional',
+ type: 'string | (() => string)',
+ doc: 'Display text where the owner projects one (nav rows, tabs). A thunk is re-read on every projection, so localized text follows the active locale without re-registering.',
+ },
+ ],
+ 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',
+ ],
+ ownerPropsReferences: [],
+ standardProps: [
+ 'useResource: UseResource',
+ 'useWorkspaces: SnapshotSelectorHook',
+ 'usePanelInfo: UsePanelInfo',
+ 'useSessions: UseSessions',
+ 'useSessionStatus: UseSessionStatus',
+ 'useSessionRetainInfo: UseSessionRetainInfo',
+ 'useWorkspaces: SnapshotSelectorHook',
+ 'useChat: UseChat',
+ 'useConversation: UseConversation',
+ 'useInput: SnapshotSelectorHook',
+ 'inputActions: InputActions',
+ 'useSession: SessionSnapshotSelector',
+ 'sessionId: SessionId',
+ 'useProjection: UseProjection',
+ 'useTrajectory: UseTrajectory',
+ ],
+ keyDomain: '',
+ hookContext: '',
+ slotInject: '',
+ declaredBy: 'an entry in \'conversation.chat.turnTail\' (client-ui-deliverables), so it exists while that entry is mounted',
+ occupants: [
+ 'client-ui-open-in-app FileRouteAction id \'open-in-app\'',
+ ],
+ replaceRisk: 'none',
+ example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'deliverables.file.actions\', () => ctx.slots.register(\n { name: \'deliverables.file.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
+ source: 'packages/client/ui-deliverables/src/client/file-actions.ts:8',
+ },
{
key: 'main',
kind: 'keyed',
diff --git a/packages/util/native-command/src/file-applications.ts b/packages/util/native-command/src/file-applications.ts
index 9b834ad8dd..7a9b7f9ea8 100644
--- a/packages/util/native-command/src/file-applications.ts
+++ b/packages/util/native-command/src/file-applications.ts
@@ -4,7 +4,7 @@ import { linuxFileApplications } from './file-applications-linux.ts'
import { windowsFileApplications } from './file-applications-windows.ts'
import { nativeFileManager } from './path-opener.ts'
import type { PathOpenerInternals } from './path-opener.ts'
-import type { NativeFileApplication } from './types.ts'
+import { parseNativeFileApplications, type NativeFileApplication } from './types.ts'
/** AppKit runs inside the system JXA host; paths arrive as argv, never executable source. */
const MAC_APPLICATIONS = `
@@ -67,21 +67,7 @@ async function queryFileApplications(
} else if (target.platform === 'win32') {
stdout = await windowsFileApplications(target.path, null, signal, run)
} else return []
- const value: unknown = JSON.parse(stdout)
- if (!Array.isArray(value)) throw new Error('Invalid native application list')
- const applications: NativeFileApplication[] = []
- const entries: readonly unknown[] = value
- for (const entry of entries) {
- if (typeof entry !== 'object' || entry === null
- || !('id' in entry) || !('name' in entry) || !('default' in entry) || !('icon' in entry)
- || typeof entry.id !== 'string' || entry.id.length === 0
- || typeof entry.name !== 'string' || typeof entry.default !== 'boolean'
- || !(entry.icon === null || (typeof entry.icon === 'string' && /^data:image\/(?:png|svg\+xml);base64,[A-Za-z0-9+/=]+$/.test(entry.icon)))) {
- throw new Error('Invalid native application entry')
- }
- applications.push({ id: entry.id, name: entry.name, default: entry.default, icon: entry.icon })
- }
- return applications
+ return parseNativeFileApplications(JSON.parse(stdout))
}
/**
diff --git a/packages/util/native-command/src/types.ts b/packages/util/native-command/src/types.ts
index 1e93b93bc5..5af42bb996 100644
--- a/packages/util/native-command/src/types.ts
+++ b/packages/util/native-command/src/types.ts
@@ -9,3 +9,26 @@ export interface NativeFileApplication {
/** PNG or SVG data URL, or null when the desktop supplies no icon. */
readonly icon: string | null
}
+
+/**
+ * Validate file association metadata received from a native process or authenticated Host.
+ * @param value - decoded application list.
+ * @returns validated application metadata.
+ * @throws Error for malformed entries or unsupported icon URLs.
+ */
+export function parseNativeFileApplications(value: unknown): readonly NativeFileApplication[] {
+ if (!Array.isArray(value)) throw new Error('Invalid native application list')
+ const applications: NativeFileApplication[] = []
+ const entries: readonly unknown[] = value
+ for (const entry of entries) {
+ if (typeof entry !== 'object' || entry === null
+ || !('id' in entry) || !('name' in entry) || !('default' in entry) || !('icon' in entry)
+ || typeof entry.id !== 'string' || entry.id.length === 0
+ || typeof entry.name !== 'string' || typeof entry.default !== 'boolean'
+ || !(entry.icon === null || (typeof entry.icon === 'string' && /^data:image\/(?:png|svg\+xml);base64,[A-Za-z0-9+/=]+$/.test(entry.icon)))) {
+ throw new Error('Invalid native application entry')
+ }
+ applications.push({ id: entry.id, name: entry.name, default: entry.default, icon: entry.icon })
+ }
+ return applications
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index dc16be7fbf..f4f06bf314 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -3575,6 +3575,9 @@ importers:
'@deepseek-ai/dsh-client-ui-conversation':
specifier: workspace:^
version: link:../ui-conversation
+ '@deepseek-ai/dsh-client-ui-deliverables':
+ specifier: workspace:^
+ version: link:../ui-deliverables
'@deepseek-ai/dsh-client-ui-primitives':
specifier: workspace:^
version: link:../ui-primitives
@@ -3593,6 +3596,9 @@ importers:
'@deepseek-ai/dsh-host-open-in-app':
specifier: workspace:^
version: link:../../host/open-in-app
+ '@deepseek-ai/dsh-native-command':
+ specifier: workspace:^
+ version: link:../../util/native-command
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
diff --git a/scripts/no-unknown-casts.baseline.json b/scripts/no-unknown-casts.baseline.json
index 154c57b993..9bf0476844 100644
--- a/scripts/no-unknown-casts.baseline.json
+++ b/scripts/no-unknown-casts.baseline.json
@@ -594,10 +594,6 @@
"packages/client/ui-deliverables/tests/present-open.host.spec.ts": {
"556e0d31b686cc80ef5c9562dad5534556f1b55ce1ce20491aa39eda98255364": 1
},
- "packages/client/ui-deliverables/tests/review-tab.client.spec.tsx": {
- "52e0f8e51efd9a65974af6ad6868c7e01fff0d6faeb669e9da1bf91de8a2d422": 1,
- "56f9870142343b23a01725140b72d040d1b9071dd30227271518f50cfa614b6c": 1
- },
"packages/client/ui-goal/tests/browser-plugin.client.spec.tsx": {
"04984a2ce1486250236e638e0197da0a1a840855ed3975c59130a85358c7e5fe": 1,
"5f1a0867b42cf35dac7fdf5839e3a39c2384c5b3b03142e799a36733ad96b9da": 1,