From 39bde1a5c90147b892c640170d6c6c16cca996a8 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sun, 20 Sep 2026 10:31:32 +0800 Subject: [PATCH 01/62] feat: query file applications for sidebar opening (#4562) --- packages/api/session-controller/src/index.ts | 45 ++++++++-- packages/api/session-controller/src/types.ts | 6 ++ .../src/client/OpenPathAction.module.css | 7 ++ .../src/client/OpenPathAction.tsx | 48 +++++++++-- .../client/ui-open-in-app/src/client/index.ts | 3 +- .../ui-open-in-app/src/client/locales.ts | 4 + .../ui-open-in-app/src/client/open-path.ts | 26 +++++- .../tests/browser-plugin.client.spec.ts | 1 + .../tests/open-path-action.client.spec.tsx | 1 + .../tests/open-path.client.spec.ts | 16 ++++ .../native-command/src/file-applications.ts | 86 +++++++++++++++++++ packages/util/native-command/src/index.ts | 3 + .../tests/file-applications.spec.ts | 37 ++++++++ 13 files changed, 262 insertions(+), 21 deletions(-) create mode 100644 packages/util/native-command/src/file-applications.ts create mode 100644 packages/util/native-command/tests/file-applications.spec.ts diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index b6f5f0dfa0..0f0a44339d 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -8,7 +8,7 @@ import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { errorChain } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-client-file-upload' -import { canOpenNativePath, nativeFileManager, openNativeAssociatedPath, revealNativePath } from '@deepseek-ai/dsh-native-command' +import { canOpenNativePath, nativeFileManager, nativeFileApplications, openNativeFileApplication, openNativeAssociatedPath, revealNativePath, type NativeFileApplication } from '@deepseek-ai/dsh-native-command' import type { SessionId } from '@deepseek-ai/dsh-session' import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence' import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' @@ -83,6 +83,9 @@ export interface Config { export interface SessionControllerInternals { /** Native default-application handoff. */ readonly openPath?: (path: string, signal: AbortSignal) => Promise + /** Native file-association query and explicit application handoff. */ + readonly fileApplications?: typeof nativeFileApplications + readonly openFileApplication?: typeof openNativeFileApplication /** Native file-manager handoff. */ readonly revealPath?: (path: string, signal: AbortSignal) => Promise /** Native handoff availability probe. */ @@ -115,6 +118,8 @@ export class SessionController extends TypertRemoteService { private readonly history: SessionHistoryController private readonly listState: ApiSessionList private readonly openPath: (path: string, signal: AbortSignal) => Promise + private readonly fileApplications: typeof nativeFileApplications + private readonly openFileApplication: typeof openNativeFileApplication private readonly revealPath: (path: string, signal: AbortSignal) => Promise private readonly canOpenPath: () => boolean private readonly promotions = new Set>() @@ -142,6 +147,8 @@ export class SessionController extends TypertRemoteService { }, 'session-controller.promotions') this.history = new SessionHistoryController(ctx, (observation) => { this.promote(observation) }) this.listState = new ApiSessionList(ctx) + this.fileApplications = internals.fileApplications ?? nativeFileApplications + this.openFileApplication = internals.openFileApplication ?? openNativeFileApplication this.openPath = internals.openPath ?? openNativeAssociatedPath this.revealPath = internals.revealPath ?? revealNativePath this.canOpenPath = internals.canOpenPath @@ -318,14 +325,9 @@ export class SessionController extends TypertRemoteService { } signal.throwIfAborted() try { - const hostPath = resolve(request.path) - const { fs } = this.ctx - const mapped = fs.processPathFromHostPath(hostPath) - if (mapped === undefined || fs.processPath(await fs.resolve(mapped, { signal })) !== hostPath) { - throw new Error('Path has no verified Host path') - } - signal.throwIfAborted() + await this.verifyDesktopPath(request.path, signal) if (request.action === 'reveal') await this.revealPath(request.path, signal) + else if (request.application !== undefined) await this.openFileApplication(resolve(request.path), request.application, signal) else await this.openPath(request.path, signal) return { opened: true } } catch (error: unknown) { @@ -338,6 +340,33 @@ export class SessionController extends TypertRemoteService { } } + /** + * Query current file handlers on the serving desktop without activating an Agent. + * @param request - file path in Host filesystem syntax. + * @param signal - caller lifetime, propagated to filesystem and desktop queries. + * @returns OS application names, icons, and default selection; empty when desktop opening is unavailable. + */ + @Remote('workspacePathApplications') + async workspacePathApplications( + request: { readonly path: string }, signal: AbortSignal, + ): Promise { + if (!this.canOpenPath()) return [] + await this.verifyDesktopPath(request.path, signal) + return this.fileApplications(resolve(request.path), signal) + } + + private async verifyDesktopPath(path: string, signal: AbortSignal): Promise { + if (path.length === 0) throw new RemoteError('gateway/bad-request', 'A non-empty file path is required', {}) + signal.throwIfAborted() + const hostPath = resolve(path) + const { fs } = this.ctx + const mapped = fs.processPathFromHostPath(hostPath) + if (mapped === undefined || fs.processPath(await fs.resolve(mapped, { signal })) !== hostPath) { + throw new RemoteError('gateway/bad-request', 'Path has no verified Host path', {}) + } + signal.throwIfAborted() + } + /** * Rename one Session after explicitly resuming it. * @param request - Session identity and proposed title. diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index a925e9da83..ac155e7ab5 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -1,3 +1,4 @@ +import type { NativeFileApplication } from '@deepseek-ai/dsh-native-command' /** Browser-safe request, result, and lifecycle vocabulary for the Session Remote service. */ import type { @@ -367,6 +368,8 @@ export interface SessionCancelValue { export interface SessionOpenWorkspacePathRequest { /** File-manager navigation when requested; omission uses the default application. */ readonly action?: 'reveal' + /** Registered application path; omission preserves the operating system default. */ + readonly application?: string /** Path after best-effort Session workspace resolution, in Host filesystem syntax. */ readonly path: string } @@ -610,3 +613,6 @@ declare module '@deepseek-ai/cordis' { /** JSON-compatible projection value accepted by list consumers. */ export type SessionProjectionValue = JsonValue + +/** Application metadata returned by the serving desktop for one file. */ +export type SessionWorkspacePathApplication = NativeFileApplication diff --git a/packages/client/ui-open-in-app/src/client/OpenPathAction.module.css b/packages/client/ui-open-in-app/src/client/OpenPathAction.module.css index f23f590613..2c770905cf 100644 --- a/packages/client/ui-open-in-app/src/client/OpenPathAction.module.css +++ b/packages/client/ui-open-in-app/src/client/OpenPathAction.module.css @@ -33,6 +33,7 @@ } .main { + gap: 4px; padding: 5px 8px 5px 10px; } @@ -54,3 +55,9 @@ .chevron:disabled { cursor: default; } + +.appIcon { + width: 16px; + height: 16px; + object-fit: contain; +} 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 bc7f842386..af45eb7d1e 100644 --- a/packages/client/ui-open-in-app/src/client/OpenPathAction.tsx +++ b/packages/client/ui-open-in-app/src/client/OpenPathAction.tsx @@ -3,11 +3,12 @@ * its default application, the chevron's menu adds the file-manager reveal. * Both path controls share the gesture hook declared here. */ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import type { ReactNode } from 'react' import { IconChevronDownOutlineRegular, IconFolderOpenOutlineRegular, IconRightUpOutlineRegular, Menu, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' +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, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/client' @@ -22,7 +23,8 @@ export interface OpenPathInjected { openInAppDesktop: ObservableSnapshot } loadDesktop: () => Promise - openPath: (path: string, action: OpenInAppPathAction) => Promise + openPath: (path: string, action: OpenInAppPathAction, application?: string) => Promise + applications: (path: string, signal: AbortSignal) => Promise } /** Full props of the document-header contribution. */ @@ -48,10 +50,11 @@ export function usePathGesture({ absolutePath, useOpenInAppDesktop, loadDesktop, available: boolean pending: boolean toast: ReactNode - act: (action: OpenInAppPathAction) => void + act: (action: OpenInAppPathAction, application?: string) => void } { const desktop = useOpenInAppDesktop(value => value) const [pending, setPending] = useState(false) + const inFlight = useRef(false) const { toast, show } = useOpenFailureToast() useEffect(() => { if (desktop === null) void loadDesktop() @@ -60,11 +63,13 @@ export function usePathGesture({ absolutePath, useOpenInAppDesktop, loadDesktop, available: desktop === true, pending, toast, - act: (action) => { + act: (action, application) => { + if (inFlight.current) return + inFlight.current = true setPending(true) - void openPath(absolutePath, action).then((failure) => { + void openPath(absolutePath, action, application).then((failure) => { if (failure !== null) show(t(`path.${failure}`)) - }).finally(() => { setPending(false) }) + }).finally(() => { inFlight.current = false; setPending(false) }) }, } } @@ -78,10 +83,24 @@ export function OpenPathAction(props: OpenPathActionProps): ReactNode { const { t } = props const [menuOpen, setMenuOpen] = useState(false) const { available, pending, toast, act } = usePathGesture(props) + const [association, setAssociation] = useState<{ + path: string + apps: readonly SessionWorkspacePathApplication[] | null + } | null>(null) + useEffect(() => { + if (!available) 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() } + }, [available, props.absolutePath, props.applications, menuOpen]) + const apps = association?.path === props.absolutePath ? association.apps : null + const preferred = apps?.find(app => app.default) if (!available) return null - const run = (action: OpenInAppPathAction): void => { + const run = (action: OpenInAppPathAction, application?: string): void => { setMenuOpen(false) - act(action) + act(action, application) } return ( <> @@ -95,9 +114,19 @@ export function OpenPathAction(props: OpenPathActionProps): ReactNode { onClose={() => { setMenuOpen(false) }} items={[ { id: 'open', icon: , label: t('path.defaultApp') }, + ...(apps ?? []).map(app => ({ + id: `app:${app.id}`, + icon: app.icon === null ? : , + label: app.default ? t('path.appDefault', { app: app.name }) : app.name, + })), + ...(association?.path === props.absolutePath && apps === null + ? [{ id: 'unavailable', label: t('path.appsError'), disabled: true }] : []), { id: 'reveal', icon: , label: t('path.reveal') }, ]} - onSelect={(id) => { run(id === 'reveal' ? 'reveal' : 'open') }} + onSelect={(id) => { + if (id.startsWith('app:')) run('open', id.slice(4)) + else if (id === 'open' || id === 'reveal') run(id) + }} anchor={(
@@ -108,6 +137,7 @@ export function OpenPathAction(props: OpenPathActionProps): ReactNode { data-open-path-open onClick={() => { run('open') }} > + {preferred?.icon != null && } {t('path.open')} 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 4073eaec1f..44572cdf3d 100644 --- a/packages/client/ui-open-in-app/src/client/index.ts +++ b/packages/client/ui-open-in-app/src/client/index.ts @@ -68,7 +68,8 @@ export function apply(ctx: ClientContext): void { const pathInjected = (): OpenPathInjected => ({ hooks: { openInAppDesktop: paths.desktop }, loadDesktop: () => paths.load(), - openPath: (path, action) => paths.openPath(path, action), + openPath: (path, action, application) => paths.openPath(path, action, application), + applications: (path, signal) => paths.applications(path, signal), }) ctx.slots.inject('sidebar.right.tab.document.actions', () => ctx.slots.register({ name: 'sidebar.right.tab.document.actions', 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 6d32f759a0..bb198d640c 100644 --- a/packages/client/ui-open-in-app/src/client/locales.ts +++ b/packages/client/ui-open-in-app/src/client/locales.ts @@ -44,6 +44,8 @@ export const zh = { 'open.error': '打开失败', 'menu.toggle': '选择打开方式', 'menu.aria': '打开方式', + 'path.appDefault': '{app}(默认)', + 'path.appsError': '无法获取应用列表', 'path.open': '打开', 'path.open.tooltip': '用默认应用打开', 'path.more': '更多打开方式', @@ -66,6 +68,8 @@ export const en: Record = { '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', diff --git a/packages/client/ui-open-in-app/src/client/open-path.ts b/packages/client/ui-open-in-app/src/client/open-path.ts index 161fcf104f..3c44e54a23 100644 --- a/packages/client/ui-open-in-app/src/client/open-path.ts +++ b/packages/client/ui-open-in-app/src/client/open-path.ts @@ -5,7 +5,7 @@ */ import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client' -import type { SessionOpenWorkspacePathRequest } from '@deepseek-ai/dsh-api-session-controller/types' +import type { SessionOpenWorkspacePathRequest, SessionWorkspacePathApplication } from '@deepseek-ai/dsh-api-session-controller/types' /** What a path gesture asks of the Host desktop: the default application, or the file manager showing the file. */ export type OpenInAppPathAction = 'open' | 'reveal' @@ -18,6 +18,9 @@ export interface OpenInAppPathRemote { readonly session: { /** Whether the Host can hand a workspace path to a native desktop. */ canOpenWorkspacePath(): Promise> + workspacePathApplications( + request: { path: string }, signal?: AbortSignal, + ): Promise> /** Open one Host path in its default application, or reveal it in the file manager. */ openWorkspacePath(request: SessionOpenWorkspacePathRequest, signal?: AbortSignal): Promise> } @@ -47,10 +50,11 @@ export class OpenInAppPathController { * Open one Host path in its default application, or reveal it in the file manager. * @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. */ - async openPath(path: string, action: OpenInAppPathAction): Promise { - const request: SessionOpenWorkspacePathRequest = action === 'reveal' ? { path, action } : { path } + async openPath(path: string, action: OpenInAppPathAction, application?: string): Promise { + const request: SessionOpenWorkspacePathRequest = action === 'reveal' ? { path, action } : { path, ...(application === undefined ? {} : { application }) } let ok = false try { ok = (await this.remote.session.openWorkspacePath(request)).ok @@ -61,6 +65,22 @@ export class OpenInAppPathController { return ok ? null : action === 'open' ? 'openError' : 'revealError' } + /** + * Refresh the file's OS associations; failures remain distinct from an empty handler list. + * @param path - file path reported by the Host. + * @param signal - lifetime of the requesting preview. + * @returns application metadata, or null when the query fails. + */ + async applications(path: string, signal: AbortSignal): Promise { + try { + const result = await this.remote.session.workspacePathApplications({ path }, signal) + return result.ok ? result.value : null + } catch (_error) { + // Carrier failure leaves default opening available while the menu reports the failed query. + return null + } + } + private async run(): Promise { let available = false try { 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 dab2b08df0..14dc706604 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 @@ -24,6 +24,7 @@ afterEach(() => { /** The Session Remote slice the path controls call; answers a desktop and acknowledges every gesture. */ const remote = { session: { + workspacePathApplications: vi.fn(async () => ({ ok: true as const, value: [] })), canOpenWorkspacePath: vi.fn(async () => ({ ok: true as const, value: true })), openWorkspacePath: vi.fn(async () => ({ ok: true as const, value: { opened: true as const } })), }, diff --git a/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx b/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx index ae9803c164..fd408119fd 100644 --- a/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx +++ b/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx @@ -40,6 +40,7 @@ function bench(over: { desktop?: boolean | null; openPath?: Bench['openPath'] } absolutePath: ABSOLUTE_PATH, useOpenInAppDesktop: useSelector(desktop), loadDesktop, + applications: vi.fn(async () => []), openPath, t, } as unknown as Bench['props'] diff --git a/packages/client/ui-open-in-app/tests/open-path.client.spec.ts b/packages/client/ui-open-in-app/tests/open-path.client.spec.ts index 2e8560f7cf..1b1702cc2f 100644 --- a/packages/client/ui-open-in-app/tests/open-path.client.spec.ts +++ b/packages/client/ui-open-in-app/tests/open-path.client.spec.ts @@ -10,6 +10,7 @@ type SessionRemote = OpenInAppPathRemote['session'] function remoteOf(over: Partial = {}): OpenInAppPathRemote { return { session: { + workspacePathApplications: async () => ({ ok: true, value: [] }), canOpenWorkspacePath: async () => ({ ok: true, value: true }), openWorkspacePath: async () => ({ ok: true, value: { opened: true } }), ...over, @@ -69,3 +70,18 @@ describe('OpenInAppPathController gestures', () => { expect(await rejecting.openPath('/w/clip.mp4', 'open')).toBe('openError') }) }) + + +it('queries current handlers and carries an explicit application without changing the default gesture', async () => { + const applications = [{ id: '/Applications/Music.app', name: 'Music', default: true, icon: null }] + const query = vi.fn(async () => ({ ok: true as const, value: applications })) + const open = vi.fn(async () => ({ ok: true as const, value: { opened: true } })) + const controller = new OpenInAppPathController(remoteOf({ workspacePathApplications: query, openWorkspacePath: open })) + const signal = new AbortController().signal + expect(await controller.applications('/file.mp3', signal)).toEqual(applications) + expect(query).toHaveBeenCalledWith({ path: '/file.mp3' }, signal) + await controller.openPath('/file.mp3', 'open', '/Applications/Music.app') + expect(open).toHaveBeenLastCalledWith({ path: '/file.mp3', application: '/Applications/Music.app' }) + await controller.openPath('/file.mp3', 'open') + expect(open).toHaveBeenLastCalledWith({ path: '/file.mp3' }) +}) diff --git a/packages/util/native-command/src/file-applications.ts b/packages/util/native-command/src/file-applications.ts new file mode 100644 index 0000000000..830b8557d6 --- /dev/null +++ b/packages/util/native-command/src/file-applications.ts @@ -0,0 +1,86 @@ +/** Native file associations queried from the desktop that owns the path. */ +import { runNativeCommand } from './runner.ts' +import type { PathOpenerInternals } from './path-opener.ts' + +/** One OS-registered application capable of opening the requested file. */ +export interface NativeFileApplication { + /** OS application identifier; callers must revalidate it against the file's current handlers before opening. */ + readonly id: string + readonly name: string + readonly default: boolean + /** PNG data URL, or null when the desktop supplies no icon. */ + readonly icon: string | null +} + +/** AppKit runs inside the system JXA host; paths arrive as argv, never executable source. */ +const MAC_APPLICATIONS = ` +ObjC.import('AppKit'); +function run(argv) { + var workspace = $.NSWorkspace.sharedWorkspace; + var file = $.NSURL.fileURLWithPath(argv[0]); + var preferred = workspace.URLForApplicationToOpenURL(file); + var preferredPath = preferred.isNil() ? null : ObjC.unwrap(preferred.path); + var urls = workspace.URLsForApplicationsToOpenURL(file); + var apps = []; + for (var i = 0; i < urls.count; i++) { + var url = urls.objectAtIndex(i); + var path = ObjC.unwrap(url.path); + var icon = workspace.iconForFile(path); + var bitmap = $.NSBitmapImageRep.imageRepWithData(icon.TIFFRepresentation); + var png = bitmap.representationUsingTypeProperties($.NSBitmapImageFileTypePNG, $({})); + apps.push({ + id: path, + name: ObjC.unwrap($.NSFileManager.defaultManager.displayNameAtPath(path)), + default: path === preferredPath, + icon: png.isNil() ? null : 'data:image/png;base64,' + ObjC.unwrap(png.base64EncodedStringWithOptions(0)) + }); + } + return JSON.stringify(apps); +}` + +/** + * List registered handlers in OS preference order, including the current default and application icons. + * @param path - verified absolute local file path. + * @param signal - caller lifetime, propagated to the OS query. + * @param internals - platform and command adapter for deterministic tests. + * @returns current file handlers; an empty list when the platform has no association query. + */ +export async function nativeFileApplications( + path: string, signal: AbortSignal, internals: PathOpenerInternals = {}, +): Promise { + signal.throwIfAborted() + const platform = internals.platform ?? process.platform + if (platform !== 'darwin') return [] + const run = internals.run ?? runNativeCommand + const { stdout } = await run('/usr/bin/osascript', ['-l', 'JavaScript', '-e', MAC_APPLICATIONS, path], signal) + const value: unknown = JSON.parse(stdout) + if (!Array.isArray(value)) throw new Error('Invalid native application list') + const applications: NativeFileApplication[] = [] + for (const entry of value) { + if (typeof entry !== 'object' || entry === null + || 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;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 +} + +/** + * Open a file in a currently registered handler; stale or arbitrary application identifiers are rejected. + * @param path - verified absolute local file path. + * @param application - identifier returned by the file association query. + * @param signal - caller lifetime, propagated to query and launch. + * @param internals - platform and command adapter for deterministic tests. + * @returns after the system launcher accepts the file. + */ +export async function openNativeFileApplication( + path: string, application: string, signal: AbortSignal, internals: PathOpenerInternals = {}, +): Promise { + const apps = await nativeFileApplications(path, signal, internals) + if (!apps.some(app => app.id === application)) throw new Error('Application is not registered for this file') + const run = internals.run ?? runNativeCommand + await run('/usr/bin/open', ['-a', application, path], signal) +} diff --git a/packages/util/native-command/src/index.ts b/packages/util/native-command/src/index.ts index 29a0f16c18..ea948a745b 100644 --- a/packages/util/native-command/src/index.ts +++ b/packages/util/native-command/src/index.ts @@ -18,3 +18,6 @@ export type { PathOpenerInternals, PathOpenerRunner, } from './path-opener.ts' + +export { nativeFileApplications, openNativeFileApplication } from './file-applications.ts' +export type { NativeFileApplication } from './file-applications.ts' diff --git a/packages/util/native-command/tests/file-applications.spec.ts b/packages/util/native-command/tests/file-applications.spec.ts new file mode 100644 index 0000000000..b6f2e3328c --- /dev/null +++ b/packages/util/native-command/tests/file-applications.spec.ts @@ -0,0 +1,37 @@ +/** File association results and explicit handler authorization at the native command adapter. */ +import { describe, expect, it, vi } from 'vitest' +import { nativeFileApplications, openNativeFileApplication } from '../src/file-applications.ts' + +const application = { id: '/Applications/Music.app', name: 'Music', default: true, icon: null } +const signal = new AbortController().signal + +describe('native file associations', () => { + it('passes file paths as arguments and preserves the desktop default', async () => { + const run = vi.fn(async () => ({ stdout: JSON.stringify([application]), stderr: '' })) + const path = '/tmp/中文 $(touch nope).mp3' + await expect(nativeFileApplications(path, signal, { platform: 'darwin', run })).resolves.toEqual([application]) + expect(run).toHaveBeenCalledWith('/usr/bin/osascript', ['-l', 'JavaScript', '-e', expect.any(String), path], signal) + }) + + it.each(['{}', '[null]', '[{"id":1}]', JSON.stringify([{ ...application, icon: 'javascript:alert(1)' }])])('rejects malformed native output %s', async (stdout) => { + await expect(nativeFileApplications('/file.mp3', signal, { + platform: 'darwin', run: async () => ({ stdout, stderr: '' }), + })).rejects.toThrow() + }) + + it('launches only a currently registered application with argv', async () => { + const run = vi.fn(async () => ({ stdout: JSON.stringify([application]), stderr: '' })) + await openNativeFileApplication('/file.mp3', application.id, signal, { platform: 'darwin', run }) + expect(run).toHaveBeenLastCalledWith('/usr/bin/open', ['-a', application.id, '/file.mp3'], signal) + run.mockClear() + await expect(openNativeFileApplication('/file.mp3', '/arbitrary.app', signal, { platform: 'darwin', run })).rejects.toThrow('not registered') + expect(run).toHaveBeenCalledOnce() + }) + + it('does not query after cancellation or on unsupported platforms', async () => { + const run = vi.fn() + await expect(nativeFileApplications('/file.mp3', signal, { platform: 'freebsd', run })).resolves.toEqual([]) + await expect(nativeFileApplications('/file.mp3', AbortSignal.abort(), { platform: 'darwin', run })).rejects.toThrow() + expect(run).not.toHaveBeenCalled() + }) +}) From d6d9d47c4929a7eff2ed27184f822814105acd35 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sun, 20 Sep 2026 10:32:57 +0800 Subject: [PATCH 02/62] fix: constrain application icon pixels before transport --- .../ui-open-in-app/tests/open-path-action.client.spec.tsx | 8 ++++---- packages/util/native-command/src/file-applications.ts | 6 +++++- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx b/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx index fd408119fd..b2a14bca11 100644 --- a/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx +++ b/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx @@ -66,10 +66,10 @@ describe('OpenPathAction gestures', () => { render() const main = screen.getByRole('button', { name: zh['path.open'] }) await act(async () => { fireEvent.click(main) }) - expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'open') + expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'open', undefined) fireEvent.click(screen.getByRole('button', { name: zh['path.more'] })) await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: zh['path.reveal'] })) }) - expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'reveal') + expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'reveal', undefined) expect(screen.queryByRole('menu')).toBeNull() }) @@ -84,7 +84,7 @@ describe('OpenPathAction gestures', () => { expect(b.openPath).not.toHaveBeenCalled() fireEvent.click(more) await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: zh['path.defaultApp'] })) }) - expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'open') + expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'open', undefined) }) it('disables both halves while its own gesture settles and leaves no failure on the control', async () => { @@ -113,7 +113,7 @@ describe('OpenPathEmptyAction', () => { const b = bench() render() await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.unpreviewable'] })) }) - expect(b.openPath).toHaveBeenCalledWith(ABSOLUTE_PATH, 'open') + expect(b.openPath).toHaveBeenCalledWith(ABSOLUTE_PATH, 'open', undefined) expect(screen.queryByRole('alert')).toBeNull() }) diff --git a/packages/util/native-command/src/file-applications.ts b/packages/util/native-command/src/file-applications.ts index 830b8557d6..7777a7f6fc 100644 --- a/packages/util/native-command/src/file-applications.ts +++ b/packages/util/native-command/src/file-applications.ts @@ -26,7 +26,11 @@ function run(argv) { var url = urls.objectAtIndex(i); var path = ObjC.unwrap(url.path); var icon = workspace.iconForFile(path); - var bitmap = $.NSBitmapImageRep.imageRepWithData(icon.TIFFRepresentation); + var thumbnail = $.NSImage.alloc.initWithSize($.NSMakeSize(32, 32)); + thumbnail.lockFocus; + icon.drawInRectFromRectOperationFraction($.NSMakeRect(0, 0, 32, 32), $.NSZeroRect, $.NSCompositingOperationSourceOver, 1); + thumbnail.unlockFocus; + var bitmap = $.NSBitmapImageRep.imageRepWithData(thumbnail.TIFFRepresentation); var png = bitmap.representationUsingTypeProperties($.NSBitmapImageFileTypePNG, $({})); apps.push({ id: path, From 97877bd8b48a9e0440868aa717558940d5db2071 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sun, 20 Sep 2026 10:34:24 +0800 Subject: [PATCH 03/62] test: cover file handler selection and shared path authorization --- packages/api/session-controller/src/index.ts | 5 +++-- packages/api/session-controller/src/types.ts | 2 +- .../session-open-workspace-path.host.spec.ts | 22 +++++++++++++++++++ .../session-controller/tests/test-remote.ts | 5 +++++ .../tests/open-path-action.client.spec.tsx | 19 ++++++++++++++++ packages/util/native-command/package.json | 5 ++++- .../native-command/src/file-applications.ts | 12 +++------- packages/util/native-command/src/types.ts | 11 ++++++++++ 8 files changed, 68 insertions(+), 13 deletions(-) create mode 100644 packages/util/native-command/src/types.ts diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index 0f0a44339d..0658b01127 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -8,7 +8,7 @@ import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { errorChain } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-client-file-upload' -import { canOpenNativePath, nativeFileManager, nativeFileApplications, openNativeFileApplication, openNativeAssociatedPath, revealNativePath, type NativeFileApplication } from '@deepseek-ai/dsh-native-command' +import { canOpenNativePath, nativeFileManager, nativeFileApplications, openNativeFileApplication, openNativeAssociatedPath, revealNativePath } from '@deepseek-ai/dsh-native-command' import type { SessionId } from '@deepseek-ai/dsh-session' import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence' import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' @@ -29,6 +29,7 @@ import { SessionSkillCatalog } from './skill-catalog.ts' import { SessionMediaReferences } from './media-references.ts' import type { ModelCatalog, + SessionWorkspacePathApplication, SessionAttachmentRequest, SessionAttachmentValue, SessionCancelRequest, @@ -349,7 +350,7 @@ export class SessionController extends TypertRemoteService { @Remote('workspacePathApplications') async workspacePathApplications( request: { readonly path: string }, signal: AbortSignal, - ): Promise { + ): Promise { if (!this.canOpenPath()) return [] await this.verifyDesktopPath(request.path, signal) return this.fileApplications(resolve(request.path), signal) diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index ac155e7ab5..e238e518b5 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -1,4 +1,4 @@ -import type { NativeFileApplication } from '@deepseek-ai/dsh-native-command' +import type { NativeFileApplication } from '@deepseek-ai/dsh-native-command/types' /** Browser-safe request, result, and lifecycle vocabulary for the Session Remote service. */ import type { diff --git a/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts b/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts index 6a14c7761d..63df8886bd 100644 --- a/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts +++ b/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts @@ -215,3 +215,25 @@ it('dispatches default-app opening to the association adapter', async () => { await controller.openWorkspacePath({ path: '/report.html' }, signal) expect(open).toHaveBeenCalledWith('/report.html', signal) }) + + +it('uses the same path verification for handler queries and explicit application opening', async () => { + const ctx = await context() + const applications = [{ id: '/Applications/Music.app', name: 'Music', default: true, icon: null }] + const fileApplications = vi.fn(async () => applications) + const openFileApplication = vi.fn(async () => {}) + const controller = createSessionTestController(ctx, { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', nativeOpen: true, + fileApplications, openFileApplication, + }) + const signal = new AbortController().signal + expect(await controller.workspacePathApplications({ path: '/file.mp3' }, signal)).toEqual(applications) + await controller.openWorkspacePath({ path: '/file.mp3', application: applications[0]!.id }, signal) + expect(openFileApplication).toHaveBeenCalledWith(expect.stringContaining('file.mp3'), applications[0]!.id, signal) + const mapping = vi.spyOn(ctx.fs, 'processPathFromHostPath').mockReturnValue(undefined) + onTestFinished(() => { mapping.mockRestore() }) + await expect(controller.workspacePathApplications({ path: '/remote.mp3' }, signal)).rejects.toThrow('verified Host path') + await expect(controller.openWorkspacePath({ path: '/remote.mp3', application: applications[0]!.id }, signal)).rejects.toThrow('verified Host path') + expect(fileApplications).toHaveBeenCalledOnce() + expect(openFileApplication).toHaveBeenCalledOnce() +}) diff --git a/packages/api/session-controller/tests/test-remote.ts b/packages/api/session-controller/tests/test-remote.ts index b4d3db8198..152cffe9b2 100644 --- a/packages/api/session-controller/tests/test-remote.ts +++ b/packages/api/session-controller/tests/test-remote.ts @@ -1,3 +1,4 @@ +import type { SessionControllerInternals } from '../src/index.ts' /** Test-only direct Remote face over the Session Controller's internal controllers. */ import { SessionLogOffset } from '@deepseek-ai/dsh-session' @@ -94,6 +95,8 @@ export interface TestSessionRemoteDefaults { readonly nativeOpen?: boolean readonly saveDefaultModelSelection?: (selection: AgentModelSelection) => void | Promise readonly openPath?: (path: string, signal: AbortSignal) => Promise + readonly fileApplications?: SessionControllerInternals['fileApplications'] + readonly openFileApplication?: SessionControllerInternals['openFileApplication'] readonly revealPath?: (path: string, signal: AbortSignal) => Promise readonly canOpenPath?: () => boolean } @@ -288,6 +291,8 @@ function installControllers( }, { ...defaults.openPath === undefined ? {} : { openPath: defaults.openPath }, + ...defaults.fileApplications === undefined ? {} : { fileApplications: defaults.fileApplications }, + ...defaults.openFileApplication === undefined ? {} : { openFileApplication: defaults.openFileApplication }, ...defaults.revealPath === undefined ? {} : { revealPath: defaults.revealPath }, ...defaults.canOpenPath === undefined ? {} : { canOpenPath: defaults.canOpenPath }, }, diff --git a/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx b/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx index b2a14bca11..6f34ced839 100644 --- a/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx +++ b/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx @@ -141,3 +141,22 @@ it('dismisses a failure on schedule even when its owner rerenders', async () => 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() + const icon = 'data:image/png;base64,aGVsbG8=' + const applications = vi.fn(async () => [ + { id: '/Music.app', name: 'Music', default: true, icon }, + { id: '/Player.app', name: 'Player', default: false, icon: null }, + ]) + const view = render() + await act(async () => {}) + expect(view.container.querySelector('[data-open-path-open] img')?.getAttribute('src')).toBe(icon) + await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.more'] })) }) + expect(screen.getByRole('menuitem', { name: 'Music(默认)' })).toBeTruthy() + await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: 'Player' })) }) + expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'open', '/Player.app') + await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.open'] })) }) + expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'open', undefined) +}) diff --git a/packages/util/native-command/package.json b/packages/util/native-command/package.json index 8060a32a2a..a4a749b100 100644 --- a/packages/util/native-command/package.json +++ b/packages/util/native-command/package.json @@ -19,7 +19,10 @@ "default": "./lib/index.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./types": { + "types": "./lib/types/types.d.ts" + } }, "files": [ "lib/index.js", diff --git a/packages/util/native-command/src/file-applications.ts b/packages/util/native-command/src/file-applications.ts index 7777a7f6fc..f865d93145 100644 --- a/packages/util/native-command/src/file-applications.ts +++ b/packages/util/native-command/src/file-applications.ts @@ -2,15 +2,9 @@ import { runNativeCommand } from './runner.ts' import type { PathOpenerInternals } from './path-opener.ts' -/** One OS-registered application capable of opening the requested file. */ -export interface NativeFileApplication { - /** OS application identifier; callers must revalidate it against the file's current handlers before opening. */ - readonly id: string - readonly name: string - readonly default: boolean - /** PNG data URL, or null when the desktop supplies no icon. */ - readonly icon: string | null -} +import type { NativeFileApplication } from './types.ts' + +export type { NativeFileApplication } from './types.ts' /** AppKit runs inside the system JXA host; paths arrive as argv, never executable source. */ const MAC_APPLICATIONS = ` diff --git a/packages/util/native-command/src/types.ts b/packages/util/native-command/src/types.ts new file mode 100644 index 0000000000..4b67d05159 --- /dev/null +++ b/packages/util/native-command/src/types.ts @@ -0,0 +1,11 @@ +/** Browser-safe metadata for native file associations. */ + +/** One OS-registered application capable of opening the requested file. */ +export interface NativeFileApplication { + /** OS application identifier; callers must revalidate it against the file's current handlers before opening. */ + readonly id: string + readonly name: string + readonly default: boolean + /** PNG data URL, or null when the desktop supplies no icon. */ + readonly icon: string | null +} From 03d44191b391c00e84bf82527cbf09f507232824 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sun, 20 Sep 2026 10:35:56 +0800 Subject: [PATCH 04/62] fix: expose browser-safe file association metadata to Remote generation --- packages/util/native-command/package.json | 6 ++++-- tsconfig.base.json | 1 + 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/util/native-command/package.json b/packages/util/native-command/package.json index a4a749b100..391b6c023d 100644 --- a/packages/util/native-command/package.json +++ b/packages/util/native-command/package.json @@ -21,12 +21,14 @@ "./src/*": "./src/*", "./package.json": "./package.json", "./types": { - "types": "./lib/types/types.d.ts" + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" } }, "files": [ "lib/index.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/types.js" ], "license": "MIT", "peerDependencies": { diff --git a/tsconfig.base.json b/tsconfig.base.json index cc1b65cc78..3f58c32145 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -366,6 +366,7 @@ "@deepseek-ai/dsh-mcp-client": ["./packages/mcp/mcp-client/src"], "@deepseek-ai/dsh-mcp-resources": ["./packages/mcp/mcp-resources/src"], "@deepseek-ai/dsh-message-feedback": ["./packages/feedback/message-feedback/src"], + "@deepseek-ai/dsh-native-command/types": ["./packages/util/native-command/src/types.ts"], "@deepseek-ai/dsh-native-command": ["./packages/util/native-command/src"], "@deepseek-ai/dsh-office-to-pdf": ["./packages/document/office-to-pdf/src"], "@deepseek-ai/dsh-output-retention": ["./packages/util/output-retention/src"], From 083901120492c835692780824a0bdac500e582bf Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sun, 20 Sep 2026 10:37:11 +0800 Subject: [PATCH 05/62] fix: include native association types in client compilation --- .../session-controller/tsconfig.client.json | 75 ++++++++++++++----- .../tests/open-path-action.client.spec.tsx | 26 +++++++ packages/util/native-command/src/types.d.ts | 11 +++ .../util/native-command/src/types.d.ts.map | 1 + packages/util/native-command/src/types.js | 3 + packages/util/native-command/src/types.js.map | 1 + 6 files changed, 99 insertions(+), 18 deletions(-) create mode 100644 packages/util/native-command/src/types.d.ts create mode 100644 packages/util/native-command/src/types.d.ts.map create mode 100644 packages/util/native-command/src/types.js create mode 100644 packages/util/native-command/src/types.js.map diff --git a/packages/api/session-controller/tsconfig.client.json b/packages/api/session-controller/tsconfig.client.json index 3bb07893e2..b8a0d78fc2 100644 --- a/packages/api/session-controller/tsconfig.client.json +++ b/packages/api/session-controller/tsconfig.client.json @@ -11,23 +11,62 @@ "src/remote-events.ts" ], "references": [ - { "path": "../../../vendor/cordis" }, - { "path": "../gateway/tsconfig.client.json" }, - { "path": "../../attachment/attachment" }, - { "path": "../../client/connection/tsconfig.client.json" }, - { "path": "../../client/file-upload/tsconfig.client.json" }, - { "path": "../../client/store" }, - { "path": "../../context/file-reference" }, - { "path": "../../core/session" }, - { "path": "../../jobs/jobs" }, - { "path": "../../llm/llm" }, - { "path": "../../session/session-projection" }, - { "path": "../../session/session-title" }, - { "path": "../../subagent/subagent" }, - { "path": "../../util/brand" }, - { "path": "../../util/crypto" }, - { "path": "../../util/workspace-path" }, - { "path": "../../workspace/workspace" }, - { "path": "../../typert/protocol" } + { + "path": "../../../vendor/cordis" + }, + { + "path": "../gateway/tsconfig.client.json" + }, + { + "path": "../../attachment/attachment" + }, + { + "path": "../../client/connection/tsconfig.client.json" + }, + { + "path": "../../client/file-upload/tsconfig.client.json" + }, + { + "path": "../../client/store" + }, + { + "path": "../../context/file-reference" + }, + { + "path": "../../core/session" + }, + { + "path": "../../jobs/jobs" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../session/session-projection" + }, + { + "path": "../../session/session-title" + }, + { + "path": "../../subagent/subagent" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../util/crypto" + }, + { + "path": "../../util/workspace-path" + }, + { + "path": "../../workspace/workspace" + }, + { + "path": "../../typert/protocol" + }, + { + "path": "../../util/native-command" + } ] } diff --git a/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx b/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx index 6f34ced839..f72a9d9780 100644 --- a/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx +++ b/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx @@ -160,3 +160,29 @@ it('shows the default icon and current application list, and selects an applicat await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.open'] })) }) expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'open', undefined) }) + + +it('ignores an obsolete file query and refreshes the default application when the menu opens', async () => { + const b = bench() + const old = Promise.withResolvers() + const applications = vi.fn() + .mockImplementationOnce(() => old.promise) + .mockResolvedValue([{ id: '/Player.app', name: 'Player', default: true, icon: null }]) + const view = render() + view.rerender() + await act(async () => { old.resolve([{ id: '/Old.app', name: 'Old', default: true, icon: null }]) }) + await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.more'] })) }) + expect(screen.queryByRole('menuitem', { name: 'Old(默认)' })).toBeNull() + expect(screen.getByRole('menuitem', { name: 'Player(默认)' })).toBeTruthy() + expect(applications.mock.calls[0]![1].aborted).toBe(true) + expect(applications).toHaveBeenLastCalledWith('/other.mp3', expect.any(AbortSignal)) +}) + +it('keeps default opening available when application discovery fails', async () => { + const b = bench() + render( null} />) + await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.more'] })) }) + expect(screen.getByRole('menuitem', { name: zh['path.appsError'] }).getAttribute('aria-disabled')).toBe('true') + await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: zh['path.defaultApp'] })) }) + expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'open', undefined) +}) diff --git a/packages/util/native-command/src/types.d.ts b/packages/util/native-command/src/types.d.ts new file mode 100644 index 0000000000..9206e9b400 --- /dev/null +++ b/packages/util/native-command/src/types.d.ts @@ -0,0 +1,11 @@ +/** Browser-safe metadata for native file associations. */ +/** One OS-registered application capable of opening the requested file. */ +export interface NativeFileApplication { + /** OS application identifier; callers must revalidate it against the file's current handlers before opening. */ + readonly id: string + readonly name: string + readonly default: boolean + /** PNG data URL, or null when the desktop supplies no icon. */ + readonly icon: string | null +} +//# sourceMappingURL=types.d.ts.map diff --git a/packages/util/native-command/src/types.d.ts.map b/packages/util/native-command/src/types.d.ts.map new file mode 100644 index 0000000000..378f0bc5ab --- /dev/null +++ b/packages/util/native-command/src/types.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["types.ts"],"names":[],"mappings":"AAAA,0DAA0D;AAE1D,2EAA2E;AAC3E,MAAM,WAAW,qBAAqB;IACpC,gHAAgH;IAChH,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;IACzB,+DAA+D;IAC/D,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;CAC7B"} \ No newline at end of file diff --git a/packages/util/native-command/src/types.js b/packages/util/native-command/src/types.js new file mode 100644 index 0000000000..77e2c20121 --- /dev/null +++ b/packages/util/native-command/src/types.js @@ -0,0 +1,3 @@ +/** Browser-safe metadata for native file associations. */ +export {}; +//# sourceMappingURL=types.js.map \ No newline at end of file diff --git a/packages/util/native-command/src/types.js.map b/packages/util/native-command/src/types.js.map new file mode 100644 index 0000000000..c8c16fbd5f --- /dev/null +++ b/packages/util/native-command/src/types.js.map @@ -0,0 +1 @@ +{"version":3,"file":"types.js","sourceRoot":"","sources":["types.ts"],"names":[],"mappings":"AAAA,0DAA0D"} \ No newline at end of file From 0ae1b2f91cb1f070179c9d056559f1d9c2d2ef0f Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sun, 20 Sep 2026 10:39:42 +0800 Subject: [PATCH 06/62] docs: describe native file application selection and platform scope --- docs/subsystems/session.i18n.yaml | 4 ++-- docs/subsystems/session.md | 10 ++++++++++ docs/subsystems/session.zh.md | 10 ++++++++++ packages/api/session-controller/README.i18n.yaml | 4 ++-- packages/api/session-controller/README.md | 2 ++ packages/api/session-controller/README.zh.md | 2 ++ packages/client/ui-open-in-app/README.i18n.yaml | 4 ++-- packages/client/ui-open-in-app/README.md | 2 ++ packages/client/ui-open-in-app/README.zh.md | 2 ++ .../extensions/tool-cordis/src/api-catalog.ts | 16 +++++++++++++++- packages/util/native-command/README.i18n.yaml | 4 ++-- packages/util/native-command/README.md | 2 ++ packages/util/native-command/README.zh.md | 2 ++ packages/util/native-command/src/types.d.ts | 11 ----------- packages/util/native-command/src/types.d.ts.map | 1 - packages/util/native-command/src/types.js | 3 --- packages/util/native-command/src/types.js.map | 1 - scripts/gen-cordis-catalog.ts | 1 + 18 files changed, 56 insertions(+), 25 deletions(-) delete mode 100644 packages/util/native-command/src/types.d.ts delete mode 100644 packages/util/native-command/src/types.d.ts.map delete mode 100644 packages/util/native-command/src/types.js delete mode 100644 packages/util/native-command/src/types.js.map diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 83eecfd1c7..15e84bb5d7 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.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 docs/subsystems/session.md -session.md: 62f7785d4d1db9c30706a8b967e6f4073b810149 -session.zh.md: 7cd3c38162df209d245d3f1085ab1edce002531a +session.md: 5c8d27167fb95e4ab2da952ea6edb47aa6afd99b +session.zh.md: b9e7299689d535f56b48e53fd4b2dff152b3b78f diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index 62f7785d4d..5c8d27167f 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -758,6 +758,8 @@ The backends that consume this contract are on [persistence.md](persistence.md). `SessionOpenWorkspacePathRequest` carries an absolute or workspace-resolved `path`; optional `action: "reveal"` selects file-manager navigation instead of default-application opening. `SessionOpenWorkspacePathValue` confirms that the Host accepted the native handoff. A Session-aware Client resolves relative paths against its current Session cwd when known; the controller hands the path to the opener unchanged and reports invalid requests, cancellation, and opener failures through the Session Remote error vocabulary. +The optional `application` selects a registered file handler without changing the system default; `workspacePathApplications({ path })` returns current handlers, names, icons, and default selection after Host path verification. + @@ -845,6 +847,14 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | */ @Remote('openWorkspacePath') async openWorkspacePath( request: SessionOpenWorkspacePathRequest, signal: AbortSignal, ): Promise +/** + * Query current file handlers on the serving desktop without activating an Agent. + * @param request - file path in Host filesystem syntax. + * @param signal - caller lifetime, propagated to filesystem and desktop queries. + * @returns OS application names, icons, and default selection; empty when desktop opening is unavailable. + */ +@Remote('workspacePathApplications') async workspacePathApplications( request: { readonly path: string }, signal: AbortSignal, ): Promise + /** * Rename one Session after explicitly resuming it. * @param request - Session identity and proposed title. diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index 7cd3c38162..b9e7299689 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -762,6 +762,8 @@ interface TurnEndReasonMap { `SessionOpenWorkspacePathRequest` 携带绝对路径或已按 workspace 解析的 `path`。`SessionOpenWorkspacePathValue` 确认 Host 已接受原生交接。Session-aware Client 会在已知当前 Session cwd 时据此解析相对路径;controller 将路径原样交给打开器,并通过 Session Remote 错误词汇表报告无效请求、取消与打开器失败。 可选的 `action: "reveal"` 选择文件管理器导航;省略时使用默认应用打开。 +可选的 `application` 指定文件关联应用,不修改系统默认值;`workspacePathApplications({ path })` 在校验 Host 路径后返回当前关联应用、名称、图标和默认项。 + @@ -849,6 +851,14 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | */ @Remote('openWorkspacePath') async openWorkspacePath( request: SessionOpenWorkspacePathRequest, signal: AbortSignal, ): Promise +/** + * Query current file handlers on the serving desktop without activating an Agent. + * @param request - file path in Host filesystem syntax. + * @param signal - caller lifetime, propagated to filesystem and desktop queries. + * @returns OS application names, icons, and default selection; empty when desktop opening is unavailable. + */ +@Remote('workspacePathApplications') async workspacePathApplications( request: { readonly path: string }, signal: AbortSignal, ): Promise + /** * Rename one Session after explicitly resuming it. * @param request - Session identity and proposed title. diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index d04576c269..3ed051d32d 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/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/api/session-controller/README.md -README.md: a0e18910d14f879171a1415476a7e05a0d261a8a -README.zh.md: 58466e387fd604137a2358e8b9a7de3320691034 +README.md: 8e879e5d26f9aa3f920789499af80e12c6beaa44 +README.zh.md: ac6e775e0f7a0a29057cd37c310fd6d00cd38a5f diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index a0e18910d1..8e879e5d26 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -68,6 +68,8 @@ References keep local Session data, scoped Contexts, and history streams alive, `SessionMediaReferences` mounts `GET|HEAD /api/file?path=` on the authenticated `connection.fetch` channel when `connection`, `fs`, and `attachments` are composed. It reads ordinary files through `ctx.fs`, including temporary paths outside registered workspaces and files in remote providers. Neither directory containment nor MIME categories restrict access; `mime-types` supplies the response type, with `application/octet-stream` for unknown extensions. GET reuses `readBytes` for preflight and ongoing byte limits; HEAD reads metadata only. All files use `ctx.attachments.imageLimits.maxImageBytes` (normally 20 MiB); exceeding this limit returns 413. Responses contain the complete file, ignore Range, and carry `private, no-store`, `nosniff`, and a sandbox CSP so directly opened HTML/SVG cannot execute with the API origin. The Client rewrite lives in `ui-chat` (`AssistantMarkdown`); audio/video responses are available, while Markdown audio/video player nodes remain separate work. +`workspacePathApplications({ path })` returns the serving desktop’s file handlers after the same filesystem mapping check used by `openWorkspacePath`. An optional `application` in the open request selects a currently registered handler without changing the system default. Application names, default selection, icons, and platform coverage come from [native-command](../../util/native-command/README.md). These operations do not activate an Agent or append Session events. + ----- diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 58466e387f..ac6e775e0f 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -68,6 +68,8 @@ Fork 复制 `atSeq` 所选的精确事件前缀,包含切点事件,允许在 当 `connection`、`fs` 与 `attachments` 均被组合时,`SessionMediaReferences` 在鉴权 `connection.fetch` 通道上挂载 `GET|HEAD /api/file?path=<绝对路径>`。它通过 `ctx.fs` 读取普通文件,包括已注册工作区之外的临时路径与远程提供方中的文件。目录包含关系与 MIME 类别均不限制访问;`mime-types` 提供响应类型,未知扩展名使用 `application/octet-stream`。GET 复用 `readBytes` 执行读取前及读取中的字节限制;HEAD 只读取元数据。所有文件均使用 `ctx.attachments.imageLimits.maxImageBytes`(通常为 20 MiB);超过此上限返回 413。响应包含完整文件,忽略 Range,并携带 `private, no-store`、`nosniff` 与沙箱 CSP,使直接打开的 HTML/SVG 无法以 API 源身份执行脚本。客户端重写位于 `ui-chat`(`AssistantMarkdown`);音视频文件响应已可用,Markdown 音视频播放器节点仍是独立工作。 +`workspacePathApplications({ path })` 使用与 `openWorkspacePath` 相同的文件系统映射校验,返回服务端桌面上该文件的关联应用。打开请求中的可选 `application` 指定当前关联的应用,不修改系统默认应用。应用名称、默认项、图标和平台支持范围由 [native-command](../../util/native-command/README.zh.md) 提供。这些操作不激活 Agent,也不追加会话事件。 + ----- diff --git a/packages/client/ui-open-in-app/README.i18n.yaml b/packages/client/ui-open-in-app/README.i18n.yaml index fbf2a75bc6..cc15c6a7eb 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: b70b39e79a172badc2b237083d6cd268e9cce06e -README.zh.md: 9d47e57cfe388b3823cee648f1fdf3187b534d78 +README.md: a21a0b3bf3cc5dabe79dc2aa907640d66c8b25c5 +README.zh.md: cbbefaa2d619f591167f7b7775b2eb4c2c945ff1 diff --git a/packages/client/ui-open-in-app/README.md b/packages/client/ui-open-in-app/README.md index b70b39e79a..a21a0b3bf3 100644 --- a/packages/client/ui-open-in-app/README.md +++ b/packages/client/ui-open-in-app/README.md @@ -33,6 +33,8 @@ The main button shows the remembered application's icon — the real application In the document preview, the header's "Open ▾" split button opens the previewed file in the Host's default application from its main button, and its menu offers that action and "Show file location" (the file manager's reveal). A file the preview cannot render, whether an unsupported suffix such as video or an archive or a file the reader rejected as non-text or oversized, shows an "Open in default app" button in its empty state where Retry would otherwise stand. Both controls hand the Host the absolute path the file's metadata reports, appear only after the Host answered that a desktop is available, disable only while their own gesture settles, and announce a failed gesture once through a transient toast ("Could not open. Try again." or "Could not show the file location. Try again."); nothing stays on the control afterwards. +The document header shows the default application’s icon when available. Its menu lists the file’s OS-registered applications and marks the default. Choosing an application opens this file once; the main button continues to use the system default. Associations refresh when the file or menu visibility changes; cancelled queries cannot replace another file’s results. A failed query displays a menu message while default opening and reveal remain available. Platform coverage follows [native-command](../../util/native-command/README.md). + ----- diff --git a/packages/client/ui-open-in-app/README.zh.md b/packages/client/ui-open-in-app/README.zh.md index 9d47e57cfe..cbbefaa2d6 100644 --- a/packages/client/ui-open-in-app/README.zh.md +++ b/packages/client/ui-open-in-app/README.zh.md @@ -33,6 +33,8 @@ kind: "package-reference" 在文档预览里,头部的「打开 ▾」分体按钮的主按钮用 Host 默认应用打开当前文件,菜单提供同一动作和「显示文件位置」(文件管理器的定位)。预览无法渲染的文件,无论是视频、压缩包这类不支持的后缀,还是被读取器判为非文本或过大的文件,都会在空态里原本放重试的位置显示「用默认应用打开」按钮。两个控件都把文件元数据报出的绝对路径交给 Host,只在 Host 回答有桌面之后出现,只在自己的手势结算期间禁用,失败时通过短暂 toast 提示一次(「打开失败,请重试」或「无法显示文件位置,请重试」),之后控件上不残留任何状态。 +文档标题栏在可用时显示默认应用图标。菜单列出系统为该文件注册的应用,并标记默认项。选择应用只用于本次打开,主按钮始终使用系统默认应用。文件或菜单显示状态变化时会重新查询关联应用;已取消的查询不会覆盖其他文件的结果。查询失败时菜单显示提示,默认打开和定位仍可使用。平台支持范围见 [native-command](../../util/native-command/README.zh.md)。 + ----- diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 78975d4a43..1dbd2216b2 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1744,6 +1744,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'confirmation after the native opener accepts the path.', throws: ['RemoteError when the request is invalid, has no verified Host mapping, is cancelled, or the opener fails.'], }, + { + signature: '@Remote(\'workspacePathApplications\') async workspacePathApplications( request: { readonly path: string }, signal: AbortSignal, ): Promise', + description: 'Query current file handlers on the serving desktop without activating an Agent.', + parameters: [{ name: 'request', description: 'file path in Host filesystem syntax.' }, { name: 'signal', description: 'caller lifetime, propagated to filesystem and desktop queries.' }], + returns: 'OS application names, icons, and default selection; empty when desktop opening is unavailable.', + }, { signature: '@Remote(\'rename\') rename(request: SessionRenameRequest): Promise', description: 'Rename one Session after explicitly resuming it.', @@ -5271,6 +5277,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ModelReasoningEffort', declaration: 'export interface ModelReasoningEffort {\n readonly id: string;\n readonly name: string;\n readonly description?: string;\n}', }, + { + name: 'NativeFileApplication', + declaration: 'export interface NativeFileApplication {\n readonly id: string;\n readonly name: string;\n readonly default: boolean;\n readonly icon: string | null;\n}', + }, { name: 'ObjectJsonSchema', declaration: 'export type ObjectJsonSchema = JsonSchemaNode & {\n type: \'object\';\n};', @@ -5981,7 +5991,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionOpenWorkspacePathRequest', - declaration: 'export interface SessionOpenWorkspacePathRequest {\n readonly action?: \'reveal\';\n readonly path: string;\n}', + declaration: 'export interface SessionOpenWorkspacePathRequest {\n readonly action?: \'reveal\';\n readonly application?: string;\n readonly path: string;\n}', }, { name: 'SessionOpenWorkspacePathValue', @@ -6235,6 +6245,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionWireHeader', declaration: 'export interface SessionWireHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly isSeeded: boolean;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n readonly agentPreset?: string;\n}', }, + { + name: 'SessionWorkspacePathApplication', + declaration: 'export type SessionWorkspacePathApplication = NativeFileApplication;', + }, { name: 'SettingsApplies', declaration: 'export type SettingsApplies = \'live\' | \'restart\';', diff --git a/packages/util/native-command/README.i18n.yaml b/packages/util/native-command/README.i18n.yaml index 8bc90e6e8d..d9fba84086 100644 --- a/packages/util/native-command/README.i18n.yaml +++ b/packages/util/native-command/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/util/native-command/README.md -README.md: 9abf4031a915121b6fd2a218473b27c864f8ced8 -README.zh.md: 083318cf4bb37e1c18f016512d58df8bb8f3aec5 +README.md: 8d193855bfb5295b17378312da7d85017867501f +README.zh.md: 4a2fed91fa8ba0c6ae03ad6f91f296352f93fe87 diff --git a/packages/util/native-command/README.md b/packages/util/native-command/README.md index 9abf4031a9..8d193855bf 100644 --- a/packages/util/native-command/README.md +++ b/packages/util/native-command/README.md @@ -49,6 +49,8 @@ The `NativeCommandRunner` type is the injectable command boundary for host integ `revealNativePath(path, signal)` selects the file in Finder or Explorer, including WSL path translation, and opens its parent directory through `xdg-open` on desktop Linux. `nativeFileManager()` identifies that action for Host-derived UI labels; desktop availability remains a separate `canOpenNativePath()` check. Callers must authorize the absolute file path before invoking either operation. Platform dispatch is covered by injected-runner tests; native desktop verification belongs to the corresponding platform. Explorer receives an encoded file URI as a separate argument. Its exit code 1 is accepted as a delegated handoff; cancellation, missing executables, and other exit codes still reject. This acknowledgement does not prove that a desktop window selected the file. +`nativeFileApplications(path, signal)` queries macOS LaunchServices for the file’s registered handlers, current default, localized names, and PNG application icons. `openNativeFileApplication(path, application, signal)` rechecks membership before launching the selected handler. Callers authorize the local file path. Windows and Linux currently return no handlers; default opening and reveal remain available through the path openers. + ----- diff --git a/packages/util/native-command/README.zh.md b/packages/util/native-command/README.zh.md index 083318cf4b..4a2fed91fa 100644 --- a/packages/util/native-command/README.zh.md +++ b/packages/util/native-command/README.zh.md @@ -49,6 +49,8 @@ const { stdout, stderr } = await runNativeCommand('osascript', ['-e', script], s `revealNativePath(path, signal)` 在 Finder 或文件资源管理器中选中文件,包含 WSL 路径转换;在桌面 Linux 上通过 `xdg-open` 打开上层目录。`nativeFileManager()` 标识该操作,供 UI 根据 Host 选择文案;桌面是否可用仍由独立的 `canOpenNativePath()` 检查决定。调用方必须先授权绝对文件路径,再执行操作。平台分派由注入运行器的测试覆盖;原生桌面验证由对应平台负责。 Explorer 接收独立参数中的编码文件 URI。退出码 1 按已转交请求处理;取消、找不到可执行文件和其他退出码仍然报错。该确认不能证明桌面窗口已选中文件。 +`nativeFileApplications(path, signal)` 从 macOS LaunchServices 查询文件的关联应用、当前默认应用、本地化名称和 PNG 应用图标。`openNativeFileApplication(path, application, signal)` 在启动指定应用前重新核对关联列表。调用方负责验证本地文件路径。Windows 和 Linux 当前返回空列表;默认打开和文件定位仍由路径打开接口提供。 + ----- diff --git a/packages/util/native-command/src/types.d.ts b/packages/util/native-command/src/types.d.ts deleted file mode 100644 index 9206e9b400..0000000000 --- a/packages/util/native-command/src/types.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** Browser-safe metadata for native file associations. */ -/** One OS-registered application capable of opening the requested file. */ -export interface NativeFileApplication { - /** OS application identifier; callers must revalidate it against the file's current handlers before opening. */ - readonly id: string - readonly name: string - readonly default: boolean - /** PNG data URL, or null when the desktop supplies no icon. */ - readonly icon: string | null -} -//# sourceMappingURL=types.d.ts.map diff --git a/packages/util/native-command/src/types.d.ts.map b/packages/util/native-command/src/types.d.ts.map deleted file mode 100644 index 378f0bc5ab..0000000000 --- a/packages/util/native-command/src/types.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["types.ts"],"names":[],"mappings":"AAAA,0DAA0D;AAE1D,2EAA2E;AAC3E,MAAM,WAAW,qBAAqB;IACpC,gHAAgH;IAChH,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;IACzB,+DAA+D;IAC/D,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;CAC7B"} \ No newline at end of file diff --git a/packages/util/native-command/src/types.js b/packages/util/native-command/src/types.js deleted file mode 100644 index 77e2c20121..0000000000 --- a/packages/util/native-command/src/types.js +++ /dev/null @@ -1,3 +0,0 @@ -/** Browser-safe metadata for native file associations. */ -export {}; -//# sourceMappingURL=types.js.map \ No newline at end of file diff --git a/packages/util/native-command/src/types.js.map b/packages/util/native-command/src/types.js.map deleted file mode 100644 index c8c16fbd5f..0000000000 --- a/packages/util/native-command/src/types.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"types.js","sourceRoot":"","sources":["types.ts"],"names":[],"mappings":"AAAA,0DAA0D"} \ No newline at end of file diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index c3a8c6a239..dd5f0f3d31 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -379,6 +379,7 @@ export const LINK_MAP: Readonly> = { ModelCatalog: 'session.md', SessionOpenWorkspacePathRequest: 'session.md', SessionOpenWorkspacePathValue: 'session.md', + SessionWorkspacePathApplication: 'session.md', SessionModels: 'session.md', SessionModelsRequest: 'session.md', SessionPage: 'session.md', From e435340eff439ec1be0f3fdc2a8a8658cabff793 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sun, 20 Sep 2026 10:41:48 +0800 Subject: [PATCH 07/62] test: exercise application selection through the recorded Web session --- apps/web/tests/document-preview.e2e.ts | 25 ++++++++++++++++--- .../tests/open-path-action.client.spec.tsx | 2 +- .../native-command/src/file-applications.ts | 4 +-- .../tests/file-applications.spec.ts | 4 +-- .../document-preview/applications.expected.md | 5 ++++ 5 files changed, 32 insertions(+), 8 deletions(-) create mode 100644 snapshots/web/document-preview/applications.expected.md diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index 31b2fd06b6..922ea15992 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -102,10 +102,17 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () const command = process.platform === 'darwin' ? 'open' : 'xdg-open' await writeFile(join(nativeRoot, command), `#!/usr/bin/env node const fs = require('node:fs'); -const path = process.argv[2] === '-R' ? process.argv[3] : process.argv[2]; -const action = process.argv[2] === '-R' || fs.statSync(path).isDirectory() ? 'reveal' : 'open'; +const path = process.argv[2] === '-a' ? process.argv[4] : process.argv[2] === '-R' ? process.argv[3] : process.argv[2]; +const action = process.argv[2] === '-a' ? 'application' : process.argv[2] === '-R' || fs.statSync(path).isDirectory() ? 'reveal' : 'open'; fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) + '\\n'); `, { mode: 0o700 }) + if (process.platform === 'darwin') { + const apps = [ + { id: '/Applications/Test Player.app', name: 'Test Player', default: true, icon: `data:image/png;base64,${TINY_PNG.toString('base64')}` }, + { id: '/Applications/Other Player.app', name: 'Other Player', default: false, icon: null }, + ] + await writeFile(join(nativeRoot, 'osascript'), `#!/usr/bin/env node\nprocess.stdout.write(${JSON.stringify(JSON.stringify(apps))});\n`, { mode: 0o700 }) + } vi.stubEnv('PATH', `${nativeRoot}${delimiter}${process.env.PATH ?? ''}`) } // The Open In rows carry the default-application controls; the SSH marker @@ -756,13 +763,25 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) + expect(gestures[0]).toEqual({ path: clip, action: 'open' }) expect(gestures[1]?.action).toBe('reveal') expect([clip, cwd]).toContain(gestures[1]?.path) + if (process.platform === 'darwin') { + await expect.poll(() => headerOpen.locator('img').count()).toBe(1) + await unsupported.locator('[data-open-path-more]').click() + await page.getByRole('menuitem', { name: 'Test Player (default)', exact: true }).waitFor() + await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'applications.expected.md'), await page.getByRole('menu').ariaSnapshot(), MODE) + await page.getByRole('menuitem', { name: 'Other Player', exact: true }).click() + await expect.poll(async () => (await opened()).length).toBe(3) + expect((await opened())[2]).toEqual({ path: clip, action: 'application' }) + await headerOpen.click() + await expect.poll(async () => (await opened()).length).toBe(4) + expect((await opened())[3]).toEqual({ path: clip, action: 'open' }) + } // Gesture facts stay out of the golden: the stub does not run on Windows. expect(await page.getByRole('alert').count()).toBe(0) } expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) await compareOrRefreshGolden(EXPECTED, sections.join('\n\n'), MODE) - await assertFixtureInventory(SNAPSHOT_DIR, ['document.expected.md', 'paging.patch.yml']) + await assertFixtureInventory(SNAPSHOT_DIR, ['document.expected.md', 'applications.expected.md', 'paging.patch.yml']) }) }) diff --git a/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx b/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx index f72a9d9780..45dfe9272b 100644 --- a/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx +++ b/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx @@ -182,7 +182,7 @@ it('keeps default opening available when application discovery fails', async () const b = bench() render( null} />) await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.more'] })) }) - expect(screen.getByRole('menuitem', { name: zh['path.appsError'] }).getAttribute('aria-disabled')).toBe('true') + expect(screen.getByRole('menuitem', { name: zh['path.appsError'] }).hasAttribute('disabled')).toBe(true) await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: zh['path.defaultApp'] })) }) expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'open', undefined) }) diff --git a/packages/util/native-command/src/file-applications.ts b/packages/util/native-command/src/file-applications.ts index f865d93145..dd6d3d7cad 100644 --- a/packages/util/native-command/src/file-applications.ts +++ b/packages/util/native-command/src/file-applications.ts @@ -50,7 +50,7 @@ export async function nativeFileApplications( const platform = internals.platform ?? process.platform if (platform !== 'darwin') return [] const run = internals.run ?? runNativeCommand - const { stdout } = await run('/usr/bin/osascript', ['-l', 'JavaScript', '-e', MAC_APPLICATIONS, path], signal) + const { stdout } = await run('osascript', ['-l', 'JavaScript', '-e', MAC_APPLICATIONS, path], signal) const value: unknown = JSON.parse(stdout) if (!Array.isArray(value)) throw new Error('Invalid native application list') const applications: NativeFileApplication[] = [] @@ -80,5 +80,5 @@ export async function openNativeFileApplication( const apps = await nativeFileApplications(path, signal, internals) if (!apps.some(app => app.id === application)) throw new Error('Application is not registered for this file') const run = internals.run ?? runNativeCommand - await run('/usr/bin/open', ['-a', application, path], signal) + await run('open', ['-a', application, path], signal) } diff --git a/packages/util/native-command/tests/file-applications.spec.ts b/packages/util/native-command/tests/file-applications.spec.ts index b6f2e3328c..fd87806cc6 100644 --- a/packages/util/native-command/tests/file-applications.spec.ts +++ b/packages/util/native-command/tests/file-applications.spec.ts @@ -10,7 +10,7 @@ describe('native file associations', () => { const run = vi.fn(async () => ({ stdout: JSON.stringify([application]), stderr: '' })) const path = '/tmp/中文 $(touch nope).mp3' await expect(nativeFileApplications(path, signal, { platform: 'darwin', run })).resolves.toEqual([application]) - expect(run).toHaveBeenCalledWith('/usr/bin/osascript', ['-l', 'JavaScript', '-e', expect.any(String), path], signal) + expect(run).toHaveBeenCalledWith('osascript', ['-l', 'JavaScript', '-e', expect.any(String), path], signal) }) it.each(['{}', '[null]', '[{"id":1}]', JSON.stringify([{ ...application, icon: 'javascript:alert(1)' }])])('rejects malformed native output %s', async (stdout) => { @@ -22,7 +22,7 @@ describe('native file associations', () => { it('launches only a currently registered application with argv', async () => { const run = vi.fn(async () => ({ stdout: JSON.stringify([application]), stderr: '' })) await openNativeFileApplication('/file.mp3', application.id, signal, { platform: 'darwin', run }) - expect(run).toHaveBeenLastCalledWith('/usr/bin/open', ['-a', application.id, '/file.mp3'], signal) + expect(run).toHaveBeenLastCalledWith('open', ['-a', application.id, '/file.mp3'], signal) run.mockClear() await expect(openNativeFileApplication('/file.mp3', '/arbitrary.app', signal, { platform: 'darwin', run })).rejects.toThrow('not registered') expect(run).toHaveBeenCalledOnce() diff --git a/snapshots/web/document-preview/applications.expected.md b/snapshots/web/document-preview/applications.expected.md new file mode 100644 index 0000000000..9a6667e3e4 --- /dev/null +++ b/snapshots/web/document-preview/applications.expected.md @@ -0,0 +1,5 @@ +- menu: + - menuitem "Open in default app" + - menuitem "Test Player (default)" + - menuitem "Other Player" + - menuitem "Show file location" From 5325babc7b5287dba51e0982f94bac05bd03fb9e Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sun, 20 Sep 2026 10:43:06 +0800 Subject: [PATCH 08/62] test: cover default native command adapter selection --- .../native-command/tests/file-applications.spec.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/util/native-command/tests/file-applications.spec.ts b/packages/util/native-command/tests/file-applications.spec.ts index fd87806cc6..b8aeed5c26 100644 --- a/packages/util/native-command/tests/file-applications.spec.ts +++ b/packages/util/native-command/tests/file-applications.spec.ts @@ -1,5 +1,6 @@ /** File association results and explicit handler authorization at the native command adapter. */ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it, vi, onTestFinished } from 'vitest' +import * as runner from '../src/runner.ts' import { nativeFileApplications, openNativeFileApplication } from '../src/file-applications.ts' const application = { id: '/Applications/Music.app', name: 'Music', default: true, icon: null } @@ -35,3 +36,12 @@ describe('native file associations', () => { expect(run).not.toHaveBeenCalled() }) }) + + +it('uses the production command adapter and current platform when no override is supplied', async () => { + const run = vi.spyOn(runner, 'runNativeCommand').mockResolvedValue({ stdout: JSON.stringify([application]), stderr: '' }) + onTestFinished(() => { run.mockRestore() }) + expect(await nativeFileApplications('/file.mp3', signal)).toEqual(process.platform === 'darwin' ? [application] : []) + await openNativeFileApplication('/file.mp3', application.id, signal, { platform: 'darwin' }) + expect(run).toHaveBeenLastCalledWith('open', ['-a', application.id, '/file.mp3'], signal) +}) From 0c3045d488e2623be21558a523669e96f7ad8e31 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sun, 20 Sep 2026 10:45:21 +0800 Subject: [PATCH 09/62] fix: validate native metadata and refresh generated source references --- apps/web/tests/document-preview.e2e.ts | 4 ++-- docs/config-catalog.i18n.yaml | 4 ++-- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/event-producer-consumer.i18n.yaml | 4 ++-- docs/event-producer-consumer.md | 10 +++++----- docs/event-producer-consumer.zh.md | 10 +++++----- docs/persistence-catalog.i18n.yaml | 4 ++-- docs/persistence-catalog.md | 8 ++++---- docs/persistence-catalog.zh.md | 8 ++++---- docs/persistence-schema.json | 6 +++--- packages/util/native-command/src/file-applications.ts | 4 +++- tsconfig.base.json | 2 +- 13 files changed, 35 insertions(+), 33 deletions(-) diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index 922ea15992..dd35398713 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -89,8 +89,8 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () let outsideRoot: string | undefined let nativeRoot: string | undefined let openLog = '' - const opened = async (): Promise> => - (await readFile(openLog, 'utf8')).split('\n').filter(Boolean).map(line => JSON.parse(line) as { path: string; action: 'open' | 'reveal' }) + const opened = async (): Promise> => + (await readFile(openLog, 'utf8')).split('\n').filter(Boolean).map(line => JSON.parse(line) as { path: string; action: 'open' | 'reveal' | 'application' }) beforeAll(async () => { outsideRoot = await mkdtemp(join(tmpdir(), 'dsh-preview-outside-')) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 106e1545fb..d336b38454 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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 docs/config-catalog.md -config-catalog.md: dad6b57ae0712e04ad16729b4067c541f83c68d6 -config-catalog.zh.md: 4ba0378810f3db4797c0d41fa5230f7069438b3f +config-catalog.md: 24d48dbba4bee38feaaf2b4abd525871ee60de12 +config-catalog.zh.md: c1339795e97d2c2247b06c97d99058ee0e6f864e diff --git a/docs/config-catalog.md b/docs/config-catalog.md index dad6b57ae0..24d48dbba4 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -213,7 +213,7 @@ export interface Config { } ``` -Source: [`packages/api/session-controller/src/index.ts:77`](../packages/api/session-controller/src/index.ts) +Source: [`packages/api/session-controller/src/index.ts:78`](../packages/api/session-controller/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 4ba0378810..c1339795e9 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -215,7 +215,7 @@ export interface Config { } ``` -来源:[`packages/api/session-controller/src/index.ts:77`](../packages/api/session-controller/src/index.ts) +来源:[`packages/api/session-controller/src/index.ts:78`](../packages/api/session-controller/src/index.ts) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index f3dd91b507..f0e8b7e68c 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.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 docs/event-producer-consumer.md -event-producer-consumer.md: c26ca2d18995eb4e883815fd4f22ec4773d10728 -event-producer-consumer.zh.md: 588ec67cd55f40b5544b81603d23e65f3ebe1119 +event-producer-consumer.md: e9d9c436a8f52fcdb9de26e5de9cc3f3b9c79dbd +event-producer-consumer.zh.md: 81e38e7171f3b6c44e97f47dc18b3a1976fbe7a2 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index c26ca2d189..e9d9c436a8 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -21,11 +21,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:600`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:580`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:607`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:586`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:593`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:603`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:583`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:610`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:589`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:596`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 588ec67cd5..81e38e7171 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -23,11 +23,11 @@ | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:600`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:580`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:607`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:586`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:593`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:603`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:583`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:610`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:589`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:596`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index ae6c4b51eb..13aa1bdf41 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.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 docs/persistence-catalog.md -persistence-catalog.md: 43b4f86125847625026bd5619216ccdc7be926c1 -persistence-catalog.zh.md: c655d97086fa60630aa5dae1bf3f93be595f179b +persistence-catalog.md: d8ec0b383514434995e8825e2f1331e4e22ecc8c +persistence-catalog.zh.md: 2ac58c08611a42bb149dd89fddb4e04595aca4f1 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 43b4f86125..d8ec0b3835 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -662,7 +662,7 @@ Source: [`packages/llm/llm-retry/src/types.ts:11`](../packages/llm/llm-retry/src 'model/selection': ModelSelection ``` -Source: [`packages/api/session-controller/src/types.ts:40`](../packages/api/session-controller/src/types.ts) +Source: [`packages/api/session-controller/src/types.ts:41`](../packages/api/session-controller/src/types.ts) ### `permission/*` @@ -4066,7 +4066,7 @@ Sources: [`packages/llm/llm/src/message.ts:24`](../packages/llm/llm/src/message. SHA-256: `f09e5e7acf5ff5ccd193ff2eeec015fa2ff7c29d98bc465f8c3302058d47e29b` -Sources: [`packages/api/session-controller/src/types.ts:86`](../packages/api/session-controller/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:87`](../packages/api/session-controller/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -5304,7 +5304,7 @@ Array of `number`. SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -Sources: [`packages/api/session-controller/src/types.ts:380`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:383`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` @@ -7195,7 +7195,7 @@ Sources: [`packages/core/tools/src/index.ts:34`](../packages/core/tools/src/inde SHA-256: `6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7` -Sources: [`packages/api/session-controller/src/types.ts:385`](../packages/api/session-controller/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:388`](../packages/api/session-controller/src/types.ts) | Property | Presence | Type | |---|---|---| diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index c655d97086..2ac58c0861 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -664,7 +664,7 @@ export type SessionEvent = { 'model/selection': ModelSelection ``` -来源:[`packages/api/session-controller/src/types.ts:40`](../packages/api/session-controller/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:41`](../packages/api/session-controller/src/types.ts) ### `permission/*` @@ -4068,7 +4068,7 @@ SHA-256: `719c51f67074f97034a6c94297fe1f4b5d3c8d392c57586dab732aa5398c7a91` SHA-256: `f09e5e7acf5ff5ccd193ff2eeec015fa2ff7c29d98bc465f8c3302058d47e29b` -来源:[`packages/api/session-controller/src/types.ts:86`](../packages/api/session-controller/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:87`](../packages/api/session-controller/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -5306,7 +5306,7 @@ SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78` SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -来源:[`packages/api/session-controller/src/types.ts:380`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:383`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` @@ -7197,7 +7197,7 @@ SHA-256: `8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543` SHA-256: `6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7` -来源:[`packages/api/session-controller/src/types.ts:385`](../packages/api/session-controller/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:388`](../packages/api/session-controller/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index 428d512264..c1fe9ea857 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -44144,7 +44144,7 @@ "packages/workflow/workflow/src/types.ts#WorkflowRunId" ], "sources": [ - "packages/api/session-controller/src/types.ts:380", + "packages/api/session-controller/src/types.ts:383", "packages/attachment/attachment/src/brand.ts:6", "packages/compaction/compaction/src/brand.ts:4", "packages/core/session/src/types.ts:20", @@ -57727,7 +57727,7 @@ }, "names": [], "sources": [ - "packages/api/session-controller/src/types.ts:385" + "packages/api/session-controller/src/types.ts:388" ] }, { @@ -86253,7 +86253,7 @@ "packages/api/session-controller/src/types.ts#ModelSelection" ], "sources": [ - "packages/api/session-controller/src/types.ts:86" + "packages/api/session-controller/src/types.ts:87" ] }, { diff --git a/packages/util/native-command/src/file-applications.ts b/packages/util/native-command/src/file-applications.ts index dd6d3d7cad..50e7c215ae 100644 --- a/packages/util/native-command/src/file-applications.ts +++ b/packages/util/native-command/src/file-applications.ts @@ -54,8 +54,10 @@ export async function nativeFileApplications( const value: unknown = JSON.parse(stdout) if (!Array.isArray(value)) throw new Error('Invalid native application list') const applications: NativeFileApplication[] = [] - for (const entry of value) { + 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;base64,[A-Za-z0-9+/=]+$/.test(entry.icon)))) { diff --git a/tsconfig.base.json b/tsconfig.base.json index 3f58c32145..0e6ff1c909 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -289,6 +289,7 @@ "@deepseek-ai/dsh-session-log-export/client": ["./packages/session-query/session-log-export/src/client"], "@deepseek-ai/dsh-compaction-image-offload/projection": ["./packages/compaction/compaction-image-offload/src/projection.ts"], "@deepseek-ai/dsh-session-format-catalog/message-projections": ["./packages/session/session-format-catalog/src/message-projections.ts"], + "@deepseek-ai/dsh-native-command/types": ["./packages/util/native-command/src/types.ts"], // BEGIN generated package aliases — pnpm run gen-tsconfig-paths "@deepseek-ai/dsh-acp": ["./packages/acp/acp/src"], "@deepseek-ai/dsh-acp-app": ["./packages/bundle/acp-app/src"], @@ -366,7 +367,6 @@ "@deepseek-ai/dsh-mcp-client": ["./packages/mcp/mcp-client/src"], "@deepseek-ai/dsh-mcp-resources": ["./packages/mcp/mcp-resources/src"], "@deepseek-ai/dsh-message-feedback": ["./packages/feedback/message-feedback/src"], - "@deepseek-ai/dsh-native-command/types": ["./packages/util/native-command/src/types.ts"], "@deepseek-ai/dsh-native-command": ["./packages/util/native-command/src"], "@deepseek-ai/dsh-office-to-pdf": ["./packages/document/office-to-pdf/src"], "@deepseek-ai/dsh-output-retention": ["./packages/util/output-retention/src"], From 79798d8c5a1934b278f93b5ef62dbf22bc4cf80a Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sun, 20 Sep 2026 11:00:05 +0800 Subject: [PATCH 10/62] fix: satisfy package publication and file-action coverage gates --- .../session-open-workspace-path.host.spec.ts | 14 ++++++++++++++ .../ui-open-in-app/src/client/OpenPathAction.tsx | 2 +- .../tests/browser-plugin.client.spec.ts | 3 +++ .../tests/open-path-action.client.spec.tsx | 15 +++++++++++++-- .../ui-open-in-app/tests/open-path.client.spec.ts | 9 +++++++++ packages/util/native-command/package.json | 4 ++-- 6 files changed, 42 insertions(+), 5 deletions(-) diff --git a/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts b/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts index 63df8886bd..aaa23c11a0 100644 --- a/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts +++ b/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts @@ -237,3 +237,17 @@ it('uses the same path verification for handler queries and explicit application expect(fileApplications).toHaveBeenCalledOnce() expect(openFileApplication).toHaveBeenCalledOnce() }) + + +it('avoids desktop queries when unavailable and rejects an empty query path', async () => { + const fileApplications = vi.fn(async () => []) + const unavailable = createSessionTestController(await context(), { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', nativeOpen: false, fileApplications, + }) + expect(await unavailable.workspacePathApplications({ path: '/file.mp3' }, new AbortController().signal)).toEqual([]) + const available = createSessionTestController(await context(), { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', nativeOpen: true, fileApplications, + }) + await expect(available.workspacePathApplications({ path: '' }, new AbortController().signal)).rejects.toMatchObject({ code: 'gateway/bad-request' }) + expect(fileApplications).not.toHaveBeenCalled() +}) 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 af45eb7d1e..bc12a61103 100644 --- a/packages/client/ui-open-in-app/src/client/OpenPathAction.tsx +++ b/packages/client/ui-open-in-app/src/client/OpenPathAction.tsx @@ -125,7 +125,7 @@ export function OpenPathAction(props: OpenPathActionProps): ReactNode { ]} onSelect={(id) => { if (id.startsWith('app:')) run('open', id.slice(4)) - else if (id === 'open' || id === 'reveal') run(id) + else run(id === 'reveal' ? 'reveal' : 'open') }} anchor={(
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 14dc706604..da5f8f1c9b 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 @@ -74,6 +74,9 @@ describe('open-in-app browser half', () => { expect(face.hooks.openInAppDesktop.getSnapshot()).toBeNull() await Promise.all([face.loadDesktop(), emptyFace.loadDesktop()]) expect(remote.session.canOpenWorkspacePath).toHaveBeenCalledOnce() + const signal = new AbortController().signal + 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(remote.session.openWorkspacePath).toHaveBeenLastCalledWith({ path: '/w/clip.mp4', action: 'reveal' }) diff --git a/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx b/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx index 45dfe9272b..0b1c017fd5 100644 --- a/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx +++ b/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx @@ -5,10 +5,10 @@ * through their own toast. */ import { afterEach, describe, expect, it, vi } from 'vitest' -import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +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 { OpenPathAction, usePathGesture, 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 { zh } from '../src/client/locales.ts' @@ -186,3 +186,14 @@ it('keeps default opening available when application discovery fails', async () await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: zh['path.defaultApp'] })) }) expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'open', undefined) }) + + +it('coalesces repeated gestures before the busy state renders', async () => { + const pending = Promise.withResolvers() + const b = bench({ openPath: vi.fn(() => pending.promise) }) + const { result } = renderHook(() => usePathGesture(b.props)) + act(() => { result.current.act('open'); result.current.act('open') }) + expect(b.openPath).toHaveBeenCalledOnce() + await act(async () => { pending.resolve(null) }) + expect(result.current.pending).toBe(false) +}) diff --git a/packages/client/ui-open-in-app/tests/open-path.client.spec.ts b/packages/client/ui-open-in-app/tests/open-path.client.spec.ts index 1b1702cc2f..2def94a830 100644 --- a/packages/client/ui-open-in-app/tests/open-path.client.spec.ts +++ b/packages/client/ui-open-in-app/tests/open-path.client.spec.ts @@ -85,3 +85,12 @@ it('queries current handlers and carries an explicit application without changin await controller.openPath('/file.mp3', 'open') expect(open).toHaveBeenLastCalledWith({ path: '/file.mp3' }) }) + + +it('distinguishes failed association queries from an empty application list', async () => { + const remote = remoteOf({ workspacePathApplications: async () => ({ ok: false, error: new RemoteError('gateway/internal', 'unavailable', {}) }) }) + const controller = new OpenInAppPathController(remote) + expect(await controller.applications('/file.mp3', new AbortController().signal)).toBeNull() + const rejecting = new OpenInAppPathController(remoteOf({ workspacePathApplications: async () => { throw new Error('disconnected') } })) + expect(await rejecting.applications('/file.mp3', new AbortController().signal)).toBeNull() +}) diff --git a/packages/util/native-command/package.json b/packages/util/native-command/package.json index 391b6c023d..9fc16ba0a9 100644 --- a/packages/util/native-command/package.json +++ b/packages/util/native-command/package.json @@ -27,8 +27,8 @@ }, "files": [ "lib/index.js", - "lib/types/**/*.d.ts", - "lib/types/types.js" + "lib/types/**/*.js", + "lib/types/**/*.d.ts" ], "license": "MIT", "peerDependencies": { From dd39d56af557eeed2bac1164c9f24abc00a7d39d Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sun, 20 Sep 2026 11:02:45 +0800 Subject: [PATCH 11/62] test: remove legacy unknown assertion from path-action fixture --- .../ui-open-in-app/tests/open-path-action.client.spec.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx b/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx index 0b1c017fd5..e6c7c90b64 100644 --- a/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx +++ b/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx @@ -43,7 +43,7 @@ function bench(over: { desktop?: boolean | null; openPath?: Bench['openPath'] } applications: vi.fn(async () => []), openPath, t, - } as unknown as Bench['props'] + } as Bench['props'] return { props, desktop, loadDesktop, openPath } } From c6c9da3eba0fda0245ce2fa2f23a0dfbcf8051cf Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sun, 20 Sep 2026 11:03:54 +0800 Subject: [PATCH 12/62] chore: retire the removed path-action unknown-cast baseline --- scripts/no-unknown-casts.baseline.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/scripts/no-unknown-casts.baseline.json b/scripts/no-unknown-casts.baseline.json index f5ecfea159..6f389f9866 100644 --- a/scripts/no-unknown-casts.baseline.json +++ b/scripts/no-unknown-casts.baseline.json @@ -632,9 +632,6 @@ "d6ca469f487b05b19564776f9a8f8288b1f0d9fb5b7fb6681e68f063fb021f38": 1, "fdc0ec545dc17a50bdc1c64102915706cfbfdbb65a2165ae4691f29d577063cd": 1 }, - "packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx": { - "362d9206df82a6d4563c2e5c19457342207b02b246c91656dec01a4b2033fd8e": 1 - }, "packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts": { "dc36dd8f678695e3b84ce0573968470941c5b6fc5efe529cba17acb1f2799d1c": 1 }, From e969aaff58d9ffc5fb37354e615b1aee22c2be5a Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sun, 20 Sep 2026 11:46:50 +0800 Subject: [PATCH 13/62] refactor: share compact opening controls for files and directories --- apps/web/tests/document-preview.e2e.ts | 7 +- .../src/client/OpenInAppAction.module.css | 64 ------ .../src/client/OpenInAppAction.tsx | 172 +++------------- .../src/client/OpenPathAction.tsx | 156 ++++----------- .../src/client/OpenPathEmptyAction.module.css | 26 --- .../src/client/OpenPathEmptyAction.tsx | 31 +-- ...module.css => OpenTargetButton.module.css} | 45 ++++- .../src/client/OpenTargetButton.tsx | 143 ++++++++++++++ .../ui-open-in-app/src/client/locales.ts | 4 +- .../tests/open-in-app-action.client.spec.tsx | 187 +++++------------- .../tests/open-path-action.client.spec.tsx | 51 +++-- scripts/no-unknown-casts.baseline.json | 4 - .../document-preview/applications.expected.md | 1 - .../web/document-preview/document.expected.md | 4 +- 14 files changed, 328 insertions(+), 567 deletions(-) delete mode 100644 packages/client/ui-open-in-app/src/client/OpenInAppAction.module.css delete mode 100644 packages/client/ui-open-in-app/src/client/OpenPathEmptyAction.module.css rename packages/client/ui-open-in-app/src/client/{OpenPathAction.module.css => OpenTargetButton.module.css} (50%) create mode 100644 packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index dd35398713..171115a18b 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -748,7 +748,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) + '## Unviewable binary', '', '- State: unsupported', `- Line: ${unsupportedLine.trim()}`, - `- Header control: ${await headerOpen.innerText()}`, + `- Header control has no text: ${String(await headerOpen.innerText() === '')}`, `- Empty-state control: ${await emptyOpen.innerText()}`, ].join('\n')) if (STUB_OPENER) { @@ -757,10 +757,11 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) + await emptyOpen.click() await expect.poll(async () => (await opened()).length, { timeout: 15_000 }).toBe(1) await unsupported.locator('[data-open-path-more]').click() - await page.getByRole('menuitem', { name: 'Show file location', exact: true }).click() + await page.getByRole('menuitem', { name: /^Show file location/ }).click() await expect.poll(async () => (await opened()).length, { timeout: 15_000 }).toBe(2) const gestures = await opened() - expect(gestures[0]).toEqual({ path: clip, action: 'open' }) + expect(gestures[0]?.action).toBe(process.platform === 'darwin' ? 'open' : 'reveal') + expect([clip, cwd]).toContain(gestures[0]?.path) expect(gestures[1]?.action).toBe('reveal') expect([clip, cwd]).toContain(gestures[1]?.path) if (process.platform === 'darwin') { diff --git a/packages/client/ui-open-in-app/src/client/OpenInAppAction.module.css b/packages/client/ui-open-in-app/src/client/OpenInAppAction.module.css deleted file mode 100644 index f5186cc9a6..0000000000 --- a/packages/client/ui-open-in-app/src/client/OpenInAppAction.module.css +++ /dev/null @@ -1,64 +0,0 @@ -/* Compact Session-header split button beside the round icon buttons. */ - -.split { - display: inline-flex; - align-items: stretch; - box-sizing: border-box; - height: 24px; - border: 0.5px solid var(--dsw-alias-border-l4); - border-radius: 9px; - overflow: hidden; - font-family: var(--dsw-font-family); -} - -.main, -.chevron { - display: inline-flex; - align-items: center; - gap: 3px; - border: 0; - background: none; - color: var(--dsw-alias-label-primary); - font-size: 11px; - font-weight: 400; - line-height: 16px; - cursor: pointer; - white-space: nowrap; -} - -.main { - padding: 3px 5px; -} - -.main:hover:not(:disabled), -.main:focus-visible, -.chevron:hover, -.chevron:focus-visible { - background: var(--dsw-alias-interactive-bg-hover); -} - -.main:disabled { - color: var(--dsw-alias-label-dimmed); - cursor: wait; -} - -.main[data-state='error'] { - color: var(--dsw-alias-state-error-primary); - box-shadow: inset 0 0 0 1px var(--dsw-alias-state-error-primary); -} - -.chevron { - padding: 3px 4px 3px 3px; - border-left: 0.5px solid var(--dsw-alias-border-l4); - color: var(--dsw-alias-label-secondary); -} - -.icon { - flex: none; -} - -img.icon { - display: block; - object-fit: contain; - user-select: none; -} diff --git a/packages/client/ui-open-in-app/src/client/OpenInAppAction.tsx b/packages/client/ui-open-in-app/src/client/OpenInAppAction.tsx index 78f547a802..a58ed63933 100644 --- a/packages/client/ui-open-in-app/src/client/OpenInAppAction.tsx +++ b/packages/client/ui-open-in-app/src/client/OpenInAppAction.tsx @@ -1,10 +1,8 @@ -import { useEffect, useRef, useState } from 'react' -import { IconChevronDownOutlineRegular, Menu, Tooltip, type MenuItem } from '@deepseek-ai/dsh-client-ui-primitives' 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-conversation/client' import { NS, type OpenInAppKey } from './locales.ts' -import css from './OpenInAppAction.module.css' +import { OpenTargetButton } from './OpenTargetButton.tsx' /** Browser operations and state injected into the Session Header contribution. */ export interface OpenInAppActionInjected { @@ -65,162 +63,36 @@ const APP_LABEL_KEY: Record = { konsole: 'app.konsole', } -/** App ids whose icon image already failed this page; a 404 icon is fetched once, not per menu open. */ -const failedIcons = new Set() - /** - * One application's real bundle icon (host-served PNG) with an inline generic - * app-square fallback while the host has none. - * @param props - catalog id, host icon URL, and rendered size. - * @returns the icon image or its fallback glyph. - */ -function AppIcon({ id, url, size }: { id: string; url: string; size: number }): React.JSX.Element { - const [failed, setFailed] = useState(failedIcons.has(id)) - if (failed) { - return ( - - - - ) - } - return ( - { - failedIcons.add(id) - setFailed(true) - }} - /> - ) -} - -/** - * Quick launches settle well under this delay, so their busy dress never - * paints — the visible dim-and-wait treatment is reserved for launches that - * are actually taking a while, instead of flashing on every click. - */ -const BUSY_DRESS_DELAY_MS = 250 - -/** - * Session-header split button: the main button opens the session's workspace - * directory in the remembered application, the chevron opens the menu of - * every application the host probed as installed. It renders nothing until - * the host reported at least one nameable application and the session has a - * known workspace directory, so a host without the capability never grows - * the control. - * @param props - session runtime, injected controller face, and localized copy. - * @returns the split button and its menu, or null when there is nothing to offer. + * Adapt the installed directory catalog to the shared opening control. + * @param props - workspace state, installed catalog, and launch operations. + * @returns the shared control, or null without an eligible application and directory. */ export function OpenInAppAction(props: OpenInAppActionProps): React.JSX.Element | null { const { sessionId, useSessions, useOpenInAppApps, useOpenInAppChoice, t } = props const cwd = useSessions(state => state.byId[sessionId]?.cwd) const available = useOpenInAppApps(apps => apps) const choice = useOpenInAppChoice(id => id) - const [open, setOpen] = useState(false) - const [phase, setPhase] = useState<'idle' | 'busy' | 'error'>('idle') - const inFlight = useRef(false) - const busyTimer = useRef | undefined>(undefined) - const errorTimer = useRef | undefined>(undefined) - - useEffect(() => () => { - clearTimeout(busyTimer.current) - clearTimeout(errorTimer.current) - }, []) - - const apps = (available ?? []) - .map(id => ({ id, labelKey: APP_LABEL_KEY[id] })) - .filter((entry): entry is { id: string; labelKey: OpenInAppKey } => entry.labelKey !== undefined) - const currentEntry = apps.find(entry => entry.id === choice) ?? apps[0] - if (currentEntry === undefined || cwd === undefined || cwd === '') return null - - const current = currentEntry.id - const currentLabel = t(currentEntry.labelKey) - const title = phase === 'error' ? t('open.error') : t('open.title', { app: currentLabel }) - - const launch = (appId: string): void => { - if (inFlight.current) return - inFlight.current = true - // A pending error decay must not flip the button back to idle mid-launch. - clearTimeout(errorTimer.current) - clearTimeout(busyTimer.current) - busyTimer.current = setTimeout(() => { setPhase('busy') }, BUSY_DRESS_DELAY_MS) - props.launch(appId, cwd).then(() => { - inFlight.current = false - clearTimeout(busyTimer.current) - setPhase('idle') - }, () => { - inFlight.current = false - clearTimeout(busyTimer.current) - setPhase('error') - clearTimeout(errorTimer.current) - errorTimer.current = setTimeout(() => { setPhase('idle') }, 2_000) - }) - } - - const items: MenuItem[] = apps.map(entry => ({ - id: entry.id, - label: t(entry.labelKey), - icon: , - })) - + const apps = (available ?? []).flatMap((id) => { + const key = APP_LABEL_KEY[id] + return key === undefined ? [] : [{ id, name: t(key), icon: props.iconUrl(id) }] + }) + const preferred = apps.find(app => app.id === choice) ?? apps[0] + if (preferred === undefined || cwd === undefined || cwd === '') return null return ( - { setOpen(false) }} - items={items} - selectedId={current} - onSelect={(id) => { - setOpen(false) - // A pick while a launch is in flight is ignored whole: persisting the - // choice without launching would leave the button naming an app the - // gesture never opened. - if (inFlight.current) return - props.choose(id) - launch(id) + { + const id = operation.kind === 'application' ? operation.id : preferred.id + try { + await props.launch(id, cwd) + } catch (_error) { + // Native launch failure is announced by the shared control. + return 'openError' + } + if (operation.kind === 'application') props.choose(id) + return null }} - anchor={( -
- - - - -
- )} /> ) } 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 bc12a61103..317f392740 100644 --- a/packages/client/ui-open-in-app/src/client/OpenPathAction.tsx +++ b/packages/client/ui-open-in-app/src/client/OpenPathAction.tsx @@ -1,21 +1,13 @@ -/** - * Document-header split button: the main button opens the previewed file in - * its default application, the chevron's menu adds the file-manager reveal. - * Both path controls share the gesture hook declared here. - */ -import { useEffect, useRef, useState } from 'react' +/** File association adapter for the shared file/directory opening control. */ +import { useEffect, useState } from 'react' import type { ReactNode } from 'react' -import { - IconChevronDownOutlineRegular, IconFolderOpenOutlineRegular, IconRightUpOutlineRegular, Menu, Tooltip, -} from '@deepseek-ai/dsh-client-ui-primitives' 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, TranslateNS } 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-sidebar-documentpreview/client' import type { OpenInAppPathAction, OpenInAppPathFailure } from './open-path.ts' -import { useOpenFailureToast } from './open-failure-toast.tsx' +import { OpenTargetButton } from './OpenTargetButton.tsx' import type { NS } from './locales.ts' -import css from './OpenPathAction.module.css' /** Desktop availability and the gesture carrier injected into both path controls. */ export interface OpenPathInjected { @@ -33,130 +25,52 @@ export type OpenPathActionProps = & PropsLocale & InjectFace -/** What one path control needs from its props: the file, the injected face, and copy. */ -type PathGestureProps = Pick & { - t: TranslateNS +/** File inputs shared by the document header and its unpreviewable state. */ +type FileOpenTargetProps = Pick & { + empty?: boolean } /** - * Desktop availability, this control's own pending state, and a gesture - * runner announcing failures through this control's toast. Only the control - * that ran the gesture disables while it settles; failure never persists on - * the control. - * @param props - the file's Host path, the injected face, and copy. - * @returns availability, pending, the toast to render, and the runner. + * Resolve file associations and adapt operations without embedding platform behavior in the control. + * @param props - verified file path, desktop query, native operations, and display variant. + * @returns the shared opening control, or null without a desktop. */ -export function usePathGesture({ absolutePath, useOpenInAppDesktop, loadDesktop, openPath, t }: PathGestureProps): { - available: boolean - pending: boolean - toast: ReactNode - act: (action: OpenInAppPathAction, application?: string) => void -} { - const desktop = useOpenInAppDesktop(value => value) - const [pending, setPending] = useState(false) - const inFlight = useRef(false) - const { toast, show } = useOpenFailureToast() - useEffect(() => { - if (desktop === null) void loadDesktop() - }, [desktop, loadDesktop]) - return { - available: desktop === true, - pending, - toast, - act: (action, application) => { - if (inFlight.current) return - inFlight.current = true - setPending(true) - void openPath(absolutePath, action, application).then((failure) => { - if (failure !== null) show(t(`path.${failure}`)) - }).finally(() => { inFlight.current = false; setPending(false) }) - }, - } -} - -/** - * Render the split button, or nothing until the Host reports a desktop. - * @param props - the previewed file, the injected face, and copy. - * @returns the split button with its menu and toast, or null. - */ -export function OpenPathAction(props: OpenPathActionProps): ReactNode { - const { t } = props - const [menuOpen, setMenuOpen] = useState(false) - const { available, pending, toast, act } = usePathGesture(props) +export function FileOpenTarget(props: FileOpenTargetProps): ReactNode { + const desktop = props.useOpenInAppDesktop(value => value) + const [revision, setRevision] = useState(0) const [association, setAssociation] = useState<{ path: string apps: readonly SessionWorkspacePathApplication[] | null } | null>(null) useEffect(() => { - if (!available) return + 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() } - }, [available, props.absolutePath, props.applications, menuOpen]) + }, [desktop, props.absolutePath, props.applications, revision]) + if (desktop !== true) return null const apps = association?.path === props.absolutePath ? association.apps : null - const preferred = apps?.find(app => app.default) - if (!available) return null - const run = (action: OpenInAppPathAction, application?: string): void => { - setMenuOpen(false) - act(action, application) - } return ( - <> - { setMenuOpen(false) }} - items={[ - { id: 'open', icon: , label: t('path.defaultApp') }, - ...(apps ?? []).map(app => ({ - id: `app:${app.id}`, - icon: app.icon === null ? : , - label: app.default ? t('path.appDefault', { app: app.name }) : app.name, - })), - ...(association?.path === props.absolutePath && apps === null - ? [{ id: 'unavailable', label: t('path.appsError'), disabled: true }] : []), - { id: 'reveal', icon: , label: t('path.reveal') }, - ]} - onSelect={(id) => { - if (id.startsWith('app:')) run('open', id.slice(4)) - else run(id === 'reveal' ? 'reveal' : 'open') - }} - anchor={( -
- - - - -
- )} - /> - {toast} - + app.default)?.id} + failed={association?.path === props.absolutePath && apps === null} empty={props.empty} t={props.t} + refresh={() => { setRevision(value => value + 1) }} + execute={operation => props.openPath(props.absolutePath, operation.kind === 'reveal' ? 'reveal' : 'open', + operation.kind === 'application' ? operation.id : undefined)} + /> ) } + +/** + * Render the file adapter in the document header. + * @param props - document owner inputs and injected opening capabilities. + * @returns the shared split button. + */ +export function OpenPathAction(props: OpenPathActionProps): ReactNode { + return +} diff --git a/packages/client/ui-open-in-app/src/client/OpenPathEmptyAction.module.css b/packages/client/ui-open-in-app/src/client/OpenPathEmptyAction.module.css deleted file mode 100644 index cf9036e0d6..0000000000 --- a/packages/client/ui-open-in-app/src/client/OpenPathEmptyAction.module.css +++ /dev/null @@ -1,26 +0,0 @@ -/* The empty state's capsule, sized like the preview's own Retry - (ui-sidebar-documentpreview TextPreview `.retry`) so the two recourses read - as one family. */ -.open { - display: inline-flex; - flex: none; - gap: 6px; - align-items: center; - height: 32px; - padding: 0 12px 0 14px; - color: var(--dsw-alias-label-primary); - font-size: var(--dsh-content-font-size-secondary, 13px); - font-family: inherit; - background: transparent; - border: 0.5px solid var(--dsw-alias-border-l2); - border-radius: 16px; - cursor: pointer; -} - -.open:hover:not(:disabled) { - background: var(--dsw-alias-interactive-bg-hover); -} - -.open:disabled { - cursor: default; -} diff --git a/packages/client/ui-open-in-app/src/client/OpenPathEmptyAction.tsx b/packages/client/ui-open-in-app/src/client/OpenPathEmptyAction.tsx index bf86006ed5..8294a7ab19 100644 --- a/packages/client/ui-open-in-app/src/client/OpenPathEmptyAction.tsx +++ b/packages/client/ui-open-in-app/src/client/OpenPathEmptyAction.tsx @@ -1,11 +1,9 @@ -/** Empty-state contribution: open a file the document preview cannot render in its default application. */ +/** File opening action in an unpreviewable document's empty state. */ import type { ReactNode } from 'react' -import { IconRightUpOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' 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 { NS } from './locales.ts' -import { usePathGesture, type OpenPathInjected } from './OpenPathAction.tsx' -import css from './OpenPathEmptyAction.module.css' +import { FileOpenTarget, type OpenPathInjected } from './OpenPathAction.tsx' /** Full props of the unpreviewable empty-state contribution. */ export type OpenPathEmptyActionProps = @@ -14,27 +12,10 @@ export type OpenPathEmptyActionProps = & InjectFace /** - * Render the default-application open button, or nothing until the Host reports a desktop. - * @param props - the unpreviewable file, the injected face, and copy. - * @returns the open button and its toast, or null. + * Render the same file default action without a dropdown. + * @param props - unpreviewable file and injected opening capabilities. + * @returns the shared file opening action. */ export function OpenPathEmptyAction(props: OpenPathEmptyActionProps): ReactNode { - const { t } = props - const { available, pending, toast, act } = usePathGesture(props) - if (!available) return null - return ( - <> - - {toast} - - ) + return } diff --git a/packages/client/ui-open-in-app/src/client/OpenPathAction.module.css b/packages/client/ui-open-in-app/src/client/OpenTargetButton.module.css similarity index 50% rename from packages/client/ui-open-in-app/src/client/OpenPathAction.module.css rename to packages/client/ui-open-in-app/src/client/OpenTargetButton.module.css index 2c770905cf..66a890bfa2 100644 --- a/packages/client/ui-open-in-app/src/client/OpenPathAction.module.css +++ b/packages/client/ui-open-in-app/src/client/OpenTargetButton.module.css @@ -1,4 +1,4 @@ -/* Sized to sit in the document header's 28px control row beside its round +/* Sized to sit in the document header's 24px control row beside its round icon buttons: a hairline pill with a 12px label and a narrow chevron half. */ .menuAnchor { @@ -11,10 +11,10 @@ display: inline-flex; align-items: stretch; box-sizing: border-box; - height: 28px; + height: 24px; overflow: hidden; - border: 0.5px solid var(--dsw-alias-border-l3); - border-radius: 14px; + border: 0.5px solid var(--dsw-alias-border-l4); + border-radius: 9px; font-family: var(--dsw-font-family); } @@ -26,7 +26,7 @@ border: 0; background: none; color: var(--dsw-alias-label-primary); - font-size: 12px; + font-size: 11px; line-height: 16px; white-space: nowrap; cursor: pointer; @@ -34,12 +34,12 @@ .main { gap: 4px; - padding: 5px 8px 5px 10px; + padding: 3px 5px; } .chevron { - padding: 5px 6px 5px 4px; - border-left: 0.5px solid var(--dsw-alias-border-l3); + padding: 3px 4px 3px 3px; + border-left: 0.5px solid var(--dsw-alias-border-l4); color: var(--dsw-alias-label-secondary); } @@ -57,7 +57,32 @@ } .appIcon { - width: 16px; - height: 16px; object-fit: contain; } + +/* The empty state's capsule, sized like the preview's own Retry + (ui-sidebar-documentpreview TextPreview `.retry`) so the two recourses read + as one family. */ +.empty { + display: inline-flex; + flex: none; + gap: 6px; + align-items: center; + height: 32px; + padding: 0 12px 0 14px; + color: var(--dsw-alias-label-primary); + font-size: var(--dsh-content-font-size-secondary, 13px); + font-family: inherit; + background: transparent; + border: 0.5px solid var(--dsw-alias-border-l2); + border-radius: 16px; + cursor: pointer; +} + +.empty:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover); +} + +.empty:disabled { + cursor: default; +} diff --git a/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx b/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx new file mode 100644 index 0000000000..570e8f4914 --- /dev/null +++ b/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx @@ -0,0 +1,143 @@ +/** Shared file and directory opener: one default action, application menu, and per-gesture feedback. */ +import { useRef, useState } from 'react' +import type { ReactNode } from 'react' +import { + IconChevronDownOutlineRegular, IconFolderOpenOutlineRegular, IconRightUpOutlineRegular, Menu, Tooltip, +} from '@deepseek-ai/dsh-client-ui-primitives' +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 { NS } from './locales.ts' +import css from './OpenTargetButton.module.css' + +/** Application metadata supplied by either the directory catalog or a file association query. */ +export interface OpenTargetApplication { + readonly id: string + readonly name: string + readonly icon: string | null +} + +/** 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' } + +/** Inputs shared by both target adapters and the file empty-state action. */ +export interface OpenTargetButtonProps { + readonly kind: 'file' | 'directory' + readonly applications: readonly OpenTargetApplication[] + readonly defaultId: string | undefined + readonly failed: boolean + readonly empty?: boolean + readonly t: TranslateNS + readonly execute: (operation: OpenTargetOperation) => Promise + readonly refresh?: () => void +} + +/** + * Serialize gestures and announce their failures through the initiating control's toast. + * @param execute - target adapter that returns the failure to announce, or null. + * @param t - localized control copy. + * @returns the pending state, feedback, and guarded action callback. + */ +export function useOpenTargetGesture(execute: OpenTargetButtonProps['execute'], t: TranslateNS): { + 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) }) + }, + } +} + +/** One application image with a per-image fallback, shared by main and menu buttons. */ +function ApplicationIcon({ source, size = 14 }: { source: string | null; size?: number }): ReactNode { + const [failed, setFailed] = useState(false) + return source === null || failed + ? + : { setFailed(true) }} /> +} + +/** + * Render identical split buttons for files and directories. File reveal always stays last and becomes the default when no app is selected. + * @param props - target applications, default selection, and operations. + * @returns the control and its transient failure feedback. + */ +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 preferred = applications.find(app => app.id === defaultId) + const revealDefault = kind === 'file' && preferred === undefined + const primaryLabel = revealDefault ? t('path.reveal') : t('open.title', { app: preferred?.name ?? '' }) + const run = (operation: OpenTargetOperation): void => { setMenuOpen(false); act(operation) } + const primary = (): void => { run({ kind: revealDefault ? 'reveal' : 'default' }) } + const icon = revealDefault + ? + : + if (props.empty === true) return ( + <> + + {toast} + + ) + return ( + <> + { setMenuOpen(false) }} + items={[ + ...applications.map(app => ({ + id: `app:${app.id}`, + icon: , + label: app.id === defaultId ? t('path.appDefault', { app: app.name }) : app.name, + })), + ...(props.failed ? [{ id: 'unavailable', label: t('path.appsError'), disabled: true }] : []), + ...(kind === 'file' ? [{ + id: 'reveal', icon: , + label: revealDefault ? t('path.appDefault', { app: t('path.reveal') }) : t('path.reveal'), + }] : []), + ]} + onSelect={(id) => { run(id === 'reveal' ? { kind: 'reveal' } : { kind: 'application', id: id.slice(4) }) }} + anchor={( +
+ + + + +
+ )} + /> + {toast} + + ) +} 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 bb198d640c..77d24914f3 100644 --- a/packages/client/ui-open-in-app/src/client/locales.ts +++ b/packages/client/ui-open-in-app/src/client/locales.ts @@ -39,7 +39,7 @@ const PRODUCT_NAMES = { /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { - 'open.title': '在 {app} 中打开工作目录', + 'open.title': '在 {app} 中打开', 'open.tooltip': '在本地打开', 'open.error': '打开失败', 'menu.toggle': '选择打开方式', @@ -63,7 +63,7 @@ export const zh = { /** English dictionary, key-identical to the Chinese source of truth. */ export const en: Record = { - 'open.title': 'Open workspace in {app}', + 'open.title': 'Open in {app}', 'open.tooltip': 'Open locally', 'open.error': 'Failed to open', 'menu.toggle': 'Choose an app to open in', diff --git a/packages/client/ui-open-in-app/tests/open-in-app-action.client.spec.tsx b/packages/client/ui-open-in-app/tests/open-in-app-action.client.spec.tsx index 62f1ba2295..0116963d6e 100644 --- a/packages/client/ui-open-in-app/tests/open-in-app-action.client.spec.tsx +++ b/packages/client/ui-open-in-app/tests/open-in-app-action.client.spec.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, screen, waitFor, act } from '@testing-library/react' +import { cleanup, fireEvent, render, screen, act } from '@testing-library/react' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' @@ -37,7 +37,7 @@ function bench(over: { projectionsBySession: {}, jobsBySession: {}, currentAddress: undefined, - } as unknown as SessionListState + } as SessionListState const apps = createSnapshotStore(over.apps ?? null) const choice = createSnapshotStore(over.choice ?? '') const launch = vi.fn(over.launch ?? (async () => {})) @@ -57,7 +57,7 @@ function bench(over: { choose, iconUrl: (appId: string) => `open-in-app/icon/${appId}`, t, - } as unknown as OpenInAppActionProps + } as OpenInAppActionProps return { props, launch, choose } } @@ -88,156 +88,57 @@ describe('OpenInAppAction visibility', () => { }) describe('OpenInAppAction launching', () => { - it('launches without painting the busy dress when the launch settles quickly', async () => { - let resolve: () => void = () => {} - const b = bench({ - apps: ['finder'], - cwd: '/w/dir', - launch: () => new Promise((r) => { resolve = r }), - }) + it('disables both buttons and ignores duplicate gestures until the launch settles', async () => { + const pending = Promise.withResolvers() + const b = bench({ apps: ['finder', 'cursor'], cwd: '/w/dir', launch: () => pending.promise }) + const view = render() + const main = screen.getByRole('button', { name: t('open.title', { app: zh['app.finder'] }) }) + fireEvent.click(main) + expect(main).toHaveProperty('disabled', true) + expect(screen.getByRole('button', { name: zh['path.more'] })).toHaveProperty('disabled', true) + fireEvent.click(main) + expect(b.launch).toHaveBeenCalledOnce() + expect(view.container.querySelector('[data-open-target]')?.getAttribute('data-state')).toBe('busy') + await act(async () => { pending.resolve(undefined) }) + expect(main).toHaveProperty('disabled', false) + }) + + it('announces launch failure without changing the remembered application', async () => { + const b = bench({ apps: ['finder', 'cursor'], cwd: '/w', launch: async () => { throw new Error('gone') } }) render() - const main = screen.getByRole('button', { name: zh['open.title'].replace('{app}', zh['app.finder']) }) - fireEvent.click(main) - expect(b.launch).toHaveBeenCalledWith('finder', '/w/dir') - // No flash: the button keeps its idle dress while the launch is fast. - expect((main as HTMLButtonElement).disabled).toBe(false) - expect(main.getAttribute('data-state')).toBe('idle') - // A second click while in flight is ignored rather than double-launching. - fireEvent.click(main) - expect(b.launch).toHaveBeenCalledTimes(1) - - resolve() - await waitFor(() => { - fireEvent.click(main) - expect(b.launch).toHaveBeenCalledTimes(2) - }) + 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.choose).not.toHaveBeenCalled() }) - it('dresses a slow launch as busy, then shows the error state on failure', async () => { - vi.useFakeTimers() - let reject: (error: Error) => void = () => {} - const b = bench({ - apps: ['finder'], - cwd: '/w/dir', - launch: () => new Promise((_, r) => { reject = r }), - }) - render() - const main = screen.getByRole('button', { name: zh['open.title'].replace('{app}', zh['app.finder']) }) - fireEvent.click(main) - - // The busy dress appears only after the launch has taken a while. - act(() => { vi.advanceTimersByTime(300) }) - expect((main as HTMLButtonElement).disabled).toBe(true) - expect(main.getAttribute('data-state')).toBe('busy') - - act(() => { reject(new Error('launch failed')) }) - await act(async () => { await vi.runOnlyPendingTimersAsync() }) - expect(screen.getByRole('button', { name: zh['open.title'].replace('{app}', zh['app.finder']) })).toBeDefined() - }) - - it('shows the error state and decays back to idle after a fast failure', async () => { - const b = bench({ - apps: ['finder'], - cwd: '/w/dir', - launch: () => Promise.reject(new Error('launch failed')), - }) - render() - const main = screen.getByRole('button', { name: zh['open.title'].replace('{app}', zh['app.finder']) }) - fireEvent.click(main) - await waitFor(() => { - expect(screen.getByRole('button', { name: zh['open.error'] })).toBeDefined() - }) - // The error state decays back to idle. - await waitFor(() => { - expect(screen.getByRole('button', { name: zh['open.title'].replace('{app}', zh['app.finder']) })).toBeDefined() - }, { timeout: 4_000 }) - }) - - it('shows the product tooltip on hover instead of a native title', async () => { - render() - const main = screen.getByRole('button', { name: zh['open.title'].replace('{app}', zh['app.finder']) }) - expect(main.getAttribute('title')).toBeNull() - fireEvent.mouseEnter(main) - expect(await screen.findByText(zh['open.tooltip'])).toBeDefined() - fireEvent.mouseLeave(main) - await waitFor(() => { - expect(screen.queryByText(zh['open.tooltip'])).toBeNull() - }) - }) - - it('opens the menu from the chevron, launches and persists a picked app', async () => { + it('lists only applications, marks the remembered default, and remembers a successful choice', async () => { const b = bench({ apps: ['finder', 'cursor', 'terminal'], cwd: '/w/dir' }) render() - fireEvent.click(screen.getByRole('button', { name: zh['menu.toggle'] })) - const cursorItem = await screen.findByText('Cursor') - fireEvent.click(cursorItem) - expect(b.choose).toHaveBeenCalledWith('cursor') + fireEvent.click(screen.getByRole('button', { name: zh['path.more'] })) + expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['访达(默认)', 'Cursor', '终端']) + expect(screen.queryByText(zh['path.reveal'])).toBeNull() + await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: 'Cursor' })) }) expect(b.launch).toHaveBeenCalledWith('cursor', '/w/dir') + expect(b.choose).toHaveBeenCalledWith('cursor') }) - it('ignores a menu pick while a launch is in flight', async () => { - let resolve: () => void = () => {} - const b = bench({ - apps: ['finder', 'cursor'], - cwd: '/w/dir', - launch: () => new Promise((r) => { resolve = r }), - }) - render() - fireEvent.click(screen.getByRole('button', { name: zh['open.title'].replace('{app}', zh['app.finder']) })) - expect(b.launch).toHaveBeenCalledTimes(1) - fireEvent.click(screen.getByRole('button', { name: zh['menu.toggle'] })) - fireEvent.click(await screen.findByText('Cursor')) - // Mid-flight the pick is ignored whole: no persisted choice, no launch. - expect(b.choose).not.toHaveBeenCalled() - expect(b.launch).toHaveBeenCalledTimes(1) - resolve() - await act(async () => {}) - }) - - it('clears a pending error decay when a retry starts', async () => { - vi.useFakeTimers() - const outcomes: Array<() => Promise> = [ - () => Promise.reject(new Error('launch failed')), - // The retry stays in flight past the original decay deadline. - () => new Promise(() => {}), - ] - const b = bench({ - apps: ['finder'], - cwd: '/w/dir', - launch: () => (outcomes.shift() ?? (() => Promise.resolve()))(), - }) - render() - fireEvent.click(screen.getByRole('button', { name: zh['open.title'].replace('{app}', zh['app.finder']) })) - await act(async () => {}) - fireEvent.click(screen.getByRole('button', { name: zh['open.error'] })) - // Past the first failure's 2s decay: the stale timer must not flip the - // in-flight retry's busy dress back to a clickable idle button. - act(() => { vi.advanceTimersByTime(2_500) }) - const main = screen.getByRole('button', { name: zh['open.title'].replace('{app}', zh['app.finder']) }) - expect(main.getAttribute('data-state')).toBe('busy') - expect((main as HTMLButtonElement).disabled).toBe(true) - }) - - it('closes an open menu on Escape without launching', async () => { - const b = bench({ apps: ['finder', 'terminal'], cwd: '/w/dir' }) - render() - fireEvent.click(screen.getByRole('button', { name: zh['menu.toggle'] })) - await screen.findByText(zh['app.terminal']) - fireEvent.keyDown(document, { key: 'Escape' }) - await waitFor(() => { - expect(screen.queryByText(zh['app.terminal'])).toBeNull() - }) + it('closes on Escape without launching and falls back after an icon fails', async () => { + const b = bench({ apps: ['finder', 'terminal'], cwd: '/w' }) + const view = render() + fireEvent.click(screen.getByRole('button', { name: zh['path.more'] })) + fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' }) + expect(screen.queryByRole('menu')).toBeNull() expect(b.launch).not.toHaveBeenCalled() + const image = view.container.querySelector('img')! + fireEvent.error(image) + expect(view.container.querySelector('img')).toBeNull() + expect(view.container.querySelector('svg')).not.toBeNull() }) - it('falls back to the generic icon after a failed image load', async () => { - const b = bench({ apps: ['terminal'], cwd: '/w/dir' }) - const { container } = render() - const img = container.querySelector('img') - if (img !== null) fireEvent.error(img) - await waitFor(() => { - expect(container.querySelector('img')).toBeNull() - expect(container.querySelector('svg path[fill-opacity="0.05"]')).not.toBeNull() - }) + it('uses the shared product tooltip', async () => { + render() + fireEvent.mouseEnter(screen.getByRole('button', { name: t('open.title', { app: zh['app.finder'] }) })) + expect(await screen.findByText(zh['open.tooltip'])).toBeTruthy() }) }) diff --git a/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx b/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx index e6c7c90b64..961b066a3a 100644 --- a/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx +++ b/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx @@ -8,9 +8,10 @@ import { afterEach, describe, expect, it, vi } 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, usePathGesture, type OpenPathActionProps } from '../src/client/OpenPathAction.tsx' +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 { useOpenTargetGesture } from '../src/client/OpenTargetButton.tsx' import { zh } from '../src/client/locales.ts' afterEach(() => { @@ -40,7 +41,7 @@ function bench(over: { desktop?: boolean | null; openPath?: Bench['openPath'] } absolutePath: ABSOLUTE_PATH, useOpenInAppDesktop: useSelector(desktop), loadDesktop, - applications: vi.fn(async () => []), + applications: vi.fn(async () => [{ id: '/Music.app', name: 'Music', default: true, icon: null }]), openPath, t, } as Bench['props'] @@ -64,7 +65,7 @@ describe('OpenPathAction gestures', () => { it('opens the file in its default application from the main button and reveals it from the menu', async () => { const b = bench() render() - const main = screen.getByRole('button', { name: zh['path.open'] }) + const main = await screen.findByRole('button', { name: t('open.title', { app: 'Music' }) }) await act(async () => { fireEvent.click(main) }) expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'open', undefined) fireEvent.click(screen.getByRole('button', { name: zh['path.more'] })) @@ -76,6 +77,7 @@ describe('OpenPathAction gestures', () => { it('offers the default application from the menu too, and Escape closes the menu without a gesture', async () => { const b = bench() render() + await act(async () => {}) const more = screen.getByRole('button', { name: zh['path.more'] }) fireEvent.click(more) expect(more.getAttribute('aria-expanded')).toBe('true') @@ -83,15 +85,15 @@ describe('OpenPathAction gestures', () => { expect(screen.queryByRole('menu')).toBeNull() expect(b.openPath).not.toHaveBeenCalled() fireEvent.click(more) - await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: zh['path.defaultApp'] })) }) - expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'open', undefined) + await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: 'Music(默认)' })) }) + 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() const b = bench({ openPath: vi.fn(() => settled.promise) }) const { container } = render() - const main = screen.getByRole('button', { name: zh['path.open'] }) + const main = await screen.findByRole('button', { name: t('open.title', { app: 'Music' }) }) fireEvent.click(main) expect(main).toHaveProperty('disabled', true) expect(screen.getByRole('button', { name: zh['path.more'] })).toHaveProperty('disabled', true) @@ -112,7 +114,8 @@ describe('OpenPathEmptyAction', () => { cleanup() const b = bench() render() - await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.unpreviewable'] })) }) + await act(async () => {}) + await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.open'] })) }) expect(b.openPath).toHaveBeenCalledWith(ABSOLUTE_PATH, 'open', undefined) expect(screen.queryByRole('alert')).toBeNull() }) @@ -121,7 +124,8 @@ describe('OpenPathEmptyAction', () => { vi.useFakeTimers() const b = bench({ openPath: vi.fn(async () => 'openError' as const) }) render() - await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.unpreviewable'] })) }) + 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) }) @@ -133,9 +137,11 @@ it('dismisses a failure on schedule even when its owner rerenders', async () => vi.useFakeTimers() const b = bench({ openPath: vi.fn(async () => 'openError' as const) }) const view = render() - await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.unpreviewable'] })) }) + await act(async () => {}) + await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.open'] })) }) act(() => { vi.advanceTimersByTime(2_000) }) view.rerender() + await act(async () => {}) act(() => { vi.advanceTimersByTime(1_999) }) expect(screen.getByRole('alert')).toBeTruthy() act(() => { vi.advanceTimersByTime(1) }) @@ -157,7 +163,7 @@ it('shows the default icon and current application list, and selects an applicat expect(screen.getByRole('menuitem', { name: 'Music(默认)' })).toBeTruthy() await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: 'Player' })) }) expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'open', '/Player.app') - await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.open'] })) }) + await act(async () => { fireEvent.click(screen.getByRole('button', { name: t('open.title', { app: 'Music' }) })) }) expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'open', undefined) }) @@ -178,21 +184,34 @@ it('ignores an obsolete file query and refreshes the default application when th expect(applications).toHaveBeenLastCalledWith('/other.mp3', expect.any(AbortSignal)) }) -it('keeps default opening available when application discovery fails', async () => { +it('keeps reveal last and makes it the default when the file query fails', async () => { const b = bench() render( null} />) await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.more'] })) }) - expect(screen.getByRole('menuitem', { name: zh['path.appsError'] }).hasAttribute('disabled')).toBe(true) - await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: zh['path.defaultApp'] })) }) - expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'open', undefined) + const items = screen.getAllByRole('menuitem') + expect(items.map(item => item.textContent)).toEqual([zh['path.appsError'], '显示文件位置(默认)']) + expect(items[0]).toHaveProperty('disabled', true) + await act(async () => { fireEvent.click(items[1]!) }) + expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'reveal', undefined) +}) + +it.each([[], [{ id: '/Player.app', name: 'Player', default: false, icon: null }]])('uses reveal as the default after the available applications when none is default', async (apps) => { + const b = bench() + render( apps} />) + await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.more'] })) }) + expect(screen.getAllByRole('menuitem').at(-1)?.textContent).toBe('显示文件位置(默认)') + expect(screen.queryByText('用默认应用打开')).toBeNull() + fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' }) + await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.reveal'] })) }) + expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'reveal', undefined) }) it('coalesces repeated gestures before the busy state renders', async () => { const pending = Promise.withResolvers() const b = bench({ openPath: vi.fn(() => pending.promise) }) - const { result } = renderHook(() => usePathGesture(b.props)) - act(() => { result.current.act('open'); result.current.act('open') }) + const { result } = renderHook(() => useOpenTargetGesture(() => b.openPath(ABSOLUTE_PATH, 'open'), t)) + act(() => { result.current.act({ kind: 'default' }); result.current.act({ kind: 'default' }) }) expect(b.openPath).toHaveBeenCalledOnce() await act(async () => { pending.resolve(null) }) expect(result.current.pending).toBe(false) diff --git a/scripts/no-unknown-casts.baseline.json b/scripts/no-unknown-casts.baseline.json index 6f389f9866..154c57b993 100644 --- a/scripts/no-unknown-casts.baseline.json +++ b/scripts/no-unknown-casts.baseline.json @@ -628,10 +628,6 @@ "eee35b4d8641824c6eaacfe03ed60d353d81d5257d1111132f34d46a7b13ef35": 2, "ff65b54ee69109a8b059fc2a2136c71a5476b47115120c7c92a01171d517f3d7": 1 }, - "packages/client/ui-open-in-app/tests/open-in-app-action.client.spec.tsx": { - "d6ca469f487b05b19564776f9a8f8288b1f0d9fb5b7fb6681e68f063fb021f38": 1, - "fdc0ec545dc17a50bdc1c64102915706cfbfdbb65a2165ae4691f29d577063cd": 1 - }, "packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts": { "dc36dd8f678695e3b84ce0573968470941c5b6fc5efe529cba17acb1f2799d1c": 1 }, diff --git a/snapshots/web/document-preview/applications.expected.md b/snapshots/web/document-preview/applications.expected.md index 9a6667e3e4..d70632d545 100644 --- a/snapshots/web/document-preview/applications.expected.md +++ b/snapshots/web/document-preview/applications.expected.md @@ -1,5 +1,4 @@ - menu: - - menuitem "Open in default app" - menuitem "Test Player (default)" - menuitem "Other Player" - menuitem "Show file location" diff --git a/snapshots/web/document-preview/document.expected.md b/snapshots/web/document-preview/document.expected.md index e92c8cf01f..d6b251223d 100644 --- a/snapshots/web/document-preview/document.expected.md +++ b/snapshots/web/document-preview/document.expected.md @@ -81,5 +81,5 @@ - State: unsupported - Line: Preview is not available for this file type yet. -- Header control: Open -- Empty-state control: Open in default app +- Header control has no text: true +- Empty-state control: Open From 5c409c39bcd5bbcf3551ed5f52ecc7cea1454651 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sun, 20 Sep 2026 11:48:58 +0800 Subject: [PATCH 14/62] fix: type shared opening fixtures and document default actions --- packages/client/ui-open-in-app/README.i18n.yaml | 4 ++-- packages/client/ui-open-in-app/README.md | 8 ++++---- packages/client/ui-open-in-app/README.zh.md | 8 ++++---- .../client/ui-open-in-app/src/client/OpenPathAction.tsx | 2 +- .../tests/open-in-app-action.client.spec.tsx | 8 +++----- .../ui-open-in-app/tests/open-path-action.client.spec.tsx | 2 +- 6 files changed, 15 insertions(+), 17 deletions(-) diff --git a/packages/client/ui-open-in-app/README.i18n.yaml b/packages/client/ui-open-in-app/README.i18n.yaml index cc15c6a7eb..e0ff0d0e57 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: a21a0b3bf3cc5dabe79dc2aa907640d66c8b25c5 -README.zh.md: cbbefaa2d619f591167f7b7775b2eb4c2c945ff1 +README.md: e38909417c715e6b953dfe54c24b85dda351045c +README.zh.md: 301e9cf9bba736c9056a5df3cb59b320f998f500 diff --git a/packages/client/ui-open-in-app/README.md b/packages/client/ui-open-in-app/README.md index a21a0b3bf3..e38909417c 100644 --- a/packages/client/ui-open-in-app/README.md +++ b/packages/client/ui-open-in-app/README.md @@ -29,11 +29,11 @@ Mount this plugin in the Web composition beside [`dsh-host-open-in-app`](../../h ### What to expect -The main button shows the remembered application's icon — the real application icon wherever the host extracts one (macOS bundle icons, Windows executable icons, Linux theme icons), a generic glyph where it serves none — and a design-system tooltip ("Open locally"); clicking launches immediately. The chevron opens a dense menu of the installed applications with the remembered one marked by a filled row. Availability is read once per page from the host; the last chosen application persists in the browser (`dsh.open-in-app.choice`), and a choice that is no longer installed falls back to the first available entry. A launch that finishes quickly leaves the button untouched — the dimmed busy treatment appears only after 250 ms in flight — and a failed launch shows the error tooltip and a red outline for two seconds. All copy lives in the bilingual `open-in-app` locale namespace; an application id the dictionaries cannot name is not offered. +The Session header and document header use the same 24px-high, icon-only split button with 9px corners. Both 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 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. -In the document preview, the header's "Open ▾" split button opens the previewed file in the Host's default application from its main button, and its menu offers that action and "Show file location" (the file manager's reveal). A file the preview cannot render, whether an unsupported suffix such as video or an archive or a file the reader rejected as non-text or oversized, shows an "Open in default app" button in its empty state where Retry would otherwise stand. Both controls hand the Host the absolute path the file's metadata reports, appear only after the Host answered that a desktop is available, disable only while their own gesture settles, and announce a failed gesture once through a transient toast ("Could not open. Try again." or "Could not show the file location. Try again."); nothing stays on the control afterwards. +File menus list the discovered associated applications without a separate “Open in default app” row. “Show file location” always stays last. When no default application is identified, that row reads “Show file location (default)” and the main button reveals the file. Directory menus have no reveal row. Choosing a file application does not change the system default. An unpreviewable file’s “Open” button uses the same default action. -The document header shows the default application’s icon when available. Its menu lists the file’s OS-registered applications and marks the default. Choosing an application opens this file once; the main button continues to use the system default. Associations refresh when the file or menu visibility changes; cancelled queries cannot replace another file’s results. A failed query displays a menu message while default opening and reveal remain available. Platform coverage follows [native-command](../../util/native-command/README.md). +File associations refresh when the file changes or its menu opens; cancelled queries cannot replace another file’s results. A failed query displays a menu message and leaves reveal available as the default action. File-association discovery follows [native-command](../../util/native-command/README.md); an empty result, including a platform without a discovery adapter, uses the same reveal fallback without platform branches in the control. ----- @@ -45,7 +45,7 @@ The document header shows the default application’s icon when available. Its m 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 file controls register on the document preview's `sidebar.right.tab.document.actions` and `sidebar.right.tab.document.unpreviewable` child slots, whose owner props carry the file’s absolute execution-environment path. A second page-lifetime controller ([`src/client/open-path.ts`](src/client/open-path.ts)) reads the Session Remote's `session.canOpenWorkspacePath` once per page into a desktop snapshot store both controls share, and runs each gesture through `session.openWorkspacePath` with the path and, for reveal, `action: 'reveal'`; a refused or rejected call resolves as the failure kind the control announces. The controls share one gesture hook that owns the pending flag and the toast, so a gesture from one control never restyles the other. The node half is an empty `apply` that keeps the plugin on the host roster. +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 their native operations pass through [`open-path.ts`](src/client/open-path.ts) and the Session Remote. The directory adapter keeps the existing catalog routes. 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 cbbefaa2d6..301e9cf9bb 100644 --- a/packages/client/ui-open-in-app/README.zh.md +++ b/packages/client/ui-open-in-app/README.zh.md @@ -29,11 +29,11 @@ kind: "package-reference" ### 预期行为 -主按钮显示记住的应用图标——凡主机能提取的都是应用真实图标(macOS bundle 图标、Windows 可执行文件图标、Linux 主题图标),提取不到时是通用占位图形——并带设计系统 tooltip(「在本地打开」);点击立即启动。下拉箭头打开已安装应用的紧凑菜单,记住的条目以整行填充标记。可用性每页读取一次;上次选择的应用持久化在浏览器中(`dsh.open-in-app.choice`),不再安装的选择回退到第一个可用条目。快速完成的启动不改变按钮外观——变暗的等待态只在飞行超过 250 毫秒后出现——失败的启动显示错误 tooltip 与红色描边两秒。所有文案在双语 `open-in-app` locale 命名空间中;词典无法命名的应用 id 不会被提供。 +会话标题栏和文档标题栏共用同一个高 24px、圆角 9px 的纯图标分体按钮。两者都显示默认动作的图标,在菜单的默认应用后标注“(默认)”,仅在自己的操作执行期间禁用,并通过短暂提示报告失败。目录适配器使用已有的跨平台应用列表,将最后一次成功选择保存在 `dsh.open-in-app.choice` 中;原选择不可用时回退到第一个可用应用。 -在文档预览里,头部的「打开 ▾」分体按钮的主按钮用 Host 默认应用打开当前文件,菜单提供同一动作和「显示文件位置」(文件管理器的定位)。预览无法渲染的文件,无论是视频、压缩包这类不支持的后缀,还是被读取器判为非文本或过大的文件,都会在空态里原本放重试的位置显示「用默认应用打开」按钮。两个控件都把文件元数据报出的绝对路径交给 Host,只在 Host 回答有桌面之后出现,只在自己的手势结算期间禁用,失败时通过短暂 toast 提示一次(「打开失败,请重试」或「无法显示文件位置,请重试」),之后控件上不残留任何状态。 +文件菜单列出已发现的关联应用,不单列“用默认应用打开”。“显示文件位置”始终放在最后。未识别到默认应用时,该项显示为“显示文件位置(默认)”,主按钮也执行文件定位。目录菜单不包含定位项。选择文件应用不修改系统默认应用。无法预览文件时,空态中的“打开”按钮也使用相同的默认动作。 -文档标题栏在可用时显示默认应用图标。菜单列出系统为该文件注册的应用,并标记默认项。选择应用只用于本次打开,主按钮始终使用系统默认应用。文件或菜单显示状态变化时会重新查询关联应用;已取消的查询不会覆盖其他文件的结果。查询失败时菜单显示提示,默认打开和定位仍可使用。平台支持范围见 [native-command](../../util/native-command/README.zh.md)。 +文件变化或打开菜单时会重新查询文件关联;已取消的查询不会覆盖其他文件的结果。查询失败时菜单显示提示,并以文件定位作为默认动作。文件关联查询的平台支持范围见 [native-command](../../util/native-command/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 的定时器驱动。 -文件控件注册在文档预览的 `sidebar.right.tab.document.actions` 与 `sidebar.right.tab.document.unpreviewable` 子 slot 上,owner props 携带文件在执行环境中的绝对路径。第二个页面生命周期的 controller([`src/client/open-path.ts`](src/client/open-path.ts))每页读取一次 Session Remote 的 `session.canOpenWorkspacePath`,写进两个控件共用的桌面 snapshot store,并把每个手势交给 `session.openWorkspacePath`,带上路径,定位时再带 `action: 'reveal'`;被拒绝或抛错的调用解析为控件要提示的失败种类。两个控件共用一个手势 hook,由它持有 pending 标志和 toast,所以一个控件的手势不会改变另一个控件的样子。节点半边是一个空 `apply`,让插件出现在主机侧的插件名册上。 +目录和文件适配器把应用信息与操作交给 [`OpenTargetButton`](src/client/OpenTargetButton.tsx),由它统一管理菜单顺序、默认标记、图标、尺寸和操作反馈。文件标题栏和空态共用 `FileOpenTarget`,原生操作通过 [`open-path.ts`](src/client/open-path.ts) 与 Session Remote 执行。目录适配器继续使用已有的应用列表路由,文件查询失败或不可用时无需增加平台专用的界面实现。 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 317f392740..d6c7da7f7a 100644 --- a/packages/client/ui-open-in-app/src/client/OpenPathAction.tsx +++ b/packages/client/ui-open-in-app/src/client/OpenPathAction.tsx @@ -58,7 +58,7 @@ export function FileOpenTarget(props: FileOpenTargetProps): ReactNode { return ( app.default)?.id} - failed={association?.path === props.absolutePath && apps === null} empty={props.empty} t={props.t} + failed={association?.path === props.absolutePath && apps === null} empty={props.empty === true} t={props.t} refresh={() => { setRevision(value => value + 1) }} 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/tests/open-in-app-action.client.spec.tsx b/packages/client/ui-open-in-app/tests/open-in-app-action.client.spec.tsx index 0116963d6e..1611bff570 100644 --- a/packages/client/ui-open-in-app/tests/open-in-app-action.client.spec.tsx +++ b/packages/client/ui-open-in-app/tests/open-in-app-action.client.spec.tsx @@ -29,15 +29,13 @@ function bench(over: { cwd?: string launch?: (appId: string, path: string) => Promise } = {}): Bench { - const state = { + const state: SessionListState = { ids: [SESSION], - byId: over.cwd === undefined ? {} : { [SESSION]: { cwd: over.cwd } }, - current: SESSION, + byId: over.cwd === undefined ? {} : { [SESSION]: { id: SESSION, displayTitle: 'Workspace', cwd: over.cwd, running: false, retainedBy: {}, blank: false, updatedAt: 0 } }, phase: 'ready', projectionsBySession: {}, jobsBySession: {}, - currentAddress: undefined, - } as SessionListState + } const apps = createSnapshotStore(over.apps ?? null) const choice = createSnapshotStore(over.choice ?? '') const launch = vi.fn(over.launch ?? (async () => {})) diff --git a/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx b/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx index 961b066a3a..6fc0b23be2 100644 --- a/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx +++ b/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx @@ -195,7 +195,7 @@ it('keeps reveal last and makes it the default when the file query fails', async expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'reveal', undefined) }) -it.each([[], [{ id: '/Player.app', name: 'Player', default: false, icon: null }]])('uses reveal as the default after the available applications when none is default', async (apps) => { +it.each([{ apps: [] }, { apps: [{ id: '/Player.app', name: 'Player', default: false, icon: null }] }])('uses reveal as the default after the available applications when none is default', async ({ apps }) => { const b = bench() render( apps} />) await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.more'] })) }) From 6111f879fd9a7fe9354f86fc846f00997269517c Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sun, 20 Sep 2026 11:50:40 +0800 Subject: [PATCH 15/62] test: verify shared opener menu toggling and SSH visibility --- apps/web/tests/open-in-app-ssh.e2e.ts | 3 +-- .../ui-open-in-app/src/client/OpenTargetButton.tsx | 2 +- .../tests/open-path-action.client.spec.tsx | 14 ++++++++++++++ 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/apps/web/tests/open-in-app-ssh.e2e.ts b/apps/web/tests/open-in-app-ssh.e2e.ts index 7323495984..ff57285d88 100644 --- a/apps/web/tests/open-in-app-ssh.e2e.ts +++ b/apps/web/tests/open-in-app-ssh.e2e.ts @@ -60,8 +60,7 @@ describe.skipIf(MODE === 'record')('web e2e: Open In under SSH', () => { ]) expect(response.status()).toBe(200) expect(await response.json()).toEqual({ apps: [] }) - expect(await page.getByRole('button', { name: /^Open workspace in / }).count()).toBe(0) - expect(await page.getByRole('button', { name: 'Choose an app to open in', exact: true }).count()).toBe(0) + expect(await page.locator('[data-open-target="directory"]').count()).toBe(0) expect(await page.evaluate(() => localStorage.getItem('dsh.open-in-app.choice'))).toBe('"vscode"') const snapshot = (await captureStableAria(page, 'role=banner', scaffold.workspaceCwd)) .split(SEED_ID).join('{{seededId}}') diff --git a/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx b/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx index 570e8f4914..b96675d8ae 100644 --- a/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx +++ b/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx @@ -79,7 +79,7 @@ export function OpenTargetButton(props: OpenTargetButtonProps): ReactNode { const { pending, toast, act } = useOpenTargetGesture(props.execute, t) const preferred = applications.find(app => app.id === defaultId) const revealDefault = kind === 'file' && preferred === undefined - const primaryLabel = revealDefault ? t('path.reveal') : t('open.title', { app: preferred?.name ?? '' }) + const primaryLabel = preferred === undefined ? t('path.reveal') : t('open.title', { app: preferred.name }) const run = (operation: OpenTargetOperation): void => { setMenuOpen(false); act(operation) } const primary = (): void => { run({ kind: revealDefault ? 'reveal' : 'default' }) } const icon = revealDefault diff --git a/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx b/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx index 6fc0b23be2..15df920d7c 100644 --- a/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx +++ b/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx @@ -216,3 +216,17 @@ it('coalesces repeated gestures before the busy state renders', async () => { await act(async () => { pending.resolve(null) }) expect(result.current.pending).toBe(false) }) + + +it('closes the application menu from its chevron without querying again', async () => { + const b = bench() + render() + await act(async () => {}) + const more = screen.getByRole('button', { name: zh['path.more'] }) + await act(async () => { fireEvent.click(more) }) + expect(screen.getByRole('menu')).toBeTruthy() + const calls = vi.mocked(b.props.applications).mock.calls.length + await act(async () => { fireEvent.click(more) }) + expect(screen.queryByRole('menu')).toBeNull() + expect(b.props.applications).toHaveBeenCalledTimes(calls) +}) From 71b7bbeb88a1916b9a27018d53ac9d0067e6989f Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sun, 20 Sep 2026 11:55:48 +0800 Subject: [PATCH 16/62] fix: wait for file associations before enabling the default action --- .../src/client/OpenPathAction.tsx | 3 ++- .../src/client/OpenTargetButton.tsx | 14 ++++++++------ .../tests/open-path-action.client.spec.tsx | 17 +++++++++++++++++ 3 files changed, 27 insertions(+), 7 deletions(-) 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 d6c7da7f7a..d7bf5aca23 100644 --- a/packages/client/ui-open-in-app/src/client/OpenPathAction.tsx +++ b/packages/client/ui-open-in-app/src/client/OpenPathAction.tsx @@ -58,7 +58,8 @@ export function FileOpenTarget(props: FileOpenTargetProps): ReactNode { return ( app.default)?.id} - failed={association?.path === props.absolutePath && apps === null} empty={props.empty === true} t={props.t} + loading={association?.path !== props.absolutePath} failed={association?.path === props.absolutePath && apps === null} + empty={props.empty === true} t={props.t} refresh={() => { setRevision(value => value + 1) }} execute={operation => props.openPath(props.absolutePath, operation.kind === 'reveal' ? 'reveal' : 'open', operation.kind === 'application' ? operation.id : undefined)} diff --git a/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx b/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx index b96675d8ae..207fee182f 100644 --- a/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx +++ b/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx @@ -26,6 +26,7 @@ export interface OpenTargetButtonProps { readonly applications: readonly OpenTargetApplication[] readonly defaultId: string | undefined readonly failed: boolean + readonly loading?: boolean readonly empty?: boolean readonly t: TranslateNS readonly execute: (operation: OpenTargetOperation) => Promise @@ -78,6 +79,7 @@ export function OpenTargetButton(props: OpenTargetButtonProps): ReactNode { const [menuOpen, setMenuOpen] = useState(false) const { pending, toast, act } = useOpenTargetGesture(props.execute, t) const preferred = applications.find(app => app.id === defaultId) + const disabled = pending || props.loading === true const revealDefault = kind === 'file' && preferred === undefined const primaryLabel = preferred === undefined ? t('path.reveal') : t('open.title', { app: preferred.name }) const run = (operation: OpenTargetOperation): void => { setMenuOpen(false); act(operation) } @@ -87,7 +89,7 @@ export function OpenTargetButton(props: OpenTargetButtonProps): ReactNode { : if (props.empty === true) return ( <> - {toast} @@ -97,7 +99,7 @@ export function OpenTargetButton(props: OpenTargetButtonProps): ReactNode { <> { run(id === 'reveal' ? { kind: 'reveal' } : { kind: 'application', id: id.slice(4) }) }} anchor={( -
+
- - {toast} - - ) + ? + : return ( <> { run(id === 'reveal' ? { kind: 'reveal' } : { kind: 'application', id: id.slice(4) }) }} anchor={( -
+
-
{collapsible &&
-
- - { setMenuOpen(false) }} - anchor={} - items={[ - { id: 'open', icon: , - label: t('presented.defaultApp') }, - { id: 'reveal', icon: , - label: t(`presented.${reveal}`) }, - ]} - onSelect={(id) => { act(id === 'reveal' ? 'reveal' : 'open') }} /> -
+
{actions}
} diff --git a/packages/client/ui-deliverables/src/client/ReviewTab.tsx b/packages/client/ui-deliverables/src/client/ReviewTab.tsx index 01ac90f1fa..c51efeeaef 100644 --- a/packages/client/ui-deliverables/src/client/ReviewTab.tsx +++ b/packages/client/ui-deliverables/src/client/ReviewTab.tsx @@ -7,11 +7,11 @@ import { useEffect, useMemo, useRef, useState } from 'react' import type { ReactNode, UIEvent } from 'react' import { Button, IconChevronDownOutlineRegular, IconCompareSplitOutlineRegular, IconInspectOutlineRegular, - IconNowrapFillRegular, IconRightUpOutlineRegular, IconWrapFillRegular, Menu, Tooltip, + IconNowrapFillRegular, IconWrapFillRegular, Menu, Tooltip, languageForPath, useCodeHighlighter, } from '@deepseek-ai/dsh-client-ui-primitives' import type { CodeHighlighter, HighlightSpan } from '@deepseek-ai/dsh-client-ui-primitives' -import type { InjectFace, PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' +import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import { fileAddressFor } from '@deepseek-ai/dsh-util-workspace-path' import type { WorkspaceChangedFile, WorkspaceDiffHunk } from '@deepseek-ai/dsh-workspace-changes/types' @@ -45,7 +45,7 @@ export interface ReviewInjected { /** The body's composed props: the tab it draws, its store, its injected face, and its copy. */ export type ReviewTabProps = PropsRuntime<'sidebar.right.pane.tab'> & PropsStore> - & InjectFace & PropsLocale + & InjectFace & PropsLocale & PropsRenderSlots<'deliverables.file.actions'> /** One drawn line of a hunk with its line numbers on each side. */ export interface DiffRow { @@ -189,7 +189,7 @@ function Counts({ file, t }: { file: WorkspaceChangedFile } & PropsLocale { tab.actions.openResource(fileAddressFor(sessionId, cwd, file.path)) }}> } - {file !== undefined && native && - - } + {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 ? : 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: , 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, From b80722812b1ff35ce1d44d73d4853987cc76936b Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sun, 20 Sep 2026 13:55:27 +0800 Subject: [PATCH 32/62] fix: scope shared file actions to their owning views --- apps/web/tests/present-svg.e2e.ts | 2 +- apps/web/tests/present.e2e.ts | 28 +++++++++++-------- .../ui-deliverables/src/client/ReviewTab.tsx | 4 +-- .../src/client/file-actions.ts | 2 ++ .../ui-deliverables/src/client/index.ts | 2 +- .../ui-deliverables/tests/file-actions.tsx | 2 +- .../tests/present-open.host.spec.ts | 2 +- .../tests/turn-tail-spacing.client.spec.ts | 1 - .../client/ui-open-in-app/src/client/index.ts | 3 ++ 9 files changed, 27 insertions(+), 19 deletions(-) diff --git a/apps/web/tests/present-svg.e2e.ts b/apps/web/tests/present-svg.e2e.ts index 7c856df6f9..461b71e0e3 100644 --- a/apps/web/tests/present-svg.e2e.ts +++ b/apps/web/tests/present-svg.e2e.ts @@ -128,7 +128,7 @@ describe('web e2e: requested SVG is explicitly delivered', () => { it.skipIf(MODE === 'record')('replays the delivered file and Chinese conversation', async () => { await assertFinalWorkspaceSnapshot(DIR, cwd) - await expect.poll(() => page.getByRole('button', { name: `${FILE} 的更多文件操作`, exact: true }).isDisabled()).toBe(true) + expect(await page.locator('[data-presented-file] [data-open-target]').count()).toBe(0) // Delivery owns the transcript; navigation and composer chrome have separate scenarios. const aria = await captureExpandedTurnProcessAria(page, '[data-chat-flow]', scaffold.workspaceCwd) await compareOrRefreshGolden(join(DIR, 'ui.expected.md'), aria, MODE) diff --git a/apps/web/tests/present.e2e.ts b/apps/web/tests/present.e2e.ts index 1c4c5fe44f..0fa592a09a 100644 --- a/apps/web/tests/present.e2e.ts +++ b/apps/web/tests/present.e2e.ts @@ -57,6 +57,13 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con agentPresets: { roots: [], default: 'ptc' }, compareReplaySession: true, ...(MODE === 'record' ? {} : { replayFixture: FIXTURE }), }) + // File associations belong to the desktop rather than the recorded Session. + const controller = scaffold.ctx.get('sessionController') + if (controller === undefined) throw new Error('present requires Session Controller') + const nativeQuery: unknown = Reflect.get(controller, 'fileApplications') + if (typeof nativeQuery !== 'function') throw new Error('present requires native association discovery') + Reflect.set(controller, 'fileApplications', async () => [{ id: 'test-editor', name: 'Test Editor', default: true, icon: null }]) + scaffold.ctx.effect(() => () => { Reflect.set(controller, 'fileApplications', nativeQuery) }, 'present: native association fixture') disposeApproval = scaffold.ctx.on('approval/request', () => Promise.resolve('allowed-once'), { prepend: true }) scaffold.ctx.on('session/event', (_session, event) => { events.push(event) }) browser = await chromium.launch() @@ -121,7 +128,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con } const row = page.locator('[data-presented-files-row]') await row.waitFor() - expect(await row.getByRole('button', { name: /More file actions/ }).count()).toBe(2) + expect(await row.getByRole('button', { name: 'More ways to open' }).count()).toBe(2) expect(await row.getByText('report.txt', { exact: true }).innerText()).toBe('report.txt') const beforePreview = (await opened()).length const column = page.locator('[data-rightbar-col]') @@ -139,19 +146,16 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con expect(downloads).toEqual([]) await page.getByRole('button', { name: 'Collapse right sidebar', exact: true }).click() const beforeReveal = (await opened()).length - await row.getByRole('button', { name: 'More file actions for report.txt', exact: true }).click() + await row.locator('[data-presented-file]').filter({ hasText: 'report.txt' }).getByRole('button', { name: 'More ways to open', exact: true }).click() const revealResponse = page.waitForResponse(response => response.url().includes('action=reveal') && response.request().method() === 'POST') - await page.getByRole('menuitem', { name: process.platform === 'darwin' ? /Show in Finder/ : /Open containing folder/ }).click() + await page.getByRole('menuitem', { name: 'Show file location', exact: true }).click() expect((await revealResponse).status()).toBe(204) - expect(await row.getByRole('button', { name: 'Open report.txt in sidebar', exact: true }) - .evaluate(button => button === document.activeElement)).toBe(true) await expect.poll(opened).toHaveLength(beforeReveal + 1) expect((await opened()).at(-1)).toEqual({ action: 'reveal', content: null, path: await realpath(process.platform === 'darwin' ? join(cwd, 'report.txt') : cwd) }) for (const [name, bytes] of [['report.txt', 'EDITED_REPORT\n'], ['说明.txt', 'EDITED_NOTE\n']] as const) { const count = (await opened()).length const response = page.waitForResponse(response => response.url().includes('/api/present.open?') && response.request().method() === 'POST') - await row.getByRole('button', { name: `More file actions for ${name}`, exact: true }).click() - await page.getByRole('menuitem', { name: 'Open in default app', exact: true }).click() + 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 opened()).toHaveLength(count + 1) @@ -217,7 +221,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con ) const description = requiredElement(report.querySelector('span[role="status"]'), 'report status') const open = requiredElement( - report.querySelector('button[aria-label="Open report.txt in sidebar"]'), + report.querySelector('[data-open-target] button'), 'report open action', ) const icon = requiredElement(report.querySelector('svg'), 'report icon') @@ -250,7 +254,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con expect(geometry.iconWidth).toBe('20') expect(geometry.titleFontSize).toBe('13px') expect(geometry.descriptionFontSize).toBe('10px') - expect(geometry.openFontSize).toBe('12px') + expect(geometry.openFontSize).toBe('11px') await page.setViewportSize({ width: 480, height: 900 }) const row = page.locator('[data-presented-files-row]') await row.scrollIntoViewIfNeeded() @@ -263,9 +267,9 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con } const beforeDelete = (await opened()).length await unlink(join(cwd, 'report.txt')) - const missing = page.waitForResponse(response => response.url().includes('/api/present.open?')) - await page.locator('[data-presented-files-row]').getByRole('button', { name: 'More file actions for report.txt', exact: true }).click() - await page.getByRole('menuitem', { name: 'Open in default app', exact: true }).click() + const missing = page.waitForResponse(response => response.url().includes('/api/present.open?') && response.request().method() === 'POST') + await page.locator('[data-presented-file]').filter({ hasText: 'report.txt' }) + .getByRole('button', { name: 'Open in Test Editor', exact: true }).click() expect((await missing).status()).toBe(404) await page.getByText('Could not open. Click to retry.', { exact: true }).waitFor() expect(await opened()).toHaveLength(beforeDelete) diff --git a/packages/client/ui-deliverables/src/client/ReviewTab.tsx b/packages/client/ui-deliverables/src/client/ReviewTab.tsx index c51efeeaef..362f88ec9d 100644 --- a/packages/client/ui-deliverables/src/client/ReviewTab.tsx +++ b/packages/client/ui-deliverables/src/client/ReviewTab.tsx @@ -45,7 +45,7 @@ export interface ReviewInjected { /** The body's composed props: the tab it draws, its store, its injected face, and its copy. */ export type ReviewTabProps = PropsRuntime<'sidebar.right.pane.tab'> & PropsStore> - & InjectFace & PropsLocale & PropsRenderSlots<'deliverables.file.actions'> + & InjectFace & PropsLocale & PropsRenderSlots<'deliverables.review.file.actions'> /** One drawn line of a hunk with its line numbers on each side. */ export interface DiffRow { @@ -266,7 +266,7 @@ export function ReviewTab({ } - {file !== undefined && renderSlot('deliverables.file.actions', { + {file !== undefined && renderSlot('deliverables.review.file.actions', { actionUrl: changedFileUrl(sessionId, seq, index), available: native, pending: phase === 'opening' || phase === 'revealing', onAction: (action, application) => openChanged(sessionId, seq, index, action, application), diff --git a/packages/client/ui-deliverables/src/client/file-actions.ts b/packages/client/ui-deliverables/src/client/file-actions.ts index b39ab16a41..e4f9dedde8 100644 --- a/packages/client/ui-deliverables/src/client/file-actions.ts +++ b/packages/client/ui-deliverables/src/client/file-actions.ts @@ -17,5 +17,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { readonly onAction: (action: PresentedAction, application?: string) => Promise } } + /** The same file actions owned by a changed-file review tab. */ + 'deliverables.review.file.actions': SlotMap['deliverables.file.actions'] } } diff --git a/packages/client/ui-deliverables/src/client/index.ts b/packages/client/ui-deliverables/src/client/index.ts index da1e38c5e6..f000547756 100644 --- a/packages/client/ui-deliverables/src/client/index.ts +++ b/packages/client/ui-deliverables/src/client/index.ts @@ -86,7 +86,7 @@ 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' } }, + children: { 'deliverables.review.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), diff --git a/packages/client/ui-deliverables/tests/file-actions.tsx b/packages/client/ui-deliverables/tests/file-actions.tsx index 964b7b88b1..7d857bf6fc 100644 --- a/packages/client/ui-deliverables/tests/file-actions.tsx +++ b/packages/client/ui-deliverables/tests/file-actions.tsx @@ -3,6 +3,6 @@ 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) => +export const renderFileActions: PropsRenderSlots<'deliverables.file.actions' | 'deliverables.review.file.actions'>['renderSlot'] = (_name, props) => props.available ? : 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 b4e80a0b0e..b313ee0aa8 100644 --- a/packages/client/ui-deliverables/tests/present-open.host.spec.ts +++ b/packages/client/ui-deliverables/tests/present-open.host.spec.ts @@ -63,7 +63,7 @@ describe('Presented workspace file native open route', () => { const { cwd, open, file, fiber, opener, handler, ctx } = await fixture() const source = await realpath(join(cwd, file.path)) expect(presentedFileUrl(SessionId('owner'), 7, 0)).toBe('api/present.open?sessionId=owner&seq=7&index=0') - expect((await handler.fetch(new Request(`http://localhost${PRESENT_OPEN_PATH}`))).status).toBe(404) + expect((await handler.fetch(new Request(`http://localhost${PRESENT_OPEN_PATH}`))).status).toBe(400) expect((await handler.fetch(new Request('http://localhost/api/present.download?sessionId=owner&seq=7&index=0'))).status).toBe(404) for (const contents of ['current source', 'edited source']) { await writeFile(source, contents) diff --git a/packages/client/ui-deliverables/tests/turn-tail-spacing.client.spec.ts b/packages/client/ui-deliverables/tests/turn-tail-spacing.client.spec.ts index 6bd7af1bc5..9d9bb1cbc4 100644 --- a/packages/client/ui-deliverables/tests/turn-tail-spacing.client.spec.ts +++ b/packages/client/ui-deliverables/tests/turn-tail-spacing.client.spec.ts @@ -18,7 +18,6 @@ describe('deliverables layout', () => { expect(read('PresentedFileCard.tsx')).toMatch(//) expect(deliveries).toMatch(/\.fileName\s*\{[^}]*font-size:\s*13px;[^}]*line-height:\s*20px/s) expect(deliveries).toMatch(/\.description\s*\{[^}]*font-size:\s*10px;[^}]*line-height:\s*16px/s) - expect(deliveries).toMatch(/\.open\s*\{[^}]*padding:\s*4px 8px;[^}]*font-size:\s*12px;[^}]*line-height:\s*18px/s) expect(deliveries).toMatch(/\.presented\s*\{[^}]*gap:\s*10px/s) }) 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 e80e58189a..c92aff20f5 100644 --- a/packages/client/ui-open-in-app/src/client/index.ts +++ b/packages/client/ui-open-in-app/src/client/index.ts @@ -87,4 +87,7 @@ export function apply(ctx: ClientContext): void { ctx.slots.inject('deliverables.file.actions', () => ctx.slots.register({ name: 'deliverables.file.actions', id: 'open-in-app', locale: NS, }, FileRouteAction)) + ctx.slots.inject('deliverables.review.file.actions', () => ctx.slots.register({ + name: 'deliverables.review.file.actions', id: 'open-in-app', locale: NS, + }, FileRouteAction)) } From 59fdc2604d083008ad9935c9213b901865f0939f Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sun, 20 Sep 2026 13:56:09 +0800 Subject: [PATCH 33/62] test: provide the file action slot runtime in delivery fixtures --- .../ui-deliverables/tests/deliverables.client.spec.tsx | 1 + packages/client/ui-deliverables/tests/file-actions.tsx | 10 +++++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx b/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx index 65ead43368..ebc34d72e7 100644 --- a/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx @@ -42,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 { + SessionProvider: ({ children }: { children?: import('react').ReactNode }) => <>{children}, renderSlot: renderFileActions, useShowCodeDiff: (select: (value: boolean) => T): T => select(true), useSessions: (select: (state: SessionListState) => T): T => select(sessions), diff --git a/packages/client/ui-deliverables/tests/file-actions.tsx b/packages/client/ui-deliverables/tests/file-actions.tsx index 7d857bf6fc..55e29618d6 100644 --- a/packages/client/ui-deliverables/tests/file-actions.tsx +++ b/packages/client/ui-deliverables/tests/file-actions.tsx @@ -1,8 +1,12 @@ /** Capture delivery action ownership without duplicating the opening-control implementation. */ -import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' +import type { SlotMap } 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' | 'deliverables.review.file.actions'>['renderSlot'] = (_name, props) => - props.available ? : null +} From 28089001a27d912c428287be7369a933344eaca0 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sun, 20 Sep 2026 13:59:00 +0800 Subject: [PATCH 34/62] fix: complete file action slot contracts and branch coverage --- .../src/client/file-actions.ts | 6 +- .../tests/changes-open.host.spec.ts | 7 +++ .../ui-deliverables/tests/file-actions.tsx | 8 +-- .../tests/present-open.client.spec.ts | 10 ++++ .../src/client/file-applications.ts | 2 +- .../tests/browser-plugin.client.spec.ts | 1 + .../src/client/slot-catalog.ts | 60 +++++++++++++++++++ 7 files changed, 87 insertions(+), 7 deletions(-) diff --git a/packages/client/ui-deliverables/src/client/file-actions.ts b/packages/client/ui-deliverables/src/client/file-actions.ts index e4f9dedde8..1326f80b76 100644 --- a/packages/client/ui-deliverables/src/client/file-actions.ts +++ b/packages/client/ui-deliverables/src/client/file-actions.ts @@ -18,6 +18,10 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { } } /** The same file actions owned by a changed-file review tab. */ - 'deliverables.review.file.actions': SlotMap['deliverables.file.actions'] + 'deliverables.review.file.actions': { + kind: 'list' + scope: 'session' + owner: SlotMap['deliverables.file.actions']['owner'] + } } } diff --git a/packages/client/ui-deliverables/tests/changes-open.host.spec.ts b/packages/client/ui-deliverables/tests/changes-open.host.spec.ts index 443287ed97..6fda6fed4a 100644 --- a/packages/client/ui-deliverables/tests/changes-open.host.spec.ts +++ b/packages/client/ui-deliverables/tests/changes-open.host.spec.ts @@ -216,3 +216,10 @@ it('queries handlers only after file authorization and forwards explicit applica expect((await handler.fetch(new Request(url))).status).toBe(422) expect(applications).toHaveBeenCalledOnce() }) + + +it('rejects an unsupported changed-file action before invoking the desktop', async () => { + const { open, opener } = await fixture() + expect((await open('?sessionId=owner&seq=9&index=0&action=remove')).status).toBe(400) + expect(opener).not.toHaveBeenCalled() +}) diff --git a/packages/client/ui-deliverables/tests/file-actions.tsx b/packages/client/ui-deliverables/tests/file-actions.tsx index 55e29618d6..5efa0ec4db 100644 --- a/packages/client/ui-deliverables/tests/file-actions.tsx +++ b/packages/client/ui-deliverables/tests/file-actions.tsx @@ -1,12 +1,10 @@ /** Capture delivery action ownership without duplicating the opening-control implementation. */ -import type { SlotMap } from '@deepseek-ai/dsh-client-ui-slots' +import type { PropsRenderSlots, SlotMap } 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 function renderFileActions( - _name: 'deliverables.file.actions' | 'deliverables.review.file.actions', - props: SlotMap['deliverables.file.actions']['owner'], -) { +export const renderFileActions: PropsRenderSlots<'deliverables.file.actions' | 'deliverables.review.file.actions'>['renderSlot'] = (_name, input) => { + const props = input as SlotMap['deliverables.file.actions']['owner'] return props.available ? : null } diff --git a/packages/client/ui-deliverables/tests/present-open.client.spec.ts b/packages/client/ui-deliverables/tests/present-open.client.spec.ts index 35fd278616..6d6d4c4760 100644 --- a/packages/client/ui-deliverables/tests/present-open.client.spec.ts +++ b/packages/client/ui-deliverables/tests/present-open.client.spec.ts @@ -174,3 +174,13 @@ it.each(['open', 'reveal'] as const)('reports an unavailable Host path for %s wh expect(controller.state.getSnapshot()[url]).toBe('nativeUnavailable') await controller.dispose() }) + + +it('encodes an explicit application identifier without changing the file coordinates', async () => { + 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(fetcher).toHaveBeenCalledWith(`${url}&application=%2FApps%2FA%26B.app`, { method: 'POST', signal: expect.any(AbortSignal) }) + await controller.dispose() +}) 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 index 00bb9331f2..b4f27e3340 100644 --- a/packages/client/ui-open-in-app/src/client/file-applications.ts +++ b/packages/client/ui-open-in-app/src/client/file-applications.ts @@ -13,7 +13,7 @@ export function useFileApplications( target: string, query: (target: string, signal: AbortSignal) => Promise, enabled: boolean, -) { +): { apps: readonly SessionWorkspacePathApplication[]; loading: boolean; failed: boolean; refresh: () => void } { const [revision, setRevision] = useState(0) const [association, setAssociation] = useState<{ target: string 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 e538dc4fd2..43ccf7df46 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 @@ -41,6 +41,7 @@ async function bench(): Promise<{ ctx: Context; fiber: ReturnType null) ctx.provide('sessions', {}) 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 08f13fbf40..7d50d6f71a 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -1623,6 +1623,66 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 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: 'deliverables.review.file.actions', + kind: 'list', + scope: 'session', + summary: 'The same file actions owned by a changed-file review tab.', + doc: 'The same file actions owned by a changed-file review tab.', + 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: [ + '/** Slot contract table. Owners extend via declaration merging; entries are {@link SlotEntryDef}. */\nexport interface SlotMap {}', + ], + ownerPropsReferences: [ + 'SlotEntryDef', + ], + 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 \'sidebar.right.pane.tab\' (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.review.file.actions\', () => ctx.slots.register(\n { name: \'deliverables.review.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:21', + }, { key: 'main', kind: 'keyed', From 1f7e4915262008b3bc5fcd2970403157c9343975 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sun, 20 Sep 2026 14:00:41 +0800 Subject: [PATCH 35/62] docs: describe shared delivery and review file actions --- packages/client/ui-deliverables/README.i18n.yaml | 4 ++-- packages/client/ui-deliverables/README.md | 2 +- packages/client/ui-deliverables/README.zh.md | 2 +- packages/client/ui-deliverables/tests/file-actions.tsx | 2 +- packages/client/ui-open-in-app/README.i18n.yaml | 4 ++-- packages/client/ui-open-in-app/README.md | 4 ++-- packages/client/ui-open-in-app/README.zh.md | 4 ++-- 7 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index 8f8941ac01..85892e110d 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: 9073659f5b6aeb11112aaa1c4e7834bf055944e7 -README.zh.md: 7a622b6f0bcf4bc75027542cc90fe6c2e2b86471 +README.md: a8e80ede3380cef7e0550a0a63aa195642ed4e12 +README.zh.md: 929ab7f93f6ff995586bd850b655eb2e7a31ac93 diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index 9073659f5b..a8e80ede33 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -32,7 +32,7 @@ Mount this plugin alongside `ui-conversation` and the Host [workspace-changes](. 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 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 `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` and `deliverables.review.file.actions`, supplied by [ui-open-in-app](../ui-open-in-app/README.md). Association GET requests and native POST actions both validate the recorded file coordinates and Session filesystem mapping before using the Host desktop. ### The changed-files card diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 7a622b6f0b..929ab7f93f 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -32,7 +32,7 @@ kind: "package-reference" Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present`,用于声明会话文件系统可访问的最终文件,包括通过 Bash 创建的文件。创建后以 `files: [{ path, description? }]` 调用。[present 工具](../../deliverables/tool-present/README.zh.md)拥有文件数量限制和会话声明。单个交付占满一行,多个交付使用双列网格,超过四个时默认收起。卡片显示文件名、说明或文件类型,以及共享的原生打开控件。点击卡片会在右侧 Sidebar 预览文件。控件打开默认应用,并列出关联应用,文件定位始终放在最后。匹配的行内代码引用也打开 Sidebar 预览。重复声明使用收尾回复之前最近一次的说明。 -`present` 工具行在执行、成功、失败和中断状态下保留文档图标及记录的结果。原生操作共享等待状态,并在卡片显示进度或可重试错误。卡片挂载时读取桌面信息,连接更换时清除缓存。Host 没有桌面时,卡片保留 Sidebar 预览并隐藏原生控件;元数据读取失败时提供重试。交付卡片和变更对比页通过 `deliverables.file.actions` 使用 [ui-open-in-app](../ui-open-in-app/README.zh.md) 提供的控件。关联应用 GET 查询与原生 POST 操作都会先校验记录中的文件坐标及会话文件系统映射,再调用 Host 桌面。 +`present` 工具行在执行、成功、失败和中断状态下保留文档图标及记录的结果。原生操作共享等待状态,并在卡片显示进度或可重试错误。卡片挂载时读取桌面信息,连接更换时清除缓存。Host 没有桌面时,卡片保留 Sidebar 预览并隐藏原生控件;元数据读取失败时提供重试。交付卡片和变更对比页通过 `deliverables.file.actions` 和 `deliverables.review.file.actions` 使用 [ui-open-in-app](../ui-open-in-app/README.zh.md) 提供的控件。关联应用 GET 查询与原生 POST 操作都会先校验记录中的文件坐标及会话文件系统映射,再调用 Host 桌面。 ### 改动文件卡片 diff --git a/packages/client/ui-deliverables/tests/file-actions.tsx b/packages/client/ui-deliverables/tests/file-actions.tsx index 5efa0ec4db..d1904bea6a 100644 --- a/packages/client/ui-deliverables/tests/file-actions.tsx +++ b/packages/client/ui-deliverables/tests/file-actions.tsx @@ -3,7 +3,7 @@ import type { PropsRenderSlots, SlotMap } 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' | 'deliverables.review.file.actions'>['renderSlot'] = (_name, input) => { +export const renderFileActions: PropsRenderSlots<'deliverables.file.actions' | 'deliverables.review.file.actions'>['renderSlot'] = (_name: string, input: object) => { const props = input as SlotMap['deliverables.file.actions']['owner'] return props.available ? : null diff --git a/packages/client/ui-open-in-app/README.i18n.yaml b/packages/client/ui-open-in-app/README.i18n.yaml index 9f87aff18b..977cb96683 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: 010f86f19297db33511b40129d6caf36c71e49f9 -README.zh.md: ec0fe7741356c71b73f4d5de6f5dc0bb1b3be887 +README.md: 7840efdf1bb092cd538865f2b9b74b0911cfb06f +README.zh.md: 0774e5228551b8463de129f49be2ac30f05f1273 diff --git a/packages/client/ui-open-in-app/README.md b/packages/client/ui-open-in-app/README.md index 010f86f192..7840efdf1b 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. `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. +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. @@ -92,4 +92,4 @@ The feature-level decisions, including the split into the host package and this -**Runtime invariant:** No companion is published. The plugin registers one dictionary effect and three slot entries whose disposal the HMR-safety spec proves; application availability, the choice, and the desktop answer live in the controllers' snapshot stores with no second copy to diverge. +**Runtime invariant:** No companion is published. The plugin registers one dictionary effect and five slot entries whose disposal the HMR-safety spec proves; application availability, the choice, and the desktop answer live in the controllers' snapshot stores with no second copy to diverge. diff --git a/packages/client/ui-open-in-app/README.zh.md b/packages/client/ui-open-in-app/README.zh.md index ec0fe77413..0774e52285 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 在启动前重新验证指定的关联应用。`FileRouteAction` 通过 `deliverables.file.actions` 为交付卡片和变更对比页提供同一控件,其认证路由保留会话文件校验。目录适配器继续使用已有的应用列表路由,文件查询失败或不可用时无需增加平台专用的界面实现。 +目录和文件适配器把应用信息与操作交给 [`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` 为交付卡片和变更对比页提供同一控件,其认证路由保留会话文件校验。目录适配器继续使用已有的应用列表路由,文件查询失败或不可用时无需增加平台专用的界面实现。 @@ -92,4 +92,4 @@ Host 在打开或定位前通过当前文件系统验证路径。没有对应 Ho -**运行时不变式:** 不发布伴生入口。插件注册一个词典 effect 和三个 slot 条目,HMR 安全性 spec 证明它们都会在资源释放时撤销;应用可用性、选择与桌面回答存储在控制器的快照存储中,不存在可能与之分歧的第二份副本。 +**运行时不变式:** 不发布伴生入口。插件注册一个词典 effect 和五个 slot 条目,HMR 安全性 spec 证明它们都会在资源释放时撤销;应用可用性、选择与桌面回答存储在控制器的快照存储中,不存在可能与之分歧的第二份副本。 From 03fde971e2c4c16de9617861192875d2985a28f9 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sun, 20 Sep 2026 14:02:18 +0800 Subject: [PATCH 36/62] build: allow browser-safe file association metadata validation --- packages/client/tsdown.client.ts | 2 +- scripts/client-bundle-purity.spec.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index b8468c0957..5e9592ba70 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -61,7 +61,7 @@ function styleInjectionModule( * Everything else under @deepseek-ai/* is either a module-table entry * (external) or a leak the purity gate rejects. */ -export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|tools|brand|deque|output-retention|typert-protocol|util-crypto|util-values|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-token-meter\/client$|@deepseek-ai\/dsh-host-open-in-app\/shared$|@deepseek-ai\/dsh-plugin-manager\/registry$|@deepseek-ai\/dsh-agent-presets\/display$|@deepseek-ai\/dsh-spill-policy\/notice$)/ +export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|tools|brand|deque|output-retention|typert-protocol|util-crypto|util-values|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-token-meter\/client$|@deepseek-ai\/dsh-native-command\/types$|@deepseek-ai\/dsh-host-open-in-app\/shared$|@deepseek-ai\/dsh-plugin-manager\/registry$|@deepseek-ai\/dsh-agent-presets\/display$|@deepseek-ai\/dsh-spill-policy\/notice$)/ /** * Vendored framework libraries: rescoped into @deepseek-ai, so the gate below diff --git a/scripts/client-bundle-purity.spec.ts b/scripts/client-bundle-purity.spec.ts index ed3ca6a0a8..cf706b334c 100644 --- a/scripts/client-bundle-purity.spec.ts +++ b/scripts/client-bundle-purity.spec.ts @@ -141,6 +141,8 @@ describe('client bundle purity gate', () => { expect(() => resolveId('@deepseek-ai/dsh-token-meter')).toThrow(/purity/) expect(() => resolveId('@deepseek-ai/dsh-token-meter/client/internal')).toThrow(/purity/) expect(resolveId('@deepseek-ai/dsh-host-open-in-app/shared')).toBeNull() + expect(resolveId('@deepseek-ai/dsh-native-command/types')).toBeNull() + expect(() => resolveId('@deepseek-ai/dsh-native-command')).toThrow('client bundle purity') expect(() => resolveId('@deepseek-ai/dsh-host-open-in-app')).toThrow(/purity/) expect(resolveId('@deepseek-ai/dsh-plugin-manager/registry')).toBeNull() expect(() => resolveId('@deepseek-ai/dsh-plugin-manager')).toThrow(/purity/) From 6f1857ae1bd58480f447d2101190ffaee3099d4e Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sun, 20 Sep 2026 14:04:54 +0800 Subject: [PATCH 37/62] test: refresh delivery snapshots for shared file controls --- apps/web/tests/present.e2e.ts | 2 +- .../tests/present-open.client.spec.ts | 2 +- .../tests/file-route-action.client.spec.tsx | 2 +- snapshots/web/present-svg/session.v4.jsonl | 29 +++++++++++++++++++ snapshots/web/present-svg/ui.expected.md | 2 -- snapshots/web/present/session.v4.jsonl | 29 +++++++++++++++++++ 6 files changed, 61 insertions(+), 5 deletions(-) create mode 100644 snapshots/web/present-svg/session.v4.jsonl create mode 100644 snapshots/web/present/session.v4.jsonl diff --git a/apps/web/tests/present.e2e.ts b/apps/web/tests/present.e2e.ts index 0fa592a09a..125c9f6ebe 100644 --- a/apps/web/tests/present.e2e.ts +++ b/apps/web/tests/present.e2e.ts @@ -128,7 +128,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con } const row = page.locator('[data-presented-files-row]') await row.waitFor() - expect(await row.getByRole('button', { name: 'More ways to open' }).count()).toBe(2) + await expect.poll(() => row.getByRole('button', { name: 'More ways to open' }).count()).toBe(2) expect(await row.getByText('report.txt', { exact: true }).innerText()).toBe('report.txt') const beforePreview = (await opened()).length const column = page.locator('[data-rightbar-col]') diff --git a/packages/client/ui-deliverables/tests/present-open.client.spec.ts b/packages/client/ui-deliverables/tests/present-open.client.spec.ts index 6d6d4c4760..468e065ad4 100644 --- a/packages/client/ui-deliverables/tests/present-open.client.spec.ts +++ b/packages/client/ui-deliverables/tests/present-open.client.spec.ts @@ -181,6 +181,6 @@ it('encodes an explicit application identifier without changing the file coordin vi.stubGlobal('fetch', fetcher) const controller = new PresentedOpenController() expect(await controller.open(id, 2, 1, 'open', '/Apps/A&B.app')).toBeNull() - expect(fetcher).toHaveBeenCalledWith(`${url}&application=%2FApps%2FA%26B.app`, { method: 'POST', signal: expect.any(AbortSignal) }) + expect(fetcher).toHaveBeenCalledWith(`${url}&application=%2FApps%2FA%26B.app`, { method: 'POST', signal: expect.any(AbortSignal) as AbortSignal }) await controller.dispose() }) diff --git a/packages/client/ui-open-in-app/tests/file-route-action.client.spec.tsx b/packages/client/ui-open-in-app/tests/file-route-action.client.spec.tsx index 6404ec8b9e..d67cf7127c 100644 --- a/packages/client/ui-open-in-app/tests/file-route-action.client.spec.tsx +++ b/packages/client/ui-open-in-app/tests/file-route-action.client.spec.tsx @@ -17,7 +17,7 @@ it('queries the authorized route, selects a handler, and keeps reveal last', asy const p = props() render() await act(async () => {}) - expect(fetcher).toHaveBeenCalledWith(p.actionUrl, { signal: expect.any(AbortSignal) }) + expect(fetcher).toHaveBeenCalledWith(p.actionUrl, { signal: expect.any(AbortSignal) as 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'] })) }) diff --git a/snapshots/web/present-svg/session.v4.jsonl b/snapshots/web/present-svg/session.v4.jsonl new file mode 100644 index 0000000000..d844e49889 --- /dev/null +++ b/snapshots/web/present-svg/session.v4.jsonl @@ -0,0 +1,29 @@ +{"type":"session","version":4,"id":"{{session:1}}","createdAt":1788926678903,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0,"agentPreset":"standard"} +{"type":"permission/preset","data":{"preset":"workspace-write"}} +{"type":"sandbox/mode","data":{"mode":"workspace-write"}} +{"type":"approval/policy","data":{"policy":"ask"}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"简单画一个 SVG 表示冯诺依曼架构, 保存为 von-neumann.svg"}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"{{clientTimeZone}}"},"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":"简单画一个 SVG 表示冯诺依曼架构, 保存为 von-neumann.svg"}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"{{clientTimeZone}}"},"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 DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"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","contextWindow":128000}} +{"type":"session/title","data":{"title":"简单画一个 SVG 表示冯诺依曼","messageSeqs":[8],"source":{"kind":"fallback"}}} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants a simple SVG diagram of the von Neumann architecture, saved as von-neumann.svg. Let me create a clean SVG showing CPU (control unit, ALU), memory, input, and output with bidirectional data/control buses.\n\nLet me write the file."},{"type":"text","text":"我来创建一个冯诺依曼架构的 SVG 示意图。"},{"type":"tool-call","id":"call_00_yxav8LdZ9iwWpgkUnspF6361","name":"write","arguments":"{\"file_path\": \"{{cwd}}/von-neumann.svg\", \"content\": \"\\n 冯诺依曼架构\\n\\n \\n \\n \\n \\n \\n\\n \\n \\n 冯诺依曼体系结构 (Von Neumann Architecture)\\n\\n \\n \\n 中央处理器 CPU\\n\\n \\n \\n 控制单元\\n CU\\n 指令译码 · 时序控制\\n\\n \\n \\n 算术逻辑单元\\n ALU\\n 算术运算 · 逻辑运算\\n\\n \\n \\n 寄存器组\\n Registers / 缓存\\n\\n \\n \\n 内存储器 Memory\\n 存储单元: 指令和数据\\n (存放程序与数据,同一总线存取)\\n\\n \\n \\n 输入设备\\n Input\\n 键盘 · 鼠标 · 磁盘\\n\\n \\n \\n 输出设备\\n Output\\n 显示器 · 打印机\\n\\n \\n \\n \\n 地址总线\\n\\n \\n \\n ← 数据总线 / 控制总线 →\\n\\n \\n \\n \\n 数据(输入)\\n 控制\\n\\n \\n \\n \\n 数据(输出)\\n 控制\\n\\n \\n \\n 总线\\n\\n \\n 总线\\n\\n \\n 核心思想: 指令与数据以二进制形式统一存放在存储器中 —— “存储程序”\\n\\n\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":7197,"outputTokens":2153,"totalTokens":10502,"cacheReadTokens":1152,"reasoningTokens":55},"stream":[{"type":"chunk","time":1789457782571,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1789457782571,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," a"," simple"," SVG"," diagram"," of"," the"," von"," Neumann"," architecture",","," saved"," as"," von","-","neum","ann",".svg","."," Let"," me"," create"," a"," clean"," SVG"," showing"," CPU"," (","control"," unit",","," AL","U","),"," memory",","," input",","," and"," output"," with"," bidirectional"," data","/","control"," buses",".\n\n","Let"," me"," write"," the"," file","."]},{"type":"chunk","time":1789457782572,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1789457782572,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0],"texts":["我来","创建一个","冯","诺","依","曼","架构","的"," SVG"," ","示意图","。"]},{"type":"chunk","time":1789457782572,"chunk":{"type":"block-start","index":2,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1789457782572,"index":2,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"id":"call_00_yxav8LdZ9iwWpgkUnspF6361","name":"write","args":["","{","\"","file","_path","\"",": ","\"","/","private","/","var","/f","old","ers","/n","5","/f","339","h","wh","15","_g","f","4","gk","8","ns","rt","9","qm","400","00","gn","/T","/d","sh","-web","-e","2","e","-w","s","-g","Ou","Z","JC","/","works","pace","/v","on","-","neum","ann",".svg","\"",", ","\"","content","\"",": ","\"","<","svg"," xmlns","=\\\"","http","://","www",".w","3",".org","/","200","0","/s","vg","\\\""," view","Box","=\\\"","0"," ","0"," ","900"," ","560","\\\""," width","=\\\"","900","\\\""," height","=\\\"","560","\\\""," font","-family","=\\\"","Hel","vetica",","," A","rial",","," sans","-serif","\\\">\\n"," "," <","title",">","冯","诺","依","曼","架构","\\n\\n"," "," <","def","s",">\\n"," "," <","mark","er"," id","=\\\"","arrow","\\\""," marker","Width","=\\\"","10","\\\""," marker","Height","=\\\"","10","\\\""," ref","X","=\\\"","8","\\\""," ref","Y","=\\\"","3","\\\""," orient","=\\\"","auto","\\\">\\n"," "," <","path"," d","=\\\"","M","0",",","0"," L","8",",","3"," L","0",",","6"," Z","\\\""," fill","=\\\"#","555","\\\"/>\\n"," "," \\n"," "," \\n\\n"," "," \\n"," "," <","rect"," x","=\\\"","0","\\\""," y","=\\\"","0","\\\""," width","=\\\"","900","\\\""," height","=\\\"","560","\\\""," fill","=\\\"#","f","af","bf","c","\\\"/>\\n"," "," <","text"," x","=\\\"","450","\\\""," y","=\\\"","34","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","22","\\\""," font","-weight","=\\\"","bold","\\\""," fill","=\\\"#","222","\\\">","冯","诺","依","曼","体系","结构"," (","Von"," Neumann"," Architecture",")\\n\\n"," "," \\n"," "," <","rect"," x","=\\\"","300","\\\""," y","=\\\"","70","\\\""," width","=\\\"","300","\\\""," height","=\\\"","240","\\\""," rx","=\\\"","10","\\\""," fill","=\\\"#","fff","\\\""," stroke","=\\\"#","1","f","6","f","eb","\\\""," stroke","-width","=\\\"","2",".","5","\\\"/>\\n"," "," <","text"," x","=\\\"","450","\\\""," y","=\\\"","98","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","17","\\\""," font","-weight","=\\\"","bold","\\\""," fill","=\\\"#","1","f","6","f","eb","\\\">","中央","处理器"," CPU","\\n\\n"," "," \\n"," "," <","rect"," x","=\\\"","325","\\\""," y","=\\\"","118","\\\""," width","=\\\"","120","\\\""," height","=\\\"","160","\\\""," rx","=\\\"","8","\\\""," fill","=\\\"#","d","df","4","ff","\\\""," stroke","=\\\"#","54","ae","ff","\\\""," stroke","-width","=\\\"","1",".","5","\\\"/>\\n"," "," <","text"," x","=\\\"","385","\\\""," y","=\\\"","142","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","12",".","5","\\\""," font","-weight","=\\\"","bold","\\\""," fill","=\\\"#","096","9","da","\\\">","控制","单元","\\n"," "," <","text"," x","=\\\"","385","\\\""," y","=\\\"","158","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","11","\\\""," fill","=\\\"#","0","b","539","4","\\\">","CU","\\n"," "," <","text"," x","=\\\"","385","\\\""," y","=\\\"","262","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","10","\\\""," fill","=\\\"#","555","\\\">","指令","译","码"," ·"," ","时序","控制","\\n\\n"," "," \\n"," "," <","rect"," x","=\\\"","455","\\\""," y","=\\\"","118","\\\""," width","=\\\"","120","\\\""," height","=\\\"","160","\\\""," rx","=\\\"","8","\\\""," fill","=\\\"#","fff","8","c","5","\\\""," stroke","=\\\"#","d","4","a","72","c","\\\""," stroke","-width","=\\\"","1",".","5","\\\"/>\\n"," "," <","text"," x","=\\\"","515","\\\""," y","=\\\"","142","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","12",".","5","\\\""," font","-weight","=\\\"","bold","\\\""," fill","=\\\"#","9","a","670","0","\\\">","算术","逻辑","单元","\\n"," "," <","text"," x","=\\\"","515","\\\""," y","=\\\"","158","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","11","\\\""," fill","=\\\"#","7","a","520","0","\\\">","ALU","\\n"," "," <","text"," x","=\\\"","515","\\\""," y","=\\\"","262","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","10","\\\""," fill","=\\\"#","555","\\\">","算术","运算"," ·"," ","逻辑","运算","\\n\\n"," "," \\n"," "," <","rect"," x","=\\\"","330","\\\""," y","=\\\"","186","\\\""," width","=\\\"","110","\\\""," height","=\\\"","56","\\\""," rx","=\\\"","6","\\\""," fill","=\\\"#","fff","\\\""," stroke","=\\\"#","8","c","959","f","\\\""," stroke","-width","=\\\"","1",".","2","\\\""," stroke","-d","ash","array","=\\\"","4",",","3","\\\"/>\\n"," "," <","text"," x","=\\\"","385","\\\""," y","=\\\"","208","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","11","\\\""," fill","=\\\"#","444","\\\">","寄存器","组","\\n"," "," <","text"," x","=\\\"","385","\\\""," y","=\\\"","224","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","10","\\\""," fill","=\\\"#","666","\\\">","Reg","isters"," /"," ","缓存","\\n\\n"," "," \\n"," "," <","rect"," x","=\\\"","300","\\\""," y","=\\\"","400","\\\""," width","=\\\"","300","\\\""," height","=\\\"","110","\\\""," rx","=\\\"","10","\\\""," fill","=\\\"#","fff","\\\""," stroke","=\\\"#","cf","222","e","\\\""," stroke","-width","=\\\"","2",".","5","\\\"/>\\n"," "," <","text"," x","=\\\"","450","\\\""," y","=\\\"","428","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","17","\\\""," font","-weight","=\\\"","bold","\\\""," fill","=\\\"#","cf","222","e","\\\">","内","存储器"," Memory","\\n"," "," <","text"," x","=\\\"","450","\\\""," y","=\\\"","452","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","12","\\\""," fill","=\\\"#","444","\\\">","存储","单元",":"," ","指令","和数据","\\n"," "," <","text"," x","=\\\"","450","\\\""," y","=\\\"","470","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","11","\\\""," fill","=\\\"#","666","\\\">","(","存放","程序","与","数据",",","同一","总线","存取",")\\n\\n"," "," \\n"," "," <","rect"," x","=\\\"","40","\\\""," y","=\\\"","150","\\\""," width","=\\\"","170","\\\""," height","=\\\"","120","\\\""," rx","=\\\"","10","\\\""," fill","=\\\"#","fff","\\\""," stroke","=\\\"#","1","a","7","f","37","\\\""," stroke","-width","=\\\"","2",".","5","\\\"/>\\n"," "," <","text"," x","=\\\"","125","\\\""," y","=\\\"","186","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","15","\\\""," font","-weight","=\\\"","bold","\\\""," fill","=\\\"#","1","a","7","f","37","\\\">","输入","设备","\\n"," "," <","text"," x","=\\\"","125","\\\""," y","=\\\"","206","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","11","\\\""," fill","=\\\"#","444","\\\">","Input","\\n"," "," <","text"," x","=\\\"","125","\\\""," y","=\\\"","238","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","10","\\\""," fill","=\\\"#","666","\\\">","键盘"," ·"," ","鼠标"," ·"," ","磁盘","\\n\\n"," "," \\n"," "," <","rect"," x","=\\\"","690","\\\""," y","=\\\"","150","\\\""," width","=\\\"","170","\\\""," height","=\\\"","120","\\\""," rx","=\\\"","10","\\\""," fill","=\\\"#","fff","\\\""," stroke","=\\\"#","bf","398","9","\\\""," stroke","-width","=\\\"","2",".","5","\\\"/>\\n"," "," <","text"," x","=\\\"","775","\\\""," y","=\\\"","186","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","15","\\\""," font","-weight","=\\\"","bold","\\\""," fill","=\\\"#","bf","398","9","\\\">","输出","设备","\\n"," "," <","text"," x","=\\\"","775","\\\""," y","=\\\"","206","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","11","\\\""," fill","=\\\"#","444","\\\">","Output","\\n"," "," <","text"," x","=\\\"","775","\\\""," y","=\\\"","238","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","10","\\\""," fill","=\\\"#","666","\\\">","显示器"," ·"," ","打印机","\\n\\n"," "," \\n"," "," \\n"," "," <","path"," d","=\\\"","M","450",",","320"," L","450",",","398","\\\""," fill","=\\\"","none","\\\""," stroke","=\\\"#","cf","222","e","\\\""," stroke","-width","=\\\"","3","\\\"/>\\n"," "," <","text"," x","=\\\"","420","\\\""," y","=\\\"","362","\\\""," text","-an","chor","=\\\"","end","\\\""," font","-size","=\\\"","11","\\\""," fill","=\\\"#","cf","222","e","\\\""," transform","=\\\"","rotate","(-","90"," ","420"," ","362",")","\\\">","地址","总线","\\n\\n"," "," \\n"," "," <","line"," x","1","=\\\"","300","\\\""," y","1","=\\\"","355","\\\""," x","2","=\\\"","600","\\\""," y","2","=\\\"","355","\\\""," stroke","=\\\"#","555","\\\""," stroke","-width","=\\\"","1","\\\""," stroke","-d","ash","array","=\\\"","2",",","3","\\\""," opacity","=\\\"","0",".","0","\\\"/>\\n"," "," <","text"," x","=\\\"","450","\\\""," y","=\\\"","353","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","12","\\\""," font","-weight","=\\\"","bold","\\\""," fill","=\\\"#","555","\\\">","←"," ","数据","总线"," /"," ","控制","总线"," →","\\n\\n"," "," \\n"," "," <","path"," d","=\\\"","M","212",",","210"," L","298",",","210","\\\""," fill","=\\\"","none","\\\""," stroke","=\\\"#","1","a","7","f","37","\\\""," stroke","-width","=\\\"","3","\\\""," marker","-end","=\\\"","url","(#","arrow",")","\\\"/>\\n"," "," <","path"," d","=\\\"","M","212",",","196"," L","298",",","196","\\\""," fill","=\\\"","none","\\\""," stroke","=\\\"#","555","\\\""," stroke","-width","=\\\"","2","\\\""," marker","-end","=\\\"","url","(#","arrow",")","\\\"/>\\n"," "," <","text"," x","=\\\"","255","\\\""," y","=\\\"","184","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","10",".","5","\\\""," fill","=\\\"#","1","a","7","f","37","\\\">","数据","(","输入",")\\n"," "," <","text"," x","=\\\"","255","\\\""," y","=\\\"","236","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","10","\\\""," fill","=\\\"#","555","\\\">","控制","\\n\\n"," "," \\n"," "," <","path"," d","=\\\"","M","602",",","210"," L","688",",","210","\\\""," fill","=\\\"","none","\\\""," stroke","=\\\"#","bf","398","9","\\\""," stroke","-width","=\\\"","3","\\\""," marker","-end","=\\\"","url","(#","arrow",")","\\\"/>\\n"," "," <","path"," d","=\\\"","M","602",",","196"," L","688",",","196","\\\""," fill","=\\\"","none","\\\""," stroke","=\\\"#","555","\\\""," stroke","-width","=\\\"","2","\\\""," marker","-end","=\\\"","url","(#","arrow",")","\\\"/>\\n"," "," <","text"," x","=\\\"","645","\\\""," y","=\\\"","184","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","10",".","5","\\\""," fill","=\\\"#","bf","398","9","\\\">","数据","(","输出",")\\n"," "," <","text"," x","=\\\"","645","\\\""," y","=\\\"","236","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","10","\\\""," fill","=\\\"#","555","\\\">","控制","\\n\\n"," "," \\n"," "," <","path"," d","=\\\"","M","125",",","272"," L","125",",","300"," Q","125",",","340"," ","175",",","340"," L","298",",","340","\\\""," fill","=\\\"","none","\\\""," stroke","=\\\"#","8","c","959","f","\\\""," stroke","-width","=\\\"","1",".","8","\\\""," stroke","-d","ash","array","=\\\"","6",",","4","\\\""," marker","-end","=\\\"","url","(#","arrow",")","\\\"/>\\n"," "," <","text"," x","=\\\"","160","\\\""," y","=\\\"","328","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","10","\\\""," fill","=\\\"#","666","\\\""," transform","=\\\"","rotate","(-","8"," ","160"," ","328",")","\\\">","总线","\\n\\n"," "," <","path"," d","=\\\"","M","775",",","272"," L","775",",","300"," Q","775",",","340"," ","725",",","340"," L","602",",","340","\\\""," fill","=\\\"","none","\\\""," stroke","=\\\"#","8","c","959","f","\\\""," stroke","-width","=\\\"","1",".","8","\\\""," stroke","-d","ash","array","=\\\"","6",",","4","\\\""," marker","-end","=\\\"","url","(#","arrow",")","\\\"/>\\n"," "," <","text"," x","=\\\"","740","\\\""," y","=\\\"","328","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","10","\\\""," fill","=\\\"#","666","\\\""," transform","=\\\"","rotate","(","8"," ","740"," ","328",")","\\\">","总线","\\n\\n"," "," \\n"," "," <","text"," x","=\\\"","450","\\\""," y","=\\\"","530","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","11",".","5","\\\""," fill","=\\\"#","666","\\\">","核心","思想",":"," ","指令","与","数据","以","二进制","形式","统一","存放在","存储器","中"," ——"," “","存储","程序","”","\\n","\\n","\"","}"]},{"type":"chunk","time":1789457782585,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants a simple SVG diagram of the von Neumann architecture, saved as von-neumann.svg. Let me create a clean SVG showing CPU (control unit, ALU), memory, input, and output with bidirectional data/control buses.\n\nLet me write the file."}}},{"type":"chunk","time":1789457782585,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"我来创建一个冯诺依曼架构的 SVG 示意图。"}}},{"type":"chunk","time":1789457782585,"chunk":{"type":"block-end","index":2,"block":{"type":"tool-call","id":"call_00_yxav8LdZ9iwWpgkUnspF6361","name":"write","arguments":"{\"file_path\": \"{{cwd}}/von-neumann.svg\", \"content\": \"\\n 冯诺依曼架构\\n\\n \\n \\n \\n \\n \\n\\n \\n \\n 冯诺依曼体系结构 (Von Neumann Architecture)\\n\\n \\n \\n 中央处理器 CPU\\n\\n \\n \\n 控制单元\\n CU\\n 指令译码 · 时序控制\\n\\n \\n \\n 算术逻辑单元\\n ALU\\n 算术运算 · 逻辑运算\\n\\n \\n \\n 寄存器组\\n Registers / 缓存\\n\\n \\n \\n 内存储器 Memory\\n 存储单元: 指令和数据\\n (存放程序与数据,同一总线存取)\\n\\n \\n \\n 输入设备\\n Input\\n 键盘 · 鼠标 · 磁盘\\n\\n \\n \\n 输出设备\\n Output\\n 显示器 · 打印机\\n\\n \\n \\n \\n 地址总线\\n\\n \\n \\n ← 数据总线 / 控制总线 →\\n\\n \\n \\n \\n 数据(输入)\\n 控制\\n\\n \\n \\n \\n 数据(输出)\\n 控制\\n\\n \\n \\n 总线\\n\\n \\n 总线\\n\\n \\n 核心思想: 指令与数据以二进制形式统一存放在存储器中 —— “存储程序”\\n\\n\"}"}}},{"type":"chunk","time":1789457782585,"chunk":{"type":"usage","usage":{"inputTokens":7197,"outputTokens":2153,"totalTokens":10502,"cacheReadTokens":1152,"reasoningTokens":55}}},{"type":"chunk","time":1789457782585,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_00_yxav8LdZ9iwWpgkUnspF6361","name":"write","arguments":"{\"file_path\": \"{{cwd}}/von-neumann.svg\", \"content\": \"\\n 冯诺依曼架构\\n\\n \\n \\n \\n \\n \\n\\n \\n \\n 冯诺依曼体系结构 (Von Neumann Architecture)\\n\\n \\n \\n 中央处理器 CPU\\n\\n \\n \\n 控制单元\\n CU\\n 指令译码 · 时序控制\\n\\n \\n \\n 算术逻辑单元\\n ALU\\n 算术运算 · 逻辑运算\\n\\n \\n \\n 寄存器组\\n Registers / 缓存\\n\\n \\n \\n 内存储器 Memory\\n 存储单元: 指令和数据\\n (存放程序与数据,同一总线存取)\\n\\n \\n \\n 输入设备\\n Input\\n 键盘 · 鼠标 · 磁盘\\n\\n \\n \\n 输出设备\\n Output\\n 显示器 · 打印机\\n\\n \\n \\n \\n 地址总线\\n\\n \\n \\n ← 数据总线 / 控制总线 →\\n\\n \\n \\n \\n 数据(输入)\\n 控制\\n\\n \\n \\n \\n 数据(输出)\\n 控制\\n\\n \\n \\n 总线\\n\\n \\n 总线\\n\\n \\n 核心思想: 指令与数据以二进制形式统一存放在存储器中 —— “存储程序”\\n\\n\"}"}} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_00_yxav8LdZ9iwWpgkUnspF6361"},"toolCallId":"call_00_yxav8LdZ9iwWpgkUnspF6361","content":[{"type":"text","text":"{{cwd}}/von-neumann.svg\nfile\n\nCreated file\n"}],"isError":false,"id":"{{message:5}}"},"meta":{"operation":"create","diffs":[]}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":1}} +{"type":"step/start","data":{"turn":1,"step":2}} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The file is created. Let me present it as a deliverable."},{"type":"tool-call","id":"call_00_otuUentgajQb2RkFVoKT3699","name":"present","arguments":"{\"files\": [{\"path\": \"von-neumann.svg\", \"description\": \"冯诺依曼架构示意图 SVG\"}]}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":85,"outputTokens":80,"totalTokens":10661,"cacheReadTokens":10496,"reasoningTokens":14},"stream":[{"type":"chunk","time":1789457782632,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1789457782632,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," file"," is"," created","."," Let"," me"," present"," it"," as"," a"," deliver","able","."]},{"type":"chunk","time":1789457782632,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1789457782632,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"id":"call_00_otuUentgajQb2RkFVoKT3699","name":"present","args":["","{","\"","files","\"",": ","[","{\"","path","\":"," \"","von","-","neum","ann",".svg","\","," \"","description","\":"," \"","冯","诺","依","曼","架构","示意图"," SVG","\"","}]","}"]},{"type":"chunk","time":1789457782632,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file is created. Let me present it as a deliverable."}}},{"type":"chunk","time":1789457782632,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_otuUentgajQb2RkFVoKT3699","name":"present","arguments":"{\"files\": [{\"path\": \"von-neumann.svg\", \"description\": \"冯诺依曼架构示意图 SVG\"}]}"}}},{"type":"chunk","time":1789457782632,"chunk":{"type":"usage","usage":{"inputTokens":85,"outputTokens":80,"totalTokens":10661,"cacheReadTokens":10496,"reasoningTokens":14}}},{"type":"chunk","time":1789457782632,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":2,"callId":"call_00_otuUentgajQb2RkFVoKT3699","name":"present","arguments":"{\"files\": [{\"path\": \"von-neumann.svg\", \"description\": \"冯诺依曼架构示意图 SVG\"}]}"}} +{"type":"deliverables/presented","data":{"turn":1,"callId":"call_00_otuUentgajQb2RkFVoKT3699","files":[{"path":"von-neumann.svg","description":"冯诺依曼架构示意图 SVG"}]}} +{"type":"tool/result","data":{"turn":1,"step":2,"message":{"role":"tool","source":{"kind":"tool","callId":"call_00_otuUentgajQb2RkFVoKT3699"},"toolCallId":"call_00_otuUentgajQb2RkFVoKT3699","content":[{"type":"text","text":"Presented von-neumann.svg"}],"isError":false,"id":"{{message:7}}"}},"sourceEventSeqs":[19],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":2}} +{"type":"step/start","data":{"turn":1,"step":3}} +{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"已创建 `von-neumann.svg`,一个简洁的冯诺依曼架构示意图,包含:\n\n- **CPU(中央处理器)**:内含控制单元 CU、算术逻辑单元 ALU 和寄存器组\n- **内存储器 Memory**:标注\"指令和数据统一存放\"(存储程序思想)\n- **输入设备 / 输出设备**:分别用绿色和粉色表示数据流向\n- **总线连接**:CPU 与内存间的地址总线,以及 CPU 与 I/O 设备间的数据/控制线,箭头标明数据流向\n\n下方附一行核心思想说明:指令与数据以二进制统一存储在存储器中。用浏览器打开该 SVG 文件即可查看。"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:8}}"},"usage":{"inputTokens":56,"outputTokens":145,"totalTokens":10825,"cacheReadTokens":10624,"reasoningTokens":0},"stream":[{"type":"chunk","time":1789457782649,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1789457782649,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["已","创建"," `","von","-","neum","ann",".svg","`",",","一个","简洁","的","冯","诺","依","曼","架构","示意图",",","包含",":\n\n","-"," **","CPU","(","中央","处理器",")","**",":","内含","控制","单元"," CU","、","算术","逻辑","单元"," AL","U"," ","和","寄存器","组","\n","-"," **","内","存储器"," Memory","**",":","标注","\"","指令","和数据","统一","存放","\"","(","存储","程序","思想",")\n","-"," **","输入","设备"," /"," ","输出","设备","**",":","分别","用","绿色","和","粉色","表示","数据","流向","\n","-"," **","总线","连接","**",":","CPU"," ","与","内存","间的","地址","总线",",","以及"," CPU"," ","与"," I","/O"," ","设备","间的","数据","/","控制","线",",","箭头","标明","数据","流向","\n\n","下方","附","一行","核心","思想","说明",":","指令","与","数据","以","二进制","统一","存储在","存储器","中","。","用","浏览器","打开","该"," SVG"," ","文件","即可","查看","。"]},{"type":"chunk","time":1789457782650,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"已创建 `von-neumann.svg`,一个简洁的冯诺依曼架构示意图,包含:\n\n- **CPU(中央处理器)**:内含控制单元 CU、算术逻辑单元 ALU 和寄存器组\n- **内存储器 Memory**:标注\"指令和数据统一存放\"(存储程序思想)\n- **输入设备 / 输出设备**:分别用绿色和粉色表示数据流向\n- **总线连接**:CPU 与内存间的地址总线,以及 CPU 与 I/O 设备间的数据/控制线,箭头标明数据流向\n\n下方附一行核心思想说明:指令与数据以二进制统一存储在存储器中。用浏览器打开该 SVG 文件即可查看。"}}},{"type":"chunk","time":1789457782650,"chunk":{"type":"usage","usage":{"inputTokens":56,"outputTokens":145,"totalTokens":10825,"cacheReadTokens":10624,"reasoningTokens":0}}},{"type":"chunk","time":1789457782650,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":3}} +{"type":"workspace/changes","data":{"turn":1}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/snapshots/web/present-svg/ui.expected.md b/snapshots/web/present-svg/ui.expected.md index c867d85c45..47b47909c6 100644 --- a/snapshots/web/present-svg/ui.expected.md +++ b/snapshots/web/present-svg/ui.expected.md @@ -37,8 +37,6 @@ - text: 此主机没有可用的桌面,无法使用外部程序打开文件或文件夹;文件仍可在侧边栏预览 - button "在侧边栏预览 von-neumann.svg" - text: von-neumann.svg 冯诺依曼架构示意图 SVG -- button "在侧边栏打开 von-neumann.svg": 打开 -- button "von-neumann.svg 的更多文件操作" [disabled] - button "复制" - button "好的回答" - button "有问题的回答" diff --git a/snapshots/web/present/session.v4.jsonl b/snapshots/web/present/session.v4.jsonl new file mode 100644 index 0000000000..442cd7b022 --- /dev/null +++ b/snapshots/web/present/session.v4.jsonl @@ -0,0 +1,29 @@ +{"type":"session","version":4,"id":"{{session:1}}","createdAt":1788883598275,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0,"agentPreset":"ptc"} +{"type":"permission/preset","data":{"preset":"workspace-write"}} +{"type":"sandbox/mode","data":{"mode":"workspace-write"}} +{"type":"approval/policy","data":{"policy":"ask"}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use one run_code program to do the following in order. Call present for missing.txt and catch its error without creating that file. Use bash to run exactly `printf \"DELIVERED_REPORT\\n\" > report.txt; printf \"DELIVERED_NOTE\\n\" > 说明.txt`. Call present for report.txt and 说明.txt. After present succeeds, deliberately throw the string \"AFTER_PRESENT\" (not an Error object) from that same run_code program. Do not retry the program or create any other files. Finish by mentioning `report.txt` and `说明.txt` in inline code, and put PRESENT_DONE in a separate paragraph."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"{{clientTimeZone}}"},"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":"Use one run_code program to do the following in order. Call present for missing.txt and catch its error without creating that file. Use bash to run exactly `printf \"DELIVERED_REPORT\\n\" > report.txt; printf \"DELIVERED_NOTE\\n\" > 说明.txt`. Call present for report.txt and 说明.txt. After present succeeds, deliberately throw the string \"AFTER_PRESENT\" (not an Error object) from that same run_code program. Do not retry the program or create any other files. Finish by mentioning `report.txt` and `说明.txt` in inline code, and put PRESENT_DONE in a separate paragraph."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"{{clientTimeZone}}"},"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 DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"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","contextWindow":128000}} +{"type":"session/title","data":{"title":"Use one run_code program to","messageSeqs":[8],"source":{"kind":"fallback"}}} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants one run_code program that:\n1. Calls present for missing.txt and catches its error without creating that file.\n2. Uses bash to run exactly `printf \"DELIVERED_REPORT\\n\" > report.txt; printf \"DELIVERED_NOTE\\n\" > 说明.txt`.\n3. Calls present for report.txt and 说明.txt.\n4. After present succeeds, deliberately throws the string \"AFTER_PRESENT\" (not an Error object) from that same run_code program.\n5. Does not retry the program or create other files.\n6. Finishes by mentioning report.txt and 说明.txt in inline code, and put PRESENT_DONE in a separate paragraph.\n\nLet me write the program.\n\npresent tool args: { files: [{ path, description? }] }.\n\nSo first: try present for missing.txt — catch error. It should error because the file doesn't exist (present requires existing workspace file).\n\nThen bash run exact command. Note the command uses `;` inside a single string. Run as a single command string: `printf \"DELIVERED_REPORT\\n\" > report.txt; printf \"DELIVERED_NOTE\\n\" > 说明.txt`.\n\nWait — printf interprets the arguments... In bash, `printf \"DELIVERED_REPORT\\n\"` with the format string containing \\n — printf will interpret \\n as newline. Good.\n\nThen present for report.txt and 说明.txt.\n\nThen throw \"AFTER_PRESENT\" string. In TS async function body, `throw \"AFTER_PRESENT\"` works.\n\nLet me write it."},{"type":"tool-call","id":"call_00_GgvYPol45qxGf6GezK3L3590","name":"run_code","arguments":"{\"code\": \"// Step 1: present for missing.txt — must fail, catch the error, don't create the file\\ntry {\\n await tools.present({ files: [{ path: \\\"missing.txt\\\", description: \\\"must not exist\\\" }] });\\n console.log(\\\"UNEXPECTED: present succeeded for missing.txt\\\");\\n} catch (err) {\\n console.log(\\\"Caught present error for missing.txt:\\\", typeof err, String(err).slice(0, 200));\\n}\\n\\n// Step 2: run the exact bash command to create the two report files\\nconst res = await tools.bash({\\n command: 'printf \\\"DELIVERED_REPORT\\\\n\\\" > report.txt; printf \\\"DELIVERED_NOTE\\\\n\\\" > 说明.txt',\\n description: \\\"Write DELIVERED_REPORT and DELIVERED_NOTE to files\\\",\\n});\\nconsole.log(\\\"bash exit:\\\", res.exitCode);\\n\\n// Step 3: present for report.txt and 说明.txt (must succeed)\\nconst p2 = await tools.present({\\n files: [\\n { path: \\\"report.txt\\\", description: \\\"delivered report\\\" },\\n { path: \\\"说明.txt\\\", description: \\\"delivered note\\\" },\\n ],\\n});\\nconsole.log(\\\"present ok, files:\\\", p2.files.map((f) => f.path).join(\\\", \\\"));\\n\\n// Step 4: deliberately throw a plain string AFTER_PRESENT (not an Error)\\nthrow \\\"AFTER_PRESENT\\\";\", \"description\": \"Present files then deliberately throw AFTER_PRESENT\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":981,"outputTokens":695,"totalTokens":9356,"cacheReadTokens":7680,"reasoningTokens":329},"stream":[{"type":"chunk","time":1788883599120,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1788883599120,"index":0,"dt":[0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," one"," run","_code"," program"," that",":\n","1","."," Calls"," present"," for"," missing",".txt"," and"," catches"," its"," error"," without"," creating"," that"," file",".\n","2","."," Uses"," bash"," to"," run"," exactly"," `","printf"," \"","DEL","IVER","ED","_RE","PORT","\\n","\""," >"," report",".txt",";"," printf"," \"","DEL","IVER","ED","_N","OTE","\\n","\""," >"," ","说明",".txt","`.\n","3","."," Calls"," present"," for"," report",".txt"," and"," ","说明",".txt",".\n","4","."," After"," present"," succeeds",","," deliberately"," throws"," the"," string"," \"","AF","TER","_P","RES","ENT","\""," (","not"," an"," Error"," object",")"," from"," that"," same"," run","_code"," program",".\n","5","."," Does"," not"," ret","ry"," the"," program"," or"," create"," other"," files",".\n","6","."," Fin","ishes"," by"," mentioning"," report",".txt"," and"," ","说明",".txt"," in"," inline"," code",","," and"," put"," PRES","ENT","_D","ONE"," in"," a"," separate"," paragraph",".\n\n","Let"," me"," write"," the"," program",".\n\n","present"," tool"," args",":"," {"," files",":"," [{"," path",","," description","?"," }","]"," }",".\n\n","So"," first",":"," try"," present"," for"," missing",".txt"," —"," catch"," error","."," It"," should"," error"," because"," the"," file"," doesn","'t"," exist"," (","present"," requires"," existing"," workspace"," file",").\n\n","Then"," bash"," run"," exact"," command","."," Note"," the"," command"," uses"," `",";","`"," inside"," a"," single"," string","."," Run"," as"," a"," single"," command"," string",":"," `","printf"," \"","DEL","IVER","ED","_RE","PORT","\\n","\""," >"," report",".txt",";"," printf"," \"","DEL","IVER","ED","_N","OTE","\\n","\""," >"," ","说明",".txt","`.\n\n","Wait"," —"," printf"," interprets"," the"," arguments","..."," In"," bash",","," `","printf"," \"","DEL","IVER","ED","_RE","PORT","\\n","\"","`"," with"," the"," format"," string"," containing"," \\","n"," —"," printf"," will"," interpret"," \\","n"," as"," new","line","."," Good",".\n\n","Then"," present"," for"," report",".txt"," and"," ","说明",".txt",".\n\n","Then"," throw"," \"","AF","TER","_P","RES","ENT","\""," string","."," In"," TS"," async"," function"," body",","," `","throw"," \"","AF","TER","_P","RES","ENT","\"","`"," works",".\n\n","Let"," me"," write"," it","."]},{"type":"chunk","time":1788883599123,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1788883599123,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"id":"call_00_GgvYPol45qxGf6GezK3L3590","name":"run_code","args":["","{","\"","code","\"",": ","\"","//"," Step"," ","1",":"," present"," for"," missing",".txt"," —"," must"," fail",","," catch"," the"," error",","," don","'t"," create"," the"," file","\\n","try"," {\\n"," "," await"," tools",".p","resent","({"," files",":"," [{"," path",":"," \\\"","missing",".txt","\\\","," description",":"," \\\"","must"," not"," exist","\\\""," }","]"," });\\n"," "," console",".log","(\\\"","UN","EXP","ECT","ED",":"," present"," succeeded"," for"," missing",".txt","\\\");\\n","}"," catch"," (","err",")"," {\\n"," "," console",".log","(\\\"","Ca","ught"," present"," error"," for"," missing",".txt",":\\\","," typeof"," err",","," String","(err",").","slice","(","0",","," ","200","));\\n","}\\n\\n","//"," Step"," ","2",":"," run"," the"," exact"," bash"," command"," to"," create"," the"," two"," report"," files","\\n","const"," res"," ="," await"," tools",".b","ash","({\\n"," "," command",":"," '","printf"," \\\"","DEL","IVER","ED","_RE","PORT","\\\\n","\\\""," >"," report",".txt",";"," printf"," \\\"","DEL","IVER","ED","_N","OTE","\\\\n","\\\""," >"," ","说明",".txt","',\\n"," "," description",":"," \\\"","Write"," DEL","IVER","ED","_RE","PORT"," and"," DEL","IVER","ED","_N","OTE"," to"," files","\\\",\\n","});\\n","console",".log","(\\\"","bash"," exit",":\\\","," res",".exit","Code",");\\n\\n","//"," Step"," ","3",":"," present"," for"," report",".txt"," and"," ","说明",".txt"," (","must"," succeed",")\\n","const"," p","2"," ="," await"," tools",".p","resent","({\\n"," "," files",":"," [\\n"," "," {"," path",":"," \\\"","report",".txt","\\\","," description",":"," \\\"","del","ivered"," report","\\\""," },\\n"," "," {"," path",":"," \\\"","说明",".txt","\\\","," description",":"," \\\"","del","ivered"," note","\\\""," },\\n"," "," ],\\n","});\\n","console",".log","(\\\"","present"," ok",","," files",":\\\","," p","2",".f","iles",".map","((","f",")"," =>"," f",".path",").","join","(\\\","," \\\"","));\\n\\n","//"," Step"," ","4",":"," deliberately"," throw"," a"," plain"," string"," AFTER","_P","RES","ENT"," (","not"," an"," Error",")\\n","throw"," \\\"","AF","TER","_P","RES","ENT","\\\";","\"",", ","\"","description","\"",": ","\"","Present"," files"," then"," deliberately"," throw"," AFTER","_P","RES","ENT","\"","}"]},{"type":"chunk","time":1788883599125,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants one run_code program that:\n1. Calls present for missing.txt and catches its error without creating that file.\n2. Uses bash to run exactly `printf \"DELIVERED_REPORT\\n\" > report.txt; printf \"DELIVERED_NOTE\\n\" > 说明.txt`.\n3. Calls present for report.txt and 说明.txt.\n4. After present succeeds, deliberately throws the string \"AFTER_PRESENT\" (not an Error object) from that same run_code program.\n5. Does not retry the program or create other files.\n6. Finishes by mentioning report.txt and 说明.txt in inline code, and put PRESENT_DONE in a separate paragraph.\n\nLet me write the program.\n\npresent tool args: { files: [{ path, description? }] }.\n\nSo first: try present for missing.txt — catch error. It should error because the file doesn't exist (present requires existing workspace file).\n\nThen bash run exact command. Note the command uses `;` inside a single string. Run as a single command string: `printf \"DELIVERED_REPORT\\n\" > report.txt; printf \"DELIVERED_NOTE\\n\" > 说明.txt`.\n\nWait — printf interprets the arguments... In bash, `printf \"DELIVERED_REPORT\\n\"` with the format string containing \\n — printf will interpret \\n as newline. Good.\n\nThen present for report.txt and 说明.txt.\n\nThen throw \"AFTER_PRESENT\" string. In TS async function body, `throw \"AFTER_PRESENT\"` works.\n\nLet me write it."}}},{"type":"chunk","time":1788883599125,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_GgvYPol45qxGf6GezK3L3590","name":"run_code","arguments":"{\"code\": \"// Step 1: present for missing.txt — must fail, catch the error, don't create the file\\ntry {\\n await tools.present({ files: [{ path: \\\"missing.txt\\\", description: \\\"must not exist\\\" }] });\\n console.log(\\\"UNEXPECTED: present succeeded for missing.txt\\\");\\n} catch (err) {\\n console.log(\\\"Caught present error for missing.txt:\\\", typeof err, String(err).slice(0, 200));\\n}\\n\\n// Step 2: run the exact bash command to create the two report files\\nconst res = await tools.bash({\\n command: 'printf \\\"DELIVERED_REPORT\\\\n\\\" > report.txt; printf \\\"DELIVERED_NOTE\\\\n\\\" > 说明.txt',\\n description: \\\"Write DELIVERED_REPORT and DELIVERED_NOTE to files\\\",\\n});\\nconsole.log(\\\"bash exit:\\\", res.exitCode);\\n\\n// Step 3: present for report.txt and 说明.txt (must succeed)\\nconst p2 = await tools.present({\\n files: [\\n { path: \\\"report.txt\\\", description: \\\"delivered report\\\" },\\n { path: \\\"说明.txt\\\", description: \\\"delivered note\\\" },\\n ],\\n});\\nconsole.log(\\\"present ok, files:\\\", p2.files.map((f) => f.path).join(\\\", \\\"));\\n\\n// Step 4: deliberately throw a plain string AFTER_PRESENT (not an Error)\\nthrow \\\"AFTER_PRESENT\\\";\", \"description\": \"Present files then deliberately throw AFTER_PRESENT\"}"}}},{"type":"chunk","time":1788883599125,"chunk":{"type":"usage","usage":{"inputTokens":981,"outputTokens":695,"totalTokens":9356,"cacheReadTokens":7680,"reasoningTokens":329}}},{"type":"chunk","time":1788883599125,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_00_GgvYPol45qxGf6GezK3L3590","name":"run_code","arguments":"{\"code\": \"// Step 1: present for missing.txt — must fail, catch the error, don't create the file\\ntry {\\n await tools.present({ files: [{ path: \\\"missing.txt\\\", description: \\\"must not exist\\\" }] });\\n console.log(\\\"UNEXPECTED: present succeeded for missing.txt\\\");\\n} catch (err) {\\n console.log(\\\"Caught present error for missing.txt:\\\", typeof err, String(err).slice(0, 200));\\n}\\n\\n// Step 2: run the exact bash command to create the two report files\\nconst res = await tools.bash({\\n command: 'printf \\\"DELIVERED_REPORT\\\\n\\\" > report.txt; printf \\\"DELIVERED_NOTE\\\\n\\\" > 说明.txt',\\n description: \\\"Write DELIVERED_REPORT and DELIVERED_NOTE to files\\\",\\n});\\nconsole.log(\\\"bash exit:\\\", res.exitCode);\\n\\n// Step 3: present for report.txt and 说明.txt (must succeed)\\nconst p2 = await tools.present({\\n files: [\\n { path: \\\"report.txt\\\", description: \\\"delivered report\\\" },\\n { path: \\\"说明.txt\\\", description: \\\"delivered note\\\" },\\n ],\\n});\\nconsole.log(\\\"present ok, files:\\\", p2.files.map((f) => f.path).join(\\\", \\\"));\\n\\n// Step 4: deliberately throw a plain string AFTER_PRESENT (not an Error)\\nthrow \\\"AFTER_PRESENT\\\";\", \"description\": \"Present files then deliberately throw AFTER_PRESENT\"}"}} +{"type":"tool/ptc-dispatch-start","data":{"rootCallId":"call_00_GgvYPol45qxGf6GezK3L3590","parentCallId":"call_00_GgvYPol45qxGf6GezK3L3590","subCallId":"call_00_GgvYPol45qxGf6GezK3L3590:ptc:1","name":"present","arguments":{"files":[{"path":"missing.txt","description":"must not exist"}]}}} +{"type":"tool/ptc-dispatch","data":{"rootCallId":"call_00_GgvYPol45qxGf6GezK3L3590","parentCallId":"call_00_GgvYPol45qxGf6GezK3L3590","subCallId":"call_00_GgvYPol45qxGf6GezK3L3590:ptc:1","name":"present","arguments":{"files":[{"path":"missing.txt","description":"must not exist"}]},"isError":true,"error":{"name":"FsError","code":"FS_NOT_FOUND"},"content":[{"type":"text","text":"Error: Cannot present missing.txt: file not found. Check the path, create the file if needed, and retry."}]}} +{"type":"tool/ptc-dispatch-start","data":{"rootCallId":"call_00_GgvYPol45qxGf6GezK3L3590","parentCallId":"call_00_GgvYPol45qxGf6GezK3L3590","subCallId":"call_00_GgvYPol45qxGf6GezK3L3590:ptc:2","name":"bash","arguments":{"command":"printf \"DELIVERED_REPORT\n\" > report.txt; printf \"DELIVERED_NOTE\n\" > 说明.txt","description":"Write DELIVERED_REPORT and DELIVERED_NOTE to files"}}} +{"type":"tool/ptc-dispatch","data":{"rootCallId":"call_00_GgvYPol45qxGf6GezK3L3590","parentCallId":"call_00_GgvYPol45qxGf6GezK3L3590","subCallId":"call_00_GgvYPol45qxGf6GezK3L3590:ptc:2","name":"bash","arguments":{"command":"printf \"DELIVERED_REPORT\n\" > report.txt; printf \"DELIVERED_NOTE\n\" > 说明.txt","description":"Write DELIVERED_REPORT and DELIVERED_NOTE to files"},"isError":false,"content":[{"type":"text","text":"(no output)"}]}} +{"type":"tool/ptc-dispatch-start","data":{"rootCallId":"call_00_GgvYPol45qxGf6GezK3L3590","parentCallId":"call_00_GgvYPol45qxGf6GezK3L3590","subCallId":"call_00_GgvYPol45qxGf6GezK3L3590:ptc:3","name":"present","arguments":{"files":[{"path":"report.txt","description":"delivered report"},{"path":"说明.txt","description":"delivered note"}]}}} +{"type":"deliverables/presented","data":{"turn":1,"callId":"call_00_GgvYPol45qxGf6GezK3L3590:ptc:3","files":[{"path":"report.txt","description":"delivered report"},{"path":"说明.txt","description":"delivered note"}]}} +{"type":"tool/ptc-dispatch","data":{"rootCallId":"call_00_GgvYPol45qxGf6GezK3L3590","parentCallId":"call_00_GgvYPol45qxGf6GezK3L3590","subCallId":"call_00_GgvYPol45qxGf6GezK3L3590:ptc:3","name":"present","arguments":{"files":[{"path":"report.txt","description":"delivered report"},{"path":"说明.txt","description":"delivered note"}]},"isError":false,"content":[{"type":"text","text":"Presented report.txt\nPresented 说明.txt"}]}} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_00_GgvYPol45qxGf6GezK3L3590"},"toolCallId":"call_00_GgvYPol45qxGf6GezK3L3590","content":[{"type":"text","text":"Error: code run failed (exception): AFTER_PRESENT\nCaptured output:\nCaught present error for missing.txt: object ToolCallError: Cannot present missing.txt: file not found. Check the path, create the file if needed, and retry.\nbash exit: 0\npresent ok, files: report.txt, 说明.txt\nFile sandbox: workspace-write; enforcement: full."}],"isError":true,"id":"{{message:5}}"},"error":{"name":"CodeRunFailedError","code":"CODE_RUN_FAILED"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":1}} +{"type":"step/start","data":{"turn":1,"step":2}} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The program ran as intended:\n1. present for missing.txt failed with a ToolCallError, caught and logged — missing.txt was not created.\n2. bash ran exactly the given command, exit 0.\n3. present succeeded for report.txt and 说明.txt.\n4. Then threw the plain string \"AFTER_PRESENT\" (program failed with that exception, as expected — not an Error object).\n\nI did not retry the program or create other files. Now finish mentioning `report.txt` and `说明.txt` in inline code, with PRESENT_DONE in a separate paragraph."},{"type":"text","text":"The single program ran exactly as ordered: `present` for `missing.txt` failed and was caught (no file created), bash ran the exact command successfully, then `present` succeeded for `report.txt` and `说明.txt`, after which the program deliberately threw the string `AFTER_PRESENT` — no retries, no extra files.\n\nPRESENT_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":94,"outputTokens":198,"totalTokens":9636,"cacheReadTokens":9344,"reasoningTokens":121},"stream":[{"type":"chunk","time":1788883599242,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1788883599242,"index":0,"dt":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," program"," ran"," as"," intended",":\n","1","."," present"," for"," missing",".txt"," failed"," with"," a"," Tool","Call","Error",","," caught"," and"," logged"," —"," missing",".txt"," was"," not"," created",".\n","2","."," bash"," ran"," exactly"," the"," given"," command",","," exit"," ","0",".\n","3","."," present"," succeeded"," for"," report",".txt"," and"," ","说明",".txt",".\n","4","."," Then"," threw"," the"," plain"," string"," \"","AF","TER","_P","RES","ENT","\""," (","program"," failed"," with"," that"," exception",","," as"," expected"," —"," not"," an"," Error"," object",").\n\n","I"," did"," not"," ret","ry"," the"," program"," or"," create"," other"," files","."," Now"," finish"," mentioning"," `","report",".txt","`"," and"," `","说明",".txt","`"," in"," inline"," code",","," with"," PRES","ENT","_D","ONE"," in"," a"," separate"," paragraph","."]},{"type":"chunk","time":1788883599244,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1788883599244,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," single"," program"," ran"," exactly"," as"," ordered",":"," `","present","`"," for"," `","missing",".txt","`"," failed"," and"," was"," caught"," (","no"," file"," created","),"," bash"," ran"," the"," exact"," command"," successfully",","," then"," `","present","`"," succeeded"," for"," `","report",".txt","`"," and"," `","说明",".txt","`,"," after"," which"," the"," program"," deliberately"," threw"," the"," string"," `","AF","TER","_P","RES","ENT","`"," —"," no"," ret","ries",","," no"," extra"," files",".\n\n","PR","ES","ENT","_D","ONE"]},{"type":"chunk","time":1788883599244,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The program ran as intended:\n1. present for missing.txt failed with a ToolCallError, caught and logged — missing.txt was not created.\n2. bash ran exactly the given command, exit 0.\n3. present succeeded for report.txt and 说明.txt.\n4. Then threw the plain string \"AFTER_PRESENT\" (program failed with that exception, as expected — not an Error object).\n\nI did not retry the program or create other files. Now finish mentioning `report.txt` and `说明.txt` in inline code, with PRESENT_DONE in a separate paragraph."}}},{"type":"chunk","time":1788883599244,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The single program ran exactly as ordered: `present` for `missing.txt` failed and was caught (no file created), bash ran the exact command successfully, then `present` succeeded for `report.txt` and `说明.txt`, after which the program deliberately threw the string `AFTER_PRESENT` — no retries, no extra files.\n\nPRESENT_DONE"}}},{"type":"chunk","time":1788883599244,"chunk":{"type":"usage","usage":{"inputTokens":94,"outputTokens":198,"totalTokens":9636,"cacheReadTokens":9344,"reasoningTokens":121}}},{"type":"chunk","time":1788883599244,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":2}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} From a6daee84ca5a7f945675b39cd83a969820a438c4 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sun, 20 Sep 2026 14:06:00 +0800 Subject: [PATCH 38/62] test: enable the shared opener in the delivery replay --- apps/web/tests/present.e2e.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/tests/present.e2e.ts b/apps/web/tests/present.e2e.ts index 125c9f6ebe..fb23c2db8e 100644 --- a/apps/web/tests/present.e2e.ts +++ b/apps/web/tests/present.e2e.ts @@ -6,6 +6,7 @@ import { chromium, type Browser, type Page } from 'playwright' import { unzipSync, strFromU8 } from 'fflate' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import { tmpdir, release } from 'node:os' +import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-tool-present/types' import { @@ -53,6 +54,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con vi.stubEnv('PATH', `${nativeRoot}${delimiter}${process.env.PATH ?? ''}`) await mkdir(DIR, { recursive: true }) scaffold = await launchWebScaffold({ + openInAppEnvironment: createLaunchEnvironmentSnapshot([{ source: 'process', values: { SSH_CONNECTION: '10.0.0.2 55000 10.0.0.9 22' } }]), extraOverlayPath: fileURLToPath(new URL('./present.overlay.yml', import.meta.url)), agentPresets: { roots: [], default: 'ptc' }, compareReplaySession: true, ...(MODE === 'record' ? {} : { replayFixture: FIXTURE }), From a4cd2f57a0499fa684cf493c896d33cee26f310e Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sun, 20 Sep 2026 14:07:32 +0800 Subject: [PATCH 39/62] fix: keep file action feedback visible while hovering delivery cards --- .../src/client/Deliverables.module.css | 4 ++-- .../ui-deliverables/src/client/Deliverables.tsx | 2 +- .../ui-deliverables/src/client/PresentedFileCard.tsx | 2 +- packages/client/ui-open-in-app/src/client/index.ts | 11 +++-------- snapshots/web/present/ui.expected.md | 8 ++++---- 5 files changed, 11 insertions(+), 16 deletions(-) diff --git a/packages/client/ui-deliverables/src/client/Deliverables.module.css b/packages/client/ui-deliverables/src/client/Deliverables.module.css index eac1f29ea4..edd1cac863 100644 --- a/packages/client/ui-deliverables/src/client/Deliverables.module.css +++ b/packages/client/ui-deliverables/src/client/Deliverables.module.css @@ -18,8 +18,8 @@ .description { overflow: hidden; color: var(--dsw-alias-label-tertiary); font-size: 10px; font-weight: 400; line-height: 16px; text-overflow: ellipsis; white-space: nowrap; } .description[data-error='true'] { color: var(--dsw-alias-state-error-primary); } .previewHint { display: none; } -.file:hover .secondaryText { display: none; } -.file:hover .previewHint { display: inline; } +.file:hover .description:not([role='status']) .secondaryText { display: none; } +.file:hover .description:not([role='status']) .previewHint { display: inline; } .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; } diff --git a/packages/client/ui-deliverables/src/client/Deliverables.tsx b/packages/client/ui-deliverables/src/client/Deliverables.tsx index fa6c167b1e..3fae39bd01 100644 --- a/packages/client/ui-deliverables/src/client/Deliverables.tsx +++ b/packages/client/ui-deliverables/src/client/Deliverables.tsx @@ -57,7 +57,7 @@ export function DeliverablesTail(props: PropsRuntime<'conversation.chat.turnTail /** * Render the changed-files card, once the Host has served the announced - * summary and it lists a file, and default-application buttons for declared + * summary and it lists a file, and shared native opening controls for declared * files. A summary the Host no longer serves leaves no card. * @param props - matched announcement and files, workspace opener, and localized copy. * @returns the closing turn's file rows. diff --git a/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx index 03ed7e411c..97008ceced 100644 --- a/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx +++ b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx @@ -1,4 +1,4 @@ -/** File identity and explicit default-app or file-manager actions for one delivery. */ +/** File identity, Sidebar preview, and contributed native actions for one delivery. */ import type { ReactNode } from 'react' import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path' import { FileTypeIcon, fileExtension } from '@deepseek-ai/dsh-client-ui-primitives' 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 c92aff20f5..7c28ccce1a 100644 --- a/packages/client/ui-open-in-app/src/client/index.ts +++ b/packages/client/ui-open-in-app/src/client/index.ts @@ -1,12 +1,7 @@ /** - * Browser half of open-in-app: one Session-header split button opening the - * session's workspace directory (the summary's `cwd`) in the remembered - * installed application, and the document preview's default-application - * controls opening one previewed file. Application availability arrives once - * per page from the host apps route and the last choice persists in the - * browser through the controller's persisted snapshot store; desktop - * availability for file paths arrives once per page from the Session Remote, - * which also runs the open and reveal gestures. + * Shared native opening controls for workspace directories, document previews, + * delivery cards, and changed-file review. Directory choices persist in the browser; + * file defaults and application lists come from the serving Host desktop. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' diff --git a/snapshots/web/present/ui.expected.md b/snapshots/web/present/ui.expected.md index ad3780520c..a3c254300f 100644 --- a/snapshots/web/present/ui.expected.md +++ b/snapshots/web/present/ui.expected.md @@ -38,13 +38,13 @@ - button "Preview report.txt in sidebar" - text: report.txt - status: Opened in default app -- button "Open report.txt in sidebar": Open -- button "More file actions for report.txt" +- button "Open in Test Editor" +- button "More ways to open" - button "Preview 说明.txt in sidebar" - text: 说明.txt - status: Opened in default app -- button "Open 说明.txt in sidebar": Open -- button "More file actions for 说明.txt" +- button "Open in Test Editor" +- button "More ways to open" - button "Copy" - button "Good response" - button "Bad response" From c1ee42cd9cbaabdeddf39df1d429db2f5c606e84 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sun, 20 Sep 2026 14:28:15 +0800 Subject: [PATCH 40/62] fix: portal file action tooltips outside clipped cards --- apps/web/tests/present.e2e.ts | 12 +++++++ .../src/client/OpenTargetButton.tsx | 2 +- .../client/ui-primitives/README.i18n.yaml | 4 +-- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- .../ui-primitives/src/Tooltip.module.css | 2 ++ packages/client/ui-primitives/src/Tooltip.tsx | 36 ++++++++++--------- .../tests/tooltip.client.spec.tsx | 15 ++++++++ 8 files changed, 54 insertions(+), 21 deletions(-) diff --git a/apps/web/tests/present.e2e.ts b/apps/web/tests/present.e2e.ts index fb23c2db8e..4df8bfae81 100644 --- a/apps/web/tests/present.e2e.ts +++ b/apps/web/tests/present.e2e.ts @@ -132,6 +132,18 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con await row.waitFor() await expect.poll(() => row.getByRole('button', { name: 'More ways to open' }).count()).toBe(2) expect(await row.getByText('report.txt', { exact: true }).innerText()).toBe('report.txt') + const card = row.locator('[data-presented-file]').filter({ hasText: 'report.txt' }) + await card.getByRole('button', { name: 'Open in Test Editor', exact: true }).hover() + await page.getByRole('tooltip', { name: 'Open in Test Editor', exact: true }).waitFor() + expect(await page.getByRole('tooltip').evaluate((tooltip) => { + const rect = tooltip.getBoundingClientRect() + const previous = tooltip.style.pointerEvents + tooltip.style.pointerEvents = 'auto' + const visible = document.elementFromPoint(rect.left + rect.width / 2, rect.bottom - 1) === tooltip + tooltip.style.pointerEvents = previous + return tooltip.parentElement === document.body && visible + })).toBe(true) + await page.mouse.move(0, 0) const beforePreview = (await opened()).length const column = page.locator('[data-rightbar-col]') for (const [name, content] of [['report.txt', 'EDITED_REPORT'], ['说明.txt', 'EDITED_NOTE']] as const) { diff --git a/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx b/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx index e9284d9293..8cc56c74e5 100644 --- a/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx +++ b/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx @@ -113,7 +113,7 @@ export function OpenTargetButton(props: OpenTargetButtonProps): ReactNode { anchor={(
- + +
) + const anchor = screen.getByRole('button', { name: 'File action' }) + fireEvent.mouseEnter(anchor) + const tooltip = screen.getByRole('tooltip') + expect(tooltip.parentElement).toBe(document.body) + expect(view.container.contains(anchor)).toBe(true) + expect(view.container.contains(tooltip)).toBe(false) + view.unmount() + expect(screen.queryByRole('tooltip')).toBeNull() +}) From a4148d687fa5f30f1c0998af81fdaf9869fe8f41 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sun, 20 Sep 2026 16:46:58 +0800 Subject: [PATCH 41/62] fix: refine file action menus feedback and shared queries --- apps/web/tests/present.e2e.ts | 3 +- .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 2 +- packages/api/session-controller/README.zh.md | 2 +- packages/api/session-controller/src/index.ts | 3 +- .../session-open-workspace-path.host.spec.ts | 6 +- .../client/ui-deliverables/README.i18n.yaml | 4 +- packages/client/ui-deliverables/README.md | 2 +- packages/client/ui-deliverables/README.zh.md | 2 +- .../src/client/Deliverables.module.css | 4 + .../src/client/PresentedFileCard.tsx | 10 +- .../ui-deliverables/src/client/locales.ts | 4 +- .../src/client/present-open.ts | 23 +++- .../tests/present-open.client.spec.ts | 40 ++++++- .../client/ui-open-in-app/README.i18n.yaml | 4 +- packages/client/ui-open-in-app/README.md | 4 +- packages/client/ui-open-in-app/README.zh.md | 4 +- .../src/client/FileRouteAction.tsx | 2 +- .../src/client/OpenTargetButton.module.css | 2 + .../src/client/OpenTargetButton.tsx | 26 +++-- .../src/client/file-applications.ts | 102 ++++++++++++------ .../client/ui-open-in-app/src/client/index.ts | 3 +- .../tests/browser-plugin.client.spec.ts | 1 + .../tests/file-applications.client.spec.tsx | 53 +++++++++ .../tests/open-in-app-action.client.spec.tsx | 7 ++ .../tests/open-path-action.client.spec.tsx | 26 ++++- 26 files changed, 267 insertions(+), 76 deletions(-) create mode 100644 packages/client/ui-open-in-app/tests/file-applications.client.spec.tsx diff --git a/apps/web/tests/present.e2e.ts b/apps/web/tests/present.e2e.ts index 4df8bfae81..36d237857a 100644 --- a/apps/web/tests/present.e2e.ts +++ b/apps/web/tests/present.e2e.ts @@ -192,6 +192,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con ]) expect(exported).not.toContain('EDITED_REPORT') if (MODE !== 'record') { + await expect.poll(() => page.locator('[data-presented-file] [role="status"]').count(), { timeout: 10_000 }).toBe(0) const aria = await captureExpandedTurnProcessAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(join(DIR, 'ui.expected.md'), aria, MODE) await page.locator('[data-turn-process]').click() @@ -233,7 +234,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con .find(element => element.textContent === 'report.txt'), 'report title', ) - const description = requiredElement(report.querySelector('span[role="status"]'), 'report status') + const description = requiredElement(report.querySelector('[data-presented-description]'), 'report description') const open = requiredElement( report.querySelector('[data-open-target] button'), 'report open action', diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index e01ad4e71a..7909684664 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/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/api/session-controller/README.md -README.md: fda32efcfd42bd88f28de0764ef8d2c0e6a4ff9f -README.zh.md: 9eb60799088cb5cc9091352f38dfb50cfd6dbfd3 +README.md: 84734325be38099f0b5ff91026607029aaae7863 +README.zh.md: 41123ae048e1edda132901920222236364095842 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index fda32efcfd..84734325be 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -68,7 +68,7 @@ References keep local Session data, scoped Contexts, and history streams alive, `SessionMediaReferences` mounts `GET|HEAD /api/file?path=` on the authenticated `connection.fetch` channel when `connection`, `fs`, and `attachments` are composed. It reads ordinary files through `ctx.fs`, including temporary paths outside registered workspaces and files in remote providers. Neither directory containment nor MIME categories restrict access; `mime-types` supplies the response type, with `application/octet-stream` for unknown extensions. GET reuses `readBytes` for preflight and ongoing byte limits; HEAD reads metadata only. All files use `ctx.attachments.imageLimits.maxImageBytes` (normally 20 MiB); exceeding this limit returns 413. Responses contain the complete file, ignore Range, and carry `private, no-store`, `nosniff`, and a sandbox CSP so directly opened HTML/SVG cannot execute with the API origin. The Client rewrite lives in `ui-chat` (`AssistantMarkdown`); audio/video responses are available, while Markdown audio/video player nodes remain separate work. -`workspacePathApplications({ path })` returns the serving desktop’s file handlers after the same filesystem mapping check used by `openWorkspacePath`. An optional `application` in the open request selects a currently registered handler without changing the system default. Application names, default selection, icons, and platform coverage come from [native-command](../../util/native-command/README.md). These operations do not activate an Agent or append Session events. +`workspacePathApplications({ path })` returns the serving desktop’s file handlers after the same filesystem mapping check used by `openWorkspacePath`. An optional `application` in the open request selects a currently registered handler without changing the system default. Application names, default selection, icons, and platform coverage come from [native-command](../../util/native-command/README.md). These operations do not activate an Agent or append Session events. Native failures return bounded messages; original command errors remain Host-side causes. ----- diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 9eb6079908..41123ae048 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -68,7 +68,7 @@ Fork 复制 `atSeq` 所选的精确事件前缀,包含切点事件,允许在 当 `connection`、`fs` 与 `attachments` 均被组合时,`SessionMediaReferences` 在鉴权 `connection.fetch` 通道上挂载 `GET|HEAD /api/file?path=<绝对路径>`。它通过 `ctx.fs` 读取普通文件,包括已注册工作区之外的临时路径与远程提供方中的文件。目录包含关系与 MIME 类别均不限制访问;`mime-types` 提供响应类型,未知扩展名使用 `application/octet-stream`。GET 复用 `readBytes` 执行读取前及读取中的字节限制;HEAD 只读取元数据。所有文件均使用 `ctx.attachments.imageLimits.maxImageBytes`(通常为 20 MiB);超过此上限返回 413。响应包含完整文件,忽略 Range,并携带 `private, no-store`、`nosniff` 与沙箱 CSP,使直接打开的 HTML/SVG 无法以 API 源身份执行脚本。客户端重写位于 `ui-chat`(`AssistantMarkdown`);音视频文件响应已可用,Markdown 音视频播放器节点仍是独立工作。 -`workspacePathApplications({ path })` 使用与 `openWorkspacePath` 相同的文件系统映射校验,返回服务端桌面上该文件的关联应用。打开请求中的可选 `application` 指定当前关联的应用,不修改系统默认应用。应用名称、默认项、图标和平台支持范围由 [native-command](../../util/native-command/README.zh.md) 提供。这些操作不激活 Agent,也不追加会话事件。 +`workspacePathApplications({ path })` 使用与 `openWorkspacePath` 相同的文件系统映射校验,返回服务端桌面上该文件的关联应用。打开请求中的可选 `application` 指定当前关联的应用,不修改系统默认应用。应用名称、默认项、图标和平台支持范围由 [native-command](../../util/native-command/README.zh.md) 提供。这些操作不激活 Agent,也不追加会话事件。原生操作失败时返回简短消息,原始命令异常作为 Host 端原因保留。 ----- diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index a9750a635f..9cab20852d 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -329,8 +329,9 @@ export class SessionController extends TypertRemoteService { if (error instanceof RemoteError) throw error throw new RemoteError( 'gateway/internal', - `path open failed: ${error instanceof Error ? error.message : String(error)}`, + 'path open failed', {}, + { cause: error }, ) } } diff --git a/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts b/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts index 93f51c7ce3..e8216b5214 100644 --- a/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts +++ b/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts @@ -101,7 +101,7 @@ describe('session/openWorkspacePath', () => { it('preserves native opener failure and cancellation results', async () => { const ctx = await context() const openPath = vi.fn((_path: string, _signal: AbortSignal) => - Promise.reject(new Error('desktop unavailable'))) + Promise.reject(new Error('Command failed: powershell.exe -EncodedCommand private-script-text'))) const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', @@ -111,7 +111,7 @@ describe('session/openWorkspacePath', () => { await expect(remote.openWorkspacePath({ path: 'result.html' })) .resolves.toMatchObject({ ok: false, - error: { code: 'gateway/internal', message: 'path open failed: desktop unavailable' }, + error: { code: 'gateway/internal', message: 'path open failed' }, }) const aborted = new AbortController() @@ -140,7 +140,7 @@ describe('session/openWorkspacePath', () => { await expect(controller.openWorkspacePath({ path: 'second.html', }, new AbortController().signal)).rejects.toMatchObject({ - code: 'gateway/internal', message: 'path open failed: desktop unavailable', + code: 'gateway/internal', message: 'path open failed', }) }) }) diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index 85892e110d..c3c62898a1 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: a8e80ede3380cef7e0550a0a63aa195642ed4e12 -README.zh.md: 929ab7f93f6ff995586bd850b655eb2e7a31ac93 +README.md: ce006783310c257957bc8cfddcfd18226972d18f +README.zh.md: 82239ba169f4ada188e8a5ba8e1889c61b336f96 diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index a8e80ede33..ce00678331 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -32,7 +32,7 @@ Mount this plugin alongside `ui-conversation` and the Host [workspace-changes](. 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 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` 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. 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 changed-files card diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 929ab7f93f..82239ba169 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -32,7 +32,7 @@ kind: "package-reference" Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present`,用于声明会话文件系统可访问的最终文件,包括通过 Bash 创建的文件。创建后以 `files: [{ path, description? }]` 调用。[present 工具](../../deliverables/tool-present/README.zh.md)拥有文件数量限制和会话声明。单个交付占满一行,多个交付使用双列网格,超过四个时默认收起。卡片显示文件名、说明或文件类型,以及共享的原生打开控件。点击卡片会在右侧 Sidebar 预览文件。控件打开默认应用,并列出关联应用,文件定位始终放在最后。匹配的行内代码引用也打开 Sidebar 预览。重复声明使用收尾回复之前最近一次的说明。 -`present` 工具行在执行、成功、失败和中断状态下保留文档图标及记录的结果。原生操作共享等待状态,并在卡片显示进度或可重试错误。卡片挂载时读取桌面信息,连接更换时清除缓存。Host 没有桌面时,卡片保留 Sidebar 预览并隐藏原生控件;元数据读取失败时提供重试。交付卡片和变更对比页通过 `deliverables.file.actions` 和 `deliverables.review.file.actions` 使用 [ui-open-in-app](../ui-open-in-app/README.zh.md) 提供的控件。关联应用 GET 查询与原生 POST 操作都会先校验记录中的文件坐标及会话文件系统映射,再调用 Host 桌面。 +`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 桌面。 ### 改动文件卡片 diff --git a/packages/client/ui-deliverables/src/client/Deliverables.module.css b/packages/client/ui-deliverables/src/client/Deliverables.module.css index edd1cac863..0194aaf183 100644 --- a/packages/client/ui-deliverables/src/client/Deliverables.module.css +++ b/packages/client/ui-deliverables/src/client/Deliverables.module.css @@ -26,3 +26,7 @@ @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; } + +.secondaryText[data-success] { animation-name: success-fade; animation-timing-function: ease-out; animation-fill-mode: forwards; } +@keyframes success-fade { to { opacity: 0; } } +@media (prefers-reduced-motion: reduce) { .secondaryText[data-success] { animation-name: none; } } diff --git a/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx index 97008ceced..f0dd26035b 100644 --- a/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx +++ b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx @@ -4,7 +4,7 @@ import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path' import { FileTypeIcon, fileExtension } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { PresentedHost } from '../presented.ts' -import type { PresentedOpenPhase } from './present-open.ts' +import { PRESENTED_SUCCESS_HOLD_MS, PRESENTED_SUCCESS_FADE_MS, type PresentedOpenPhase } from './present-open.ts' import { basename, type PresentedPath } from './turn-deliverables.ts' import type { NS } from './locales.ts' import css from './Deliverables.module.css' @@ -27,6 +27,7 @@ export function PresentedFileCard({ file, cwd, phase, host, onPreview, actions, onPreview: () => void actions: ReactNode } & PropsLocale) { + const succeeded = phase === 'opened' || phase === 'revealed' const reveal = host?.fileManager ?? 'directory' const name = basename(file.path) const metadata = fileExtension(name).toUpperCase() || t('presented.file') @@ -42,9 +43,12 @@ export function PresentedFileCard({ file, cwd, phase, host, onPreview, actions,
{name} - - {status} + + {status} + {t('presented.preview')}
diff --git a/packages/client/ui-deliverables/src/client/locales.ts b/packages/client/ui-deliverables/src/client/locales.ts index 4ed0595bc3..69e976fa06 100644 --- a/packages/client/ui-deliverables/src/client/locales.ts +++ b/packages/client/ui-deliverables/src/client/locales.ts @@ -23,7 +23,7 @@ export const zh = { 'presented.collapse': '收起', 'presented.collapseAria': '收起交付文件列表', 'presented.opening': '正在打开…', - 'presented.opened': '已在默认程序中打开', + 'presented.opened': '已请求打开', 'presented.error': '打开失败,点击重试', 'presented.file': '文件', 'row.title': '交付文件', @@ -85,7 +85,7 @@ export const en: Record = { 'presented.collapse': 'Collapse', 'presented.collapseAria': 'Collapse delivered files', 'presented.opening': 'Opening…', - 'presented.opened': 'Opened in default app', + 'presented.opened': 'Open requested', 'presented.error': 'Could not open. Click to retry.', 'presented.file': 'File', 'row.title': 'Present files', diff --git a/packages/client/ui-deliverables/src/client/present-open.ts b/packages/client/ui-deliverables/src/client/present-open.ts index 92764884ef..fae3c3557a 100644 --- a/packages/client/ui-deliverables/src/client/present-open.ts +++ b/packages/client/ui-deliverables/src/client/present-open.ts @@ -4,6 +4,11 @@ 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 @@ -16,6 +21,7 @@ export class PresentedOpenController { readonly state = createSnapshotStore>({}) /** Native destination metadata, or a retryable read failure. */ readonly host = createSnapshotStore(null) + private readonly expiry = new Map>() private loading: Promise | undefined private metadata = new AbortController() private readonly lifetime = new AbortController() @@ -55,6 +61,7 @@ export class PresentedOpenController { 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 null + this.clearExpiry(url) this.state.update((state) => { state[url] = action === 'open' ? 'opening' : 'revealing' }) const task = this.request(url, action, application) this.pending.add(task) @@ -110,9 +117,15 @@ export class PresentedOpenController { /** Cancel outstanding requests and wait until no request can publish state. */ async dispose(): Promise { 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 { const failure = action === 'open' ? 'error' : 'revealError' let phase: PresentedOpenPhase = action === 'open' ? 'opened' : 'revealed' @@ -125,7 +138,15 @@ export class PresentedOpenController { // 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 }) + if (!this.lifetime.signal.aborted) { + this.state.update((state) => { state[url] = phase }) + 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)) + } + } return phase === 'opened' || phase === 'revealed' ? null : action === 'reveal' ? 'revealError' : 'openError' } } diff --git a/packages/client/ui-deliverables/tests/present-open.client.spec.ts b/packages/client/ui-deliverables/tests/present-open.client.spec.ts index 468e065ad4..dea83c354e 100644 --- a/packages/client/ui-deliverables/tests/present-open.client.spec.ts +++ b/packages/client/ui-deliverables/tests/present-open.client.spec.ts @@ -3,7 +3,7 @@ import { afterEach, expect, it, vi } from 'vitest' import { SessionId } from '@deepseek-ai/dsh-session/types' import { PresentedOpenController } from '../src/client/present-open.ts' -afterEach(() => { vi.unstubAllGlobals() }) +afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers() }) const id = SessionId('fork') const url = 'api/present.open?sessionId=fork&seq=2&index=1' @@ -184,3 +184,41 @@ it('encodes an explicit application identifier without changing the file coordin 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() + 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) + expect(controller.state.getSnapshot()[url]).toBeUndefined() + await controller.dispose() +}) + +it('cancels an earlier success expiry when the next action fails', async () => { + vi.useFakeTimers() + 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) +}) diff --git a/packages/client/ui-open-in-app/README.i18n.yaml b/packages/client/ui-open-in-app/README.i18n.yaml index 977cb96683..b0fa75db3e 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: 7840efdf1bb092cd538865f2b9b74b0911cfb06f -README.zh.md: 0774e5228551b8463de129f49be2ac30f05f1273 +README.md: 138be128c291fc4a0611295c54768a95f33f3ae1 +README.zh.md: b81f62f9dbf8ccfbc1822b8a72df95e7169228c0 diff --git a/packages/client/ui-open-in-app/README.md b/packages/client/ui-open-in-app/README.md index 7840efdf1b..138be128c2 100644 --- a/packages/client/ui-open-in-app/README.md +++ b/packages/client/ui-open-in-app/README.md @@ -31,9 +31,9 @@ Mount this plugin in the Web composition beside [`dsh-host-open-in-app`](../../h The Session header and document header use the same 24px-high split button with 9px corners. Both headers show only the icon; their tooltip names the default application or the reveal action. Both 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 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. -File menus list the discovered associated applications without a separate “Open in default app” row. “Show file location” always stays last. When no default application is identified, that row reads “Show file location (default)” and the main button reveals the file. 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. +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 no default application is identified, that row reads “Show file location (default)” and the main button reveals the file. 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. -File associations refresh when the file changes or its menu opens; cancelled queries cannot replace another file’s results. A failed query displays a menu message and leaves reveal available as the default action. File-association discovery follows [native-command](../../util/native-command/README.md); an empty result, including a platform without a discovery adapter, uses the same reveal fallback without platform branches in the control. +Mounted controls with the same association reader and file share one query and its results; opening either menu refreshes all of them. Releasing the last control cancels the query and discards its state. Cancelled queries cannot replace another file’s results. A failed query displays a menu message and leaves reveal available as the default action. File-association discovery follows [native-command](../../util/native-command/README.md); an empty result, including a platform without a discovery adapter, uses the same reveal fallback without platform branches in the control. ----- diff --git a/packages/client/ui-open-in-app/README.zh.md b/packages/client/ui-open-in-app/README.zh.md index 0774e52285..b81f62f9db 100644 --- a/packages/client/ui-open-in-app/README.zh.md +++ b/packages/client/ui-open-in-app/README.zh.md @@ -31,9 +31,9 @@ kind: "package-reference" 会话标题栏和文档标题栏共用同一个高 24px、圆角 9px 的分体按钮。两个标题栏都只显示图标,悬停提示显示默认应用名称或文件定位动作。两者都显示默认动作的图标,在菜单的默认应用后标注“(默认)”,仅在自己的操作执行期间禁用,并通过短暂提示报告失败。目录适配器使用已有的跨平台应用列表,将最后一次成功选择保存在 `dsh.open-in-app.choice` 中;原选择不可用时回退到第一个可用应用。 -文件菜单列出已发现的关联应用,不单列“用默认应用打开”。“显示文件位置”始终放在最后。未识别到默认应用时,该项显示为“显示文件位置(默认)”,主按钮也执行文件定位。目录菜单不包含定位项。选择文件应用不修改系统默认应用。无法预览文件时,空态使用同一菜单,按钮增大到 40px 高并显示图标和动作文字;根据默认动作显示“打开”或“显示文件位置”。 +文件菜单列出已发现的关联应用,不单列“用默认应用打开”。“显示文件位置”固定在菜单底部,通过分隔线与滚动的应用列表分开。查询成功且只有一个可用操作时显示单按钮,不再显示下拉箭头。文件关联首次加载时使用灰色骨架图标。未识别到默认应用时,该项显示为“显示文件位置(默认)”,主按钮也执行文件定位。目录菜单不包含定位项。选择文件应用不修改系统默认应用。无法预览文件时,空态使用同一菜单,按钮增大到 40px 高并显示图标和动作文字;根据默认动作显示“打开”或“显示文件位置”。 -文件变化或打开菜单时会重新查询文件关联;已取消的查询不会覆盖其他文件的结果。查询失败时菜单显示提示,并以文件定位作为默认动作。文件关联查询的平台支持范围见 [native-command](../../util/native-command/README.zh.md);查询结果为空时,包括平台尚无查询适配器的情况,控件统一使用定位动作,不按操作系统分支处理。 +使用同一查询函数和文件的已挂载控件共用查询与结果,打开任一菜单会刷新所有相关控件。最后一个控件释放后取消查询并清除状态。已取消的查询不会覆盖其他文件的结果。查询失败时菜单显示提示,并以文件定位作为默认动作。文件关联查询的平台支持范围见 [native-command](../../util/native-command/README.zh.md);查询结果为空时,包括平台尚无查询适配器的情况,控件统一使用定位动作,不按操作系统分支处理。 ----- diff --git a/packages/client/ui-open-in-app/src/client/FileRouteAction.tsx b/packages/client/ui-open-in-app/src/client/FileRouteAction.tsx index 287b4b3956..49c27245f1 100644 --- a/packages/client/ui-open-in-app/src/client/FileRouteAction.tsx +++ b/packages/client/ui-open-in-app/src/client/FileRouteAction.tsx @@ -30,7 +30,7 @@ export function FileRouteAction( if (!props.available) return null return app.default)?.id} failed={association.failed} - loading={association.loading || props.pending} refresh={association.refresh} t={props.t} + 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) }} /> diff --git a/packages/client/ui-open-in-app/src/client/OpenTargetButton.module.css b/packages/client/ui-open-in-app/src/client/OpenTargetButton.module.css index 89089a8c2a..44a66f59f2 100644 --- a/packages/client/ui-open-in-app/src/client/OpenTargetButton.module.css +++ b/packages/client/ui-open-in-app/src/client/OpenTargetButton.module.css @@ -76,3 +76,5 @@ .split[data-size='large'] .chevron { padding: 8px 10px; } + +.skeleton { display: inline-block; flex: none; border-radius: 3px; background: var(--dsw-alias-interactive-bg-hover); } diff --git a/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx b/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx index 8cc56c74e5..65306e4716 100644 --- a/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx +++ b/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx @@ -26,6 +26,7 @@ export interface OpenTargetButtonProps { readonly applications: readonly OpenTargetApplication[] readonly defaultId: string | undefined readonly failed: boolean + readonly busy?: boolean readonly loading?: boolean readonly prominent?: boolean readonly t: TranslateNS @@ -79,19 +80,22 @@ export function OpenTargetButton(props: OpenTargetButtonProps): ReactNode { const [menuOpen, setMenuOpen] = useState(false) const { pending, toast, act } = useOpenTargetGesture(props.execute, t) const preferred = applications.find(app => app.id === defaultId) - const disabled = pending || props.loading === true + const disabled = pending || props.busy === true || props.loading === true + const hasMenu = props.loading === true || props.failed || applications.length + (kind === 'file' ? 1 : 0) > 1 const revealDefault = kind === 'file' && preferred === undefined && props.loading !== true const primaryLabel = preferred === undefined ? t('path.reveal') : t('open.title', { app: preferred.name }) const run = (operation: OpenTargetOperation): void => { setMenuOpen(false); act(operation) } const primary = (): void => { run({ kind: revealDefault ? 'reveal' : 'default' }) } - const icon = revealDefault - ? - : + const icon = props.loading === true && preferred === undefined + ?