From 3515c48a37cadae13e49a518fc9b28b474416bc3 Mon Sep 17 00:00:00 2001 From: mektpoy Date: Sun, 20 Sep 2026 15:37:38 +0800 Subject: [PATCH 01/36] fix(desktop): resolve the development runtime directory from the build target The unpackaged shell derived the primary-runtime directory from process.arch, while preparePrimaryRuntime() resolves the target through resolveDesktopBuildTarget(), which fixes Windows to x64. On a Windows ARM64 host the shell looked under targets/win-arm64 while the payload was under targets/win-x64, and the missing office-skills/scripts/check_office.py became a fatal startup dialog. The launcher now resolves that directory through the same resolver and passes it to the shell as DSH_DESKTOP_PRIMARY_RUNTIME_DIR. --- apps/desktop/README.i18n.yaml | 4 ++-- apps/desktop/README.md | 2 +- apps/desktop/README.zh.md | 2 +- apps/desktop/scripts/desktop-build-paths.d.mts | 13 +++++++++++++ apps/desktop/scripts/desktop-build-paths.mjs | 17 +++++++++++++++++ apps/desktop/scripts/dev.ts | 2 ++ apps/desktop/scripts/test-workspace-updates.ts | 2 +- apps/desktop/src/main.ts | 10 +++++++++- apps/desktop/tests/desktop-build-paths.spec.ts | 10 ++++++++++ apps/desktop/tests/main-startup.spec.ts | 1 + 10 files changed, 57 insertions(+), 6 deletions(-) diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 455501862e..02b287a60c 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/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 apps/desktop/README.md -README.md: 6c9a6a8784732e6fa1369df5632391b789786b22 -README.zh.md: a0f2184d911eeab46902670cf992b6847e2bca31 +README.md: 860867eef3ae1da8c89d8292f00afa9ad2a20872 +README.zh.md: ac9a4ddbfff1ad96950fbda6e14ea2632480ea03 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 6c9a6a8784..860867eef3 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -342,7 +342,7 @@ The downloaded bytes are inert, and the installation call is recorded rather tha ## Low-level development overrides -An unpackaged Electron process uses `.desktop-build/development/project` under its application directory as its development project. `DSH_DESKTOP_PNPM_ENTRY` and `DSH_DESKTOP_DSH_DIR` select explicit runtime resources. Packaged applications ignore these variables, resolve signed resources from `process.resourcesPath`, and use the managed Desktop profile. +An unpackaged Electron process uses `.desktop-build/development/project` under its application directory as its development project. `DSH_DESKTOP_PNPM_ENTRY`, `DSH_DESKTOP_DSH_DIR`, and `DSH_DESKTOP_PRIMARY_RUNTIME_DIR` select explicit runtime resources. The development launcher sets `DSH_DESKTOP_PRIMARY_RUNTIME_DIR` to the primary-runtime directory of the target it prepared, because the Windows target is always x64. Packaged applications ignore these variables, resolve signed resources from `process.resourcesPath`, and use the managed Desktop profile. ## Known limitations diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index a0f2184d91..ac9a4ddbff 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -342,7 +342,7 @@ node apps/desktop/node_modules/pnpm/bin/pnpm.mjs --dir apps/desktop run test:upd ## 底层开发覆盖项 -未打包的 Electron 进程使用应用目录下的 `.desktop-build/development/project` 作为开发项目。`DSH_DESKTOP_PNPM_ENTRY` 和 `DSH_DESKTOP_DSH_DIR` 用于选择明确的运行时资源。打包应用会忽略这些变量,从 `process.resourcesPath` 解析签名资源,并使用受管 Desktop profile。 +未打包的 Electron 进程使用应用目录下的 `.desktop-build/development/project` 作为开发项目。`DSH_DESKTOP_PNPM_ENTRY`、`DSH_DESKTOP_DSH_DIR` 和 `DSH_DESKTOP_PRIMARY_RUNTIME_DIR` 用于选择明确的运行时资源。开发启动器将 `DSH_DESKTOP_PRIMARY_RUNTIME_DIR` 设置为其已准备目标的 primary-runtime 目录,因为 Windows 目标固定为 x64。打包应用会忽略这些变量,从 `process.resourcesPath` 解析签名资源,并使用受管 Desktop profile。 ## 已知限制 diff --git a/apps/desktop/scripts/desktop-build-paths.d.mts b/apps/desktop/scripts/desktop-build-paths.d.mts index f20d60da48..2939a5b2e6 100644 --- a/apps/desktop/scripts/desktop-build-paths.d.mts +++ b/apps/desktop/scripts/desktop-build-paths.d.mts @@ -47,3 +47,16 @@ export function resolveDesktopTargetBuildPaths( hostPlatform?: NodeJS.Platform, hostArch?: string, ): DesktopTargetBuildPaths + +/** + * Resolve the primary-runtime directory an unpackaged development launch uses. + * @param env - Packaging environment. + * @param hostPlatform - Build-host platform used when no target override exists. + * @param hostArch - Build-host architecture used when no target override exists. + * @returns Primary-runtime directory prepared for the selected target. + */ +export function developmentRuntimeDirectory( + env?: NodeJS.ProcessEnv, + hostPlatform?: NodeJS.Platform, + hostArch?: string, +): string diff --git a/apps/desktop/scripts/desktop-build-paths.mjs b/apps/desktop/scripts/desktop-build-paths.mjs index 375e61b542..0d1abaefb4 100644 --- a/apps/desktop/scripts/desktop-build-paths.mjs +++ b/apps/desktop/scripts/desktop-build-paths.mjs @@ -69,3 +69,20 @@ export function resolveDesktopTargetBuildPaths( ) { return desktopTargetBuildPaths(resolveDesktopBuildTarget(env, hostPlatform, hostArch)) } + +/** + * Resolve the primary-runtime directory an unpackaged development launch uses. + * The build target fixes Windows to x64, so the shell cannot derive this directory from + * the architecture of the process that launched it. + * @param {NodeJS.ProcessEnv} env - Packaging environment. + * @param {NodeJS.Platform} hostPlatform - Build-host platform used when no target override exists. + * @param {string} hostArch - Build-host architecture used when no target override exists. + * @returns {string} Primary-runtime directory prepared for the selected target. + */ +export function developmentRuntimeDirectory( + env = process.env, + hostPlatform = process.platform, + hostArch = process.arch, +) { + return join(resolveDesktopTargetBuildPaths(env, hostPlatform, hostArch).runtime, 'primary-runtime') +} diff --git a/apps/desktop/scripts/dev.ts b/apps/desktop/scripts/dev.ts index 05e1bdbf0e..481ce59f4b 100644 --- a/apps/desktop/scripts/dev.ts +++ b/apps/desktop/scripts/dev.ts @@ -7,6 +7,7 @@ import { join, resolve } from 'node:path' import { parseArgs } from 'node:util' import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts' import type { DesktopRelease } from '../src/release.ts' +import { developmentRuntimeDirectory } from './desktop-build-paths.mjs' import { prepareDevelopmentProject } from './development-project.ts' import { preparePrimaryRuntime } from './prepare-primary-runtime.ts' @@ -66,6 +67,7 @@ async function launchElectron(): Promise { const environment: NodeJS.ProcessEnv = { ...process.env, DSH_HOME: home, + DSH_DESKTOP_PRIMARY_RUNTIME_DIR: developmentRuntimeDirectory(), DSH_DESKTOP_HOST_INSPECT_PORT: String(hostPort), DSH_DESKTOP_OPEN_DEVTOOLS: process.env.DSH_DESKTOP_OPEN_DEVTOOLS ?? '1', ELECTRON_ENABLE_LOGGING: process.env.ELECTRON_ENABLE_LOGGING ?? '1', diff --git a/apps/desktop/scripts/test-workspace-updates.ts b/apps/desktop/scripts/test-workspace-updates.ts index b768de5544..81c6c96921 100644 --- a/apps/desktop/scripts/test-workspace-updates.ts +++ b/apps/desktop/scripts/test-workspace-updates.ts @@ -56,7 +56,7 @@ try { ...(interactive ? ['--interactive'] : [])], { cwd: root, env: { ...environment, DSH_HOME: join(root, 'home'), USERPROFILE: root, HOME: root, TEMP: root, TMP: root, TMPDIR: root, DSH_WORKSPACE_UPDATE_ROOT: root, DSH_WORKSPACE_UPDATE_TOKEN: randomUUID(), - DSH_DESKTOP_OPEN_DEVTOOLS: '0' }, + DSH_DESKTOP_PRIMARY_RUNTIME_DIR: join(application, 'runtime', 'primary-runtime'), DSH_DESKTOP_OPEN_DEVTOOLS: '0' }, // Hiding the GUI process suppresses its first window and can suspend renderer frame callbacks. stdio: 'inherit', windowsHide: false, }) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index e5aa563c6e..925458c4d0 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -98,6 +98,14 @@ function runtimeResources(): RuntimeResources { return { node, nodeBin, pnpm, dsh } } +function developmentPrimaryRuntime(): string { + const directory = process.env.DSH_DESKTOP_PRIMARY_RUNTIME_DIR + if (directory === undefined || directory === '') { + throw new Error('dsh desktop: DSH_DESKTOP_PRIMARY_RUNTIME_DIR is required for an unpackaged launch') + } + return directory +} + function developmentHostInspectPort(enabled: boolean): number | undefined { const configured = process.env.DSH_DESKTOP_HOST_INSPECT_PORT if (!enabled || configured === undefined || configured === '') return undefined @@ -261,7 +269,7 @@ async function main(): Promise { const hostInspectPort = developmentHostInspectPort(development) const host = new DesktopHostProcess(resources.node, resources.dsh, activeProject, hostInspectPort, process.env, onFailure, - development ? join(app.getAppPath(), '.desktop-build', 'targets', `${process.platform === 'darwin' ? 'mac' : 'win'}-${process.arch}`, 'runtime', 'primary-runtime') + development ? developmentPrimaryRuntime() : join(process.resourcesPath, 'runtime', 'primary-runtime'), resources) return { diff --git a/apps/desktop/tests/desktop-build-paths.spec.ts b/apps/desktop/tests/desktop-build-paths.spec.ts index fdbab81b60..53767b270a 100644 --- a/apps/desktop/tests/desktop-build-paths.spec.ts +++ b/apps/desktop/tests/desktop-build-paths.spec.ts @@ -2,6 +2,7 @@ import { join, sep } from 'node:path' import { describe, expect, it } from 'vitest' import { desktopTargetBuildPaths, + developmentRuntimeDirectory, resolveDesktopBuildTarget, } from '../scripts/desktop-build-paths.mjs' @@ -38,6 +39,15 @@ describe('desktop build paths', () => { expect(arm64.downloads).not.toContain(`${sep}targets${sep}`) }) + it('resolves the development primary runtime from the build target rather than the host architecture', () => { + expect(developmentRuntimeDirectory({}, 'darwin', 'arm64')) + .toContain(join('targets', 'mac-arm64', 'runtime', 'primary-runtime')) + expect(developmentRuntimeDirectory({}, 'darwin', 'x64')) + .toContain(join('targets', 'mac-x64', 'runtime', 'primary-runtime')) + expect(developmentRuntimeDirectory({}, 'win32', 'arm64')) + .toContain(join('targets', 'win-x64', 'runtime', 'primary-runtime')) + }) + it('resolves environment overrides and rejects unsupported targets', () => { expect(resolveDesktopBuildTarget({ DSH_DESKTOP_TARGET_PLATFORM: 'darwin', diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 171e3855f1..da680befcf 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -291,6 +291,7 @@ beforeEach(() => { vi.spyOn(console, 'info').mockImplementation(() => {}) vi.stubEnv('DSH_DESKTOP_PNPM_ENTRY', 'test-pnpm') vi.stubEnv('DSH_DESKTOP_DSH_DIR', 'test-runtime') + vi.stubEnv('DSH_DESKTOP_PRIMARY_RUNTIME_DIR', 'test-primary-runtime') vi.stubGlobal('process', { ...process, platform: 'win32', arch: 'x64', resourcesPath: 'desktop-test-resources' }) vi.stubEnv('DSH_DESKTOP_HOST_INSPECT_PORT', undefined) vi.stubEnv('DSH_DESKTOP_MANDATORY_UPDATE_CONFIG', undefined) From a2487e34b78ec37f7b0d68d641241443a0c245a1 Mon Sep 17 00:00:00 2001 From: mektpoy Date: Mon, 21 Sep 2026 11:53:32 +0800 Subject: [PATCH 02/36] fix(desktop): lock the development runtime target and complete the resolution - main-startup: assert primaryRuntime for the unpackaged Host and cover a missing DSH_DESKTOP_PRIMARY_RUNTIME_DIR as a fatal startup dialog with no Host started. - test-workspace-updates: pass the prepared primary-runtime directory instead of an application/runtime path the runner never creates. - development-project: derive the descriptor platform/arch from resolveDesktopBuildTarget so it matches the prepared payload. - main: resolve the unpackaged primary runtime once at startup instead of inside the Host factory. - dev: keep the environment override symmetric with its neighbours. - README: document the variable as required for unpackaged launches and record the remaining win32-arm64 payload architecture limitation. --- apps/desktop/README.i18n.yaml | 4 ++-- apps/desktop/README.md | 3 ++- apps/desktop/README.zh.md | 3 ++- apps/desktop/scripts/dev.ts | 2 +- apps/desktop/scripts/development-project.ts | 5 ++++- apps/desktop/scripts/test-workspace-updates.ts | 3 ++- apps/desktop/src/main.ts | 6 ++++-- apps/desktop/tests/main-startup.spec.ts | 13 ++++++++++++- 8 files changed, 29 insertions(+), 10 deletions(-) diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 0dc1ab4e65..e8104799d1 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/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 apps/desktop/README.md -README.md: f3025746c57e620b1796e1920b95ce428155ce70 -README.zh.md: 8bee1971826d301eeabcc83eb550f13bb6988551 +README.md: 6b117410c74b0785f98352c5cfb5fed88dc7e712 +README.zh.md: d131e4c5c407ed61f79c3fea8c70c977b1f280ce diff --git a/apps/desktop/README.md b/apps/desktop/README.md index f3025746c5..6b117410c7 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -344,13 +344,14 @@ The downloaded bytes are inert, and the installation call is recorded rather tha ## Low-level development overrides -An unpackaged Electron process uses `.desktop-build/development/project` under its application directory as its development project. `DSH_DESKTOP_PNPM_ENTRY`, `DSH_DESKTOP_DSH_DIR`, and `DSH_DESKTOP_PRIMARY_RUNTIME_DIR` select explicit runtime resources. The development launcher sets `DSH_DESKTOP_PRIMARY_RUNTIME_DIR` to the primary-runtime directory of the target it prepared, because the Windows target is always x64. Packaged applications ignore these variables, resolve signed resources from `process.resourcesPath`, and use the managed Desktop profile. +An unpackaged Electron process uses `.desktop-build/development/project` under its application directory as its development project. `DSH_DESKTOP_PNPM_ENTRY` and `DSH_DESKTOP_DSH_DIR` are optional overrides with application-path defaults. `DSH_DESKTOP_PRIMARY_RUNTIME_DIR` is required for every unpackaged launch: the development launchers (`dev:desktop`, `start:desktop`, and the workspace-update qualification runner) set it to the primary-runtime directory of the target they prepared, and a launch without it fails with the fatal startup dialog. The launcher must set it because the shell cannot derive that directory from `process.arch`: the build target fixes Windows to x64 while the host may be arm64. Packaged applications ignore these variables, resolve signed resources from `process.resourcesPath`, and use the managed Desktop profile. ## Known limitations - Release signing, notarization, update hosting, and previous-version installed-artifact qualification require the production release environment. - Dependency lifecycle scripts follow pnpm’s build permissions; Desktop provides no separate approval dialog. - The desktop shell shares sessions, settings, credentials, workspaces, and storage under `$DSH_HOME` with CLI dsh, while executable packages, plugin activation, and lockfiles remain separate. +- Unpackaged startup on an Electron win32-arm64 host now succeeds, but the payload remains x64: the architecture check in `apps/desktop-host/src/primary-runtime.ts` compares the recorded payload architecture against the host `process.arch`, so the `load_workspace_dependencies` tool can still reject the primary runtime. ## Dev Note diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 8bee197182..d131e4c5c4 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -344,13 +344,14 @@ node apps/desktop/node_modules/pnpm/bin/pnpm.mjs --dir apps/desktop run test:upd ## 底层开发覆盖项 -未打包的 Electron 进程使用应用目录下的 `.desktop-build/development/project` 作为开发项目。`DSH_DESKTOP_PNPM_ENTRY`、`DSH_DESKTOP_DSH_DIR` 和 `DSH_DESKTOP_PRIMARY_RUNTIME_DIR` 用于选择明确的运行时资源。开发启动器将 `DSH_DESKTOP_PRIMARY_RUNTIME_DIR` 设置为其已准备目标的 primary-runtime 目录,因为 Windows 目标固定为 x64。打包应用会忽略这些变量,从 `process.resourcesPath` 解析签名资源,并使用受管 Desktop profile。 +未打包的 Electron 进程使用应用目录下的 `.desktop-build/development/project` 作为开发项目。`DSH_DESKTOP_PNPM_ENTRY` 和 `DSH_DESKTOP_DSH_DIR` 是带应用路径默认值的可选覆盖项。每次未打包启动都必须设置 `DSH_DESKTOP_PRIMARY_RUNTIME_DIR`:开发启动器(`dev:desktop`、`start:desktop` 及工作区更新验证运行器)会把它设置为自己已准备目标的 primary-runtime 目录;缺少该变量的启动会以致命启动对话框失败。启动器必须设置它,因为壳无法从 `process.arch` 推导该目录:构建目标将 Windows 固定为 x64,而宿主可能是 arm64。打包应用会忽略这些变量,从 `process.resourcesPath` 解析签名资源,并使用受管 Desktop profile。 ## 已知限制 - 发布签名、公证、更新托管和跨上一版本的已安装产物验证需要生产发布环境。 - 依赖的生命周期脚本遵循 pnpm 的构建权限;Desktop 不提供单独的审批对话框。 - 桌面壳与 CLI dsh 共享 `$DSH_HOME` 下的会话、设置、凭据、工作区和存储,但可执行包、插件激活和锁文件彼此隔离。 +- 在 Electron win32-arm64 宿主上,未打包启动现在可以成功,但载荷仍为 x64:`apps/desktop-host/src/primary-runtime.ts` 的架构校验会把载荷记录的架构与宿主 `process.arch` 比较,因此 `load_workspace_dependencies` 工具仍可能拒绝 primary runtime。 ## 开发备注 diff --git a/apps/desktop/scripts/dev.ts b/apps/desktop/scripts/dev.ts index 481ce59f4b..b9629e8346 100644 --- a/apps/desktop/scripts/dev.ts +++ b/apps/desktop/scripts/dev.ts @@ -67,7 +67,7 @@ async function launchElectron(): Promise { const environment: NodeJS.ProcessEnv = { ...process.env, DSH_HOME: home, - DSH_DESKTOP_PRIMARY_RUNTIME_DIR: developmentRuntimeDirectory(), + DSH_DESKTOP_PRIMARY_RUNTIME_DIR: process.env.DSH_DESKTOP_PRIMARY_RUNTIME_DIR ?? developmentRuntimeDirectory(), DSH_DESKTOP_HOST_INSPECT_PORT: String(hostPort), DSH_DESKTOP_OPEN_DEVTOOLS: process.env.DSH_DESKTOP_OPEN_DEVTOOLS ?? '1', ELECTRON_ENABLE_LOGGING: process.env.ELECTRON_ENABLE_LOGGING ?? '1', diff --git a/apps/desktop/scripts/development-project.ts b/apps/desktop/scripts/development-project.ts index eddd683161..5ccb8539f7 100644 --- a/apps/desktop/scripts/development-project.ts +++ b/apps/desktop/scripts/development-project.ts @@ -16,6 +16,7 @@ import { dirname, join } from 'node:path' import { createDevelopmentProjectMetadata } from '../src/project-manager.ts' import type { DesktopRelease } from '../src/release.ts' import { DESKTOP_RUNTIME_FILE, type DesktopRuntimeDescriptor } from '../src/runtime-tree.ts' +import { resolveDesktopBuildTarget } from './desktop-build-paths.mjs' interface PackageManifest { readonly name?: string @@ -128,8 +129,10 @@ export function prepareDevelopmentProject(options: DevelopmentProjectOptions): s const manifest = readManifest(join(destinationModules, name, 'package.json')) return typeof manifest.version === 'string' ? [{ name, version: manifest.version, path: `node_modules/${name}` }] : [] }) + const target = resolveDesktopBuildTarget() const runtime: DesktopRuntimeDescriptor = { schemaVersion: 1, release: options.release, - platform: process.platform, arch: process.arch, sharedPackages, files: [] } + platform: target === 'win-x64' ? 'win32' : 'darwin', + arch: target === 'mac-arm64' ? 'arm64' : 'x64', sharedPackages, files: [] } writeFileSync(join(options.projectDir, DESKTOP_RUNTIME_FILE), `${JSON.stringify(runtime, undefined, 2)}\n`) return options.projectDir } diff --git a/apps/desktop/scripts/test-workspace-updates.ts b/apps/desktop/scripts/test-workspace-updates.ts index 81c6c96921..234c1e60c2 100644 --- a/apps/desktop/scripts/test-workspace-updates.ts +++ b/apps/desktop/scripts/test-workspace-updates.ts @@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url' import { createDevelopmentProjectMetadata, createPluginProfile } from '../src/project-manager.ts' import { removeOwnedDirectory } from '../src/owned-directory.ts' import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts' +import { developmentRuntimeDirectory } from './desktop-build-paths.mjs' const repo = resolve(import.meta.dirname, '../../..') const interactive = process.argv.includes('--interactive') @@ -56,7 +57,7 @@ try { ...(interactive ? ['--interactive'] : [])], { cwd: root, env: { ...environment, DSH_HOME: join(root, 'home'), USERPROFILE: root, HOME: root, TEMP: root, TMP: root, TMPDIR: root, DSH_WORKSPACE_UPDATE_ROOT: root, DSH_WORKSPACE_UPDATE_TOKEN: randomUUID(), - DSH_DESKTOP_PRIMARY_RUNTIME_DIR: join(application, 'runtime', 'primary-runtime'), DSH_DESKTOP_OPEN_DEVTOOLS: '0' }, + DSH_DESKTOP_PRIMARY_RUNTIME_DIR: developmentRuntimeDirectory(), DSH_DESKTOP_OPEN_DEVTOOLS: '0' }, // Hiding the GUI process suppresses its first window and can suspend renderer frame callbacks. stdio: 'inherit', windowsHide: false, }) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index ddaca7ee1f..72644c9cd7 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -208,6 +208,9 @@ async function main(): Promise { const resources = runtimeResources() const paths = resolveDesktopPaths() const development = !app.isPackaged + const primaryRuntime = development + ? developmentPrimaryRuntime() + : join(process.resourcesPath, 'runtime', 'primary-runtime') const activeProject = paths.profile const manager = new DesktopProjectManager(paths, resources) let quitting = false @@ -269,8 +272,7 @@ async function main(): Promise { const hostInspectPort = developmentHostInspectPort(development) const host = new DesktopHostProcess(resources.node, resources.dsh, activeProject, hostInspectPort, process.env, onFailure, - development ? developmentPrimaryRuntime() - : join(process.resourcesPath, 'runtime', 'primary-runtime'), + primaryRuntime, resources) return { start: async () => { diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 2be4d17404..21dc92edb5 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -1424,13 +1424,24 @@ describe('desktop main startup', () => { harness.prepared.resolve() await harness.hostStarted.promise const project = join(harness.app.getAppPath(), '.desktop-build', 'development', 'project') - expect(harness.hosts[0]).toMatchObject({ node: process.execPath, runtime: project, profile: 'desktop-test-profile' }) + expect(harness.hosts[0]).toMatchObject({ node: process.execPath, runtime: project, + primaryRuntime: 'test-primary-runtime', profile: 'desktop-test-profile' }) expect(harness.applyRelease).toHaveBeenCalledOnce() harness.hosts[0]!.ready.resolve() await harness.navigated.promise expect(harness.dialog.showErrorBox).not.toHaveBeenCalled() }) + it('fails an unpackaged launch that receives no primary runtime directory', async () => { + harness.app.isPackaged = false + vi.stubEnv('DSH_DESKTOP_PRIMARY_RUNTIME_DIR', undefined) + await import('../src/main.ts') + await harness.dialogShown.promise + const options = harness.dialog.showMessageBox.mock.calls[0]![0] as MessageBoxOptions + expect(options.detail).toContain('DSH_DESKTOP_PRIMARY_RUNTIME_DIR is required for an unpackaged launch') + expect(harness.hosts).toHaveLength(0) + }) + it('keeps startup errors in the existing window without a retry handler', async () => { await import('../src/main.ts') await harness.preparing.promise From d685383ee64336517f80daf26f6ee7e4828a874f Mon Sep 17 00:00:00 2001 From: dsh-agent Date: Mon, 21 Sep 2026 16:39:34 +0800 Subject: [PATCH 03/36] fix(native-command): resolve packaged Windows app icons Windows 11 ships some applications (Notepad, other Store apps) as MSIX packages whose Shell handler reports an indirect icon reference such as @{Microsoft.WindowsNotepad_...?ms-resource://.../NotepadAppList.png} instead of a file path. SHDefExtractIcon cannot read that reference, so the menu showed the generic fallback glyph. Resolve an indirect reference with SHLoadIndirectString before extracting, and fall back to the handler's executable when the resolved source still yields no image. --- packages/util/native-command/README.i18n.yaml | 4 +- packages/util/native-command/README.md | 2 +- packages/util/native-command/README.zh.md | 2 +- .../src/file-applications-windows.ts | 39 +++++++++++++++++-- 4 files changed, 39 insertions(+), 8 deletions(-) diff --git a/packages/util/native-command/README.i18n.yaml b/packages/util/native-command/README.i18n.yaml index e0280c9599..317286a9d9 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: 2ae2fd047e6359428debfe9252dc87146b646596 -README.zh.md: 62bb667af4705aac1373d9d0dda809dbab60e7da +README.md: 1d42f4f15b4d36a09dfb76db7e951d4ec217df86 +README.zh.md: e9a0f690c556ce963635047f6d14dc4fbe473437 diff --git a/packages/util/native-command/README.md b/packages/util/native-command/README.md index 2ae2fd047e..1d42f4f15b 100644 --- a/packages/util/native-command/README.md +++ b/packages/util/native-command/README.md @@ -49,7 +49,7 @@ 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)` returns registered applications, localized names, icons, and the current default. macOS 12 and later use LaunchServices; Windows uses Shell association handlers; Linux uses GIO with shared XDG desktop-entry and icon lookup. `openNativeFileApplication(path, application, signal)` revalidates the handler without changing the system default. Windows delegates invocation to the Shell, and Linux delegates argument expansion to `gio launch`. WSL translates the path and uses the Windows adapter. Callers authorize the local file path. Native integration tests use private Windows file associations and Linux XDG roots on their respective platforms. +`nativeFileApplications(path, signal)` returns registered applications, localized names, icons, and the current default. macOS 12 and later use LaunchServices; Windows uses Shell association handlers and resolves a packaged application’s indirect icon reference before extraction; Linux uses GIO with shared XDG desktop-entry and icon lookup. `openNativeFileApplication(path, application, signal)` revalidates the handler without changing the system default. Windows delegates invocation to the Shell, and Linux delegates argument expansion to `gio launch`. WSL translates the path and uses the Windows adapter. Callers authorize the local file path. Native integration tests use private Windows file associations and Linux XDG roots on their respective platforms. ----- diff --git a/packages/util/native-command/README.zh.md b/packages/util/native-command/README.zh.md index 62bb667af4..e9a0f690c5 100644 --- a/packages/util/native-command/README.zh.md +++ b/packages/util/native-command/README.zh.md @@ -49,7 +49,7 @@ 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 12 及以上版本使用 LaunchServices,Windows 使用 Shell 关联处理器,Linux 使用 GIO,并共用 XDG 桌面文件和图标读取逻辑。`openNativeFileApplication(path, application, signal)` 重新验证关联应用,不修改系统默认值。Windows 交给 Shell 启动应用,Linux 交给 `gio launch` 展开参数。WSL 转换路径后使用 Windows 适配器。调用方负责验证本地文件路径。原生集成测试分别在相应平台使用独立的 Windows 文件关联和 Linux XDG 目录。 +`nativeFileApplications(path, signal)` 返回关联应用、本地化名称、图标和当前默认项。macOS 12 及以上版本使用 LaunchServices,Windows 使用 Shell 关联处理器并在提取前解析打包应用的间接图标引用,Linux 使用 GIO,并共用 XDG 桌面文件和图标读取逻辑。`openNativeFileApplication(path, application, signal)` 重新验证关联应用,不修改系统默认值。Windows 交给 Shell 启动应用,Linux 交给 `gio launch` 展开参数。WSL 转换路径后使用 Windows 适配器。调用方负责验证本地文件路径。原生集成测试分别在相应平台使用独立的 Windows 文件关联和 Linux XDG 目录。 ----- diff --git a/packages/util/native-command/src/file-applications-windows.ts b/packages/util/native-command/src/file-applications-windows.ts index a468c9979c..c9950875cd 100644 --- a/packages/util/native-command/src/file-applications-windows.ts +++ b/packages/util/native-command/src/file-applications-windows.ts @@ -41,6 +41,8 @@ public static class DshFileAssociations { static extern int SHDefExtractIcon(string path, int index, uint flags, out IntPtr large, out IntPtr small, uint size); [DllImport("user32.dll")] static extern bool DestroyIcon(IntPtr icon); + [DllImport("shlwapi.dll", CharSet = CharSet.Unicode)] + static extern int SHLoadIndirectString(string source, StringBuilder output, uint size, IntPtr reserved); [DllImport("shlwapi.dll", PreserveSig = false)] static extern void SHCreateThreadRef(IntPtr count, out IntPtr reference); @@ -118,12 +120,18 @@ public static class DshFileAssociations { var text = new StringBuilder((int)size); return AssocQueryString(0, kind, extension, null, text, ref size) == 0 ? text.ToString() : null; } - static string IconData(IHandler handler) { + // Resolve a packaged application's indirect icon reference, for example + // @{Microsoft.WindowsNotepad_...?ms-resource://.../NotepadAppList.png}, to + // the resource file the Shell would draw; an unresolved reference is null. + static string ResolveIndirectIcon(string source) { + var output = new StringBuilder(1024); + return SHLoadIndirectString(source, output, (uint)output.Capacity, IntPtr.Zero) == 0 ? output.ToString() : null; + } + // Read one icon source into a 32px PNG: an image file directly, otherwise the + // resource the Shell extracts at the given index. A missing resource is null. + static string PngFromIconSource(string source, int index) { IntPtr large = IntPtr.Zero, small = IntPtr.Zero; try { - string source; int index; - handler.GetIconLocation(out source, out index); - source = Environment.ExpandEnvironmentVariables(source); if (Path.GetExtension(source).Equals(".png", StringComparison.OrdinalIgnoreCase)) { using (var original = Image.FromFile(source)) using (var resized = new Bitmap(original, new Size(32, 32))) @@ -147,6 +155,29 @@ public static class DshFileAssociations { if (small != IntPtr.Zero) DestroyIcon(small); } } + static string IconData(IHandler handler) { + string source = null; int index = 0; + try { handler.GetIconLocation(out source, out index); } catch (Exception) { + // A handler without a drawable icon location falls back to its executable. + } + if (!String.IsNullOrEmpty(source)) { + source = Environment.ExpandEnvironmentVariables(source); + if (source.StartsWith("@{", StringComparison.Ordinal) && source.EndsWith("}", StringComparison.Ordinal)) { + var resolved = ResolveIndirectIcon(source); + if (!String.IsNullOrEmpty(resolved)) source = resolved; + } + var icon = PngFromIconSource(source, index); + if (icon != null) return icon; + } + // Packaged handlers name the executable only in GetName; extract there too. + string executable = null; + try { handler.GetName(out executable); } catch (Exception) { + // A handler that names no executable has no icon to fall back to. + } + return String.IsNullOrEmpty(executable) || String.Equals(executable, source, StringComparison.OrdinalIgnoreCase) + ? null + : PngFromIconSource(executable, 0); + } static void Visit(string path, Action visit) { var extension = Path.GetExtension(path); if (extension.Length == 0) return; From 6fa5151d8a7cea9433b570861f7a9458bc0dc151 Mon Sep 17 00:00:00 2001 From: dsh-agent Date: Mon, 21 Sep 2026 16:39:38 +0800 Subject: [PATCH 04/36] fix(ui-open-in-app): keep an application as the default file action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Host's default marker is best effort: on Windows it comes from the Shell's ASSOCSTR_EXECUTABLE/APPID answer, which does not always name an enumerated handler. When no application carried that marker the split button promoted 显示文件位置 to the primary action even though the menu listed applications that can open the file. Select the OS-marked default when present, otherwise the first registered handler in Shell preference order, and mark it as the menu default. Reveal is promoted only when no application is registered at all. --- .../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 +- .../src/client/OpenTargetButton.tsx | 13 +++++++--- .../tests/open-path-action.client.spec.tsx | 24 ++++++++++++------- 5 files changed, 29 insertions(+), 16 deletions(-) diff --git a/packages/client/ui-open-in-app/README.i18n.yaml b/packages/client/ui-open-in-app/README.i18n.yaml index b0fa75db3e..4ceb2983f9 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: 138be128c291fc4a0611295c54768a95f33f3ae1 -README.zh.md: b81f62f9dbf8ccfbc1822b8a72df95e7169228c0 +README.md: 4f8462b29579b7290398eac802a614c250f7501b +README.zh.md: df58e838628df98e0c95f6f67aab76299002b3c8 diff --git a/packages/client/ui-open-in-app/README.md b/packages/client/ui-open-in-app/README.md index 138be128c2..4f8462b295 100644 --- a/packages/client/ui-open-in-app/README.md +++ b/packages/client/ui-open-in-app/README.md @@ -31,7 +31,7 @@ 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” 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 menus list the discovered associated applications without a separate “Open in default app” row. “Show file location” stays in a fixed footer separated from the scrolling applications. A successful query with only one available action renders a single button without a dropdown. Initial file-association loading uses a gray skeleton icon. When the Host identifies a default application the menu marks it “(default)” and the main button opens it; otherwise the first associated application takes that place, so the main button reveals the file only when no application is registered. Directory menus have no reveal row. Choosing a file application does not change the system default. An unpreviewable file uses the same menu in a 40px-high split button with an icon and action text; its label is “Open” or “Show file location” according to the default action. 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 b81f62f9db..df58e83862 100644 --- a/packages/client/ui-open-in-app/README.zh.md +++ b/packages/client/ui-open-in-app/README.zh.md @@ -31,7 +31,7 @@ kind: "package-reference" 会话标题栏和文档标题栏共用同一个高 24px、圆角 9px 的分体按钮。两个标题栏都只显示图标,悬停提示显示默认应用名称或文件定位动作。两者都显示默认动作的图标,在菜单的默认应用后标注“(默认)”,仅在自己的操作执行期间禁用,并通过短暂提示报告失败。目录适配器使用已有的跨平台应用列表,将最后一次成功选择保存在 `dsh.open-in-app.choice` 中;原选择不可用时回退到第一个可用应用。 -文件菜单列出已发现的关联应用,不单列“用默认应用打开”。“显示文件位置”固定在菜单底部,通过分隔线与滚动的应用列表分开。查询成功且只有一个可用操作时显示单按钮,不再显示下拉箭头。文件关联首次加载时使用灰色骨架图标。未识别到默认应用时,该项显示为“显示文件位置(默认)”,主按钮也执行文件定位。目录菜单不包含定位项。选择文件应用不修改系统默认应用。无法预览文件时,空态使用同一菜单,按钮增大到 40px 高并显示图标和动作文字;根据默认动作显示“打开”或“显示文件位置”。 +文件菜单列出已发现的关联应用,不单列“用默认应用打开”。“显示文件位置”固定在菜单底部,通过分隔线与滚动的应用列表分开。查询成功且只有一个可用操作时显示单按钮,不再显示下拉箭头。文件关联首次加载时使用灰色骨架图标。Host 识别出默认应用时,菜单将该项标注为“(默认)”,主按钮打开该应用;否则第一个关联应用占据该位置,只有在一个关联应用都没有时主按钮才执行文件定位。目录菜单不包含定位项。选择文件应用不修改系统默认应用。无法预览文件时,空态使用同一菜单,按钮增大到 40px 高并显示图标和动作文字;根据默认动作显示“打开”或“显示文件位置”。 使用同一查询函数和文件的已挂载控件共用查询与结果,打开任一菜单会刷新所有相关控件。最后一个控件释放后取消查询并清除状态。已取消的查询不会覆盖其他文件的结果。查询失败时菜单显示提示,并以文件定位作为默认动作。文件关联查询的平台支持范围见 [native-command](../../util/native-command/README.zh.md);查询结果为空时,包括平台尚无查询适配器的情况,控件统一使用定位动作,不按操作系统分支处理。 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 93a3a34657..9c4fa97242 100644 --- a/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx +++ b/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx @@ -71,7 +71,8 @@ function ApplicationIcon({ source, size = 14 }: { source: string | null; size?: } /** - * Render identical split buttons for files and directories. File reveal always stays last and becomes the default when no app is selected. + * Render identical split buttons for files and directories. File reveal always + * stays last; it is the default only when no application is registered. * @param props - target applications, default selection, and operations. * @returns the control and its transient failure feedback. */ @@ -79,7 +80,13 @@ 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) + // Files open through an application whenever one is registered: the OS-marked + // default when the Host reported one, else the first entry in the Shell's + // preference order. Reveal is only promoted with no registered application, so + // a missing default marker never hides the applications behind it. + const preferred = kind === 'file' + ? applications.find(app => app.id === defaultId) ?? applications[0] + : applications.find(app => app.id === defaultId) 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 @@ -105,7 +112,7 @@ export function OpenTargetButton(props: OpenTargetButtonProps): ReactNode { ...applications.map(app => ({ id: `app:${app.id}`, icon: , - label: app.id === defaultId ? t('path.appDefault', { app: app.name }) : app.name, + label: app.id === preferred?.id ? t('path.appDefault', { app: app.name }) : app.name, })), ...(props.failed ? [{ id: 'unavailable', label: t('path.appsError'), disabled: true }] : []), ]} 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 0a4a13f090..a504c3b158 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 @@ -196,16 +196,22 @@ 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([{ 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 }) => { +it('opens with the first registered application when none is marked default', async () => { const b = bench() - render( apps} />) + render( [{ id: '/Player.app', name: 'Player', default: false, icon: null }]} />) await act(async () => {}) - if (apps.length > 0) { - 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' }) - } else expect(screen.queryByRole('button', { name: zh['path.more'] })).toBeNull() + const main = screen.getByRole('button', { name: t('open.title', { app: 'Player' }) }) + await act(async () => { fireEvent.click(main) }) + expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'open', undefined) + await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.more'] })) }) + expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['Player(默认)', zh['path.reveal']]) +}) + +it('uses reveal as the default only when no application is registered', async () => { + const b = bench() + render( []} />) + await act(async () => {}) + expect(screen.queryByRole('button', { name: zh['path.more'] })).toBeNull() await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.reveal'] })) }) expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'reveal', undefined) }) @@ -262,7 +268,7 @@ it('uses the same application menu in the prominent empty-state control', async expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'open', '/Music.app') }) -it('labels the prominent default action as reveal when no default application is discovered', async () => { +it('labels the prominent default action as reveal when no application is registered', async () => { const b = bench() render( []} />) await act(async () => {}) From 2545414c02216a843406979bcc5b4abb8c8ce58a Mon Sep 17 00:00:00 2001 From: dsh-agent Date: Mon, 21 Sep 2026 16:46:45 +0800 Subject: [PATCH 05/36] test(preview): lock ini admission to the code renderer The issue reported .ini files with no preview. On master the ini grammar is already in CODE_HIGHLIGHT_EXTENSIONS, so the code renderer claims the suffix and .ini previews. No production change is required; this test records the admission behavior so a future allowlist change cannot silently drop the reported suffix. --- .../tests/code-registration.client.spec.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/client/ui-sidebar-documentpreview/tests/code-registration.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/code-registration.client.spec.tsx index 17fe414745..c429935443 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/code-registration.client.spec.tsx +++ b/packages/client/ui-sidebar-documentpreview/tests/code-registration.client.spec.tsx @@ -48,6 +48,15 @@ describe('code renderer registration', () => { expect(h.previews.candidates('notes.unknown')).toEqual([]) }) + it('admits ini and other text configuration suffixes to the code renderer', async () => { + const h = await boot() + await h.rt.mount(plugin) + for (const path of ['config.ini', 'C:\\work\\settings.INI', 'pyproject.toml']) { + expect(h.previews.candidates(path).map(item => item.id), path).toEqual([ID]) + } + expect(h.previews.candidates('notes.unknown')).toEqual([]) + }) + it('resolves localized names at read time and removes metadata, locale, and body on disposal', async () => { const h = await boot() await h.declare() From 80e4da0e3d4071ba7a0ef9c5166fff7bd960c6d6 Mon Sep 17 00:00:00 2001 From: mektpoy Date: Wed, 23 Sep 2026 16:03:17 +0800 Subject: [PATCH 06/36] fix(desktop): take the development project target as an explicit input `prepareDevelopmentProject()` resolved the build target from the host platform and architecture, so a Linux host threw `unsupported target linux-x64` while writing the runtime descriptor. Pass the target in instead: the launcher supplies the target it prepared, and isolated tests supply a fixed supported target. Extract `desktopTargetPlatform()` into desktop-build-paths.mjs so the target-to-payload mapping has one home, and use it for the qualification runner, which previously derived both its target directory and its descriptor from the host architecture. Correct the README limitation to name the module that now performs the architecture check. --- apps/desktop/README.i18n.yaml | 4 ++-- apps/desktop/README.md | 2 +- apps/desktop/README.zh.md | 2 +- .../desktop/scripts/desktop-build-paths.d.mts | 11 +++++++++ apps/desktop/scripts/desktop-build-paths.mjs | 24 ++++++++++++++++--- apps/desktop/scripts/dev.ts | 3 ++- apps/desktop/scripts/development-project.ts | 11 +++++---- .../desktop/scripts/test-workspace-updates.ts | 6 ++--- .../desktop/tests/desktop-build-paths.spec.ts | 8 +++++++ .../desktop/tests/development-project.spec.ts | 9 +++++-- apps/desktop/tests/welcome-flow.e2e.ts | 3 +++ 11 files changed, 65 insertions(+), 18 deletions(-) diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index cc8adb7a2a..d8e4311401 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/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 apps/desktop/README.md -README.md: 52d220cb4d109c6467b2a4a72aa60b4d9be787a6 -README.zh.md: 5caf8414d02e3b03e6cfc7bd3cab427b1bd5fb8e +README.md: d378c10af1c96a3db56a56d4b783b102ca4d794e +README.zh.md: b26c20c57996c7b30378910d13479cebea4a07d1 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 52d220cb4d..d378c10af1 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -408,7 +408,7 @@ An unpackaged Electron process uses `.desktop-build/development/project` under i - Release signing, notarization, update hosting, and previous-version installed-artifact qualification require the production release environment. - Dependency lifecycle scripts follow pnpm’s build permissions; Desktop provides no separate approval dialog. - The desktop shell shares sessions, settings, credentials, workspaces, and storage under `$DSH_HOME` with CLI dsh, while executable packages, plugin activation, and lockfiles remain separate. -- Unpackaged startup on an Electron win32-arm64 host now succeeds, but the payload remains x64: the architecture check in `apps/desktop-host/src/primary-runtime.ts` compares the recorded payload architecture against the host `process.arch`, so the `load_workspace_dependencies` tool can still reject the primary runtime. +- Unpackaged startup on an Electron win32-arm64 host now succeeds, but the payload remains x64: the architecture check in `packages/skill/tool-workspace-dependencies/src/index.ts` compares the recorded payload architecture against the host `process.arch`, so the `load_workspace_dependencies` tool can still reject the primary runtime. Sign in opens the configured platform page in the system browser. The Host owns PKCE and a temporary loopback callback, saves the credential before entering the workspace, and redirects the browser to the platform completion page. Opened and copied authorization links carry the effective Desktop theme as `theme=light` or `theme=dark`; `system` resolves at the time of the action. Cancel withdraws the local attempt even if the platform page later approves it. Settings offers Account sign-out; without a separate API key, sign-out returns to the welcome window. Packaged applications register dsh://open to show the window without passing credentials. On macOS, the development launcher prepares an ad-hoc-signed `Harness Dev.app` under `.desktop-build/development`, declares `dsh` in its Info.plist, and registers it with Launch Services. It loads the current workspace and records the selected development home, browser-data path, and debug settings for cold starts. Starting this bundle makes it the default `dsh://` handler; starting the packaged application registers the packaged handler again. The generated bundle does not contain account tokens and requires the workspace and prepared runtime to remain available. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 5caf8414d0..b26c20c579 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -408,7 +408,7 @@ node apps/desktop/node_modules/pnpm/bin/pnpm.mjs --dir apps/desktop run test:upd - 发布签名、公证、更新托管和跨上一版本的已安装产物验证需要生产发布环境。 - 依赖的生命周期脚本遵循 pnpm 的构建权限;Desktop 不提供单独的审批对话框。 - 桌面壳与 CLI dsh 共享 `$DSH_HOME` 下的会话、设置、凭据、工作区和存储,但可执行包、插件激活和锁文件彼此隔离。 -- 在 Electron win32-arm64 宿主上,未打包启动现在可以成功,但载荷仍为 x64:`apps/desktop-host/src/primary-runtime.ts` 的架构校验会把载荷记录的架构与宿主 `process.arch` 比较,因此 `load_workspace_dependencies` 工具仍可能拒绝 primary runtime。 +- 在 Electron win32-arm64 宿主上,未打包启动现在可以成功,但载荷仍为 x64:`packages/skill/tool-workspace-dependencies/src/index.ts` 的架构校验会把载荷记录的架构与宿主 `process.arch` 比较,因此 `load_workspace_dependencies` 工具仍可能拒绝 primary runtime。 登录会在系统浏览器中打开配置的平台页面。Host 负责 PKCE 和临时本机回调,在进入工作区前保存凭证,再将浏览器跳转到平台完成页。打开和复制的授权链接通过 `theme=light` 或 `theme=dark` 携带当前生效的 Desktop 主题;`system` 在执行操作时解析。即使平台页面随后批准,取消仍会撤销本地尝试。设置中的账号页面提供退出;没有独立 API Key 时,退出后返回欢迎窗。打包应用注册 dsh://open,只显示窗口而不传递凭证。macOS 开发启动器在 `.desktop-build/development` 下准备经临时签名的 `Harness Dev.app`,在 Info.plist 中声明 `dsh` 并注册到 Launch Services。它加载当前工作区,并记录选定的开发 home、浏览器数据路径和调试设置,以供冷启动使用。启动此应用会将其设为 `dsh://` 默认处理程序;启动打包应用会重新注册打包版处理程序。生成的应用包不包含账号 token,依赖工作区和已准备的运行环境继续存在。 diff --git a/apps/desktop/scripts/desktop-build-paths.d.mts b/apps/desktop/scripts/desktop-build-paths.d.mts index a05c7f77cc..9a8e408ed1 100644 --- a/apps/desktop/scripts/desktop-build-paths.d.mts +++ b/apps/desktop/scripts/desktop-build-paths.d.mts @@ -36,6 +36,17 @@ export function resolveDesktopBuildTarget( */ export function desktopTargetBuildPaths(target: DesktopAutoUpdateTarget): DesktopTargetBuildPaths +/** + * Return the platform and architecture of the payload one release target prepares. + * Windows is prepared as x64 only, so this differs from the build host on an arm64 Windows machine. + * @param target - Supported Desktop target name. + * @returns Platform and architecture of the prepared payload. + */ +export function desktopTargetPlatform(target: DesktopAutoUpdateTarget): { + readonly platform: 'darwin' | 'win32' + readonly arch: 'arm64' | 'x64' +} + /** * Resolve the paths owned by the target selected in a packaging environment. * @param env - Packaging environment. diff --git a/apps/desktop/scripts/desktop-build-paths.mjs b/apps/desktop/scripts/desktop-build-paths.mjs index 6519db0915..4f33e804c3 100644 --- a/apps/desktop/scripts/desktop-build-paths.mjs +++ b/apps/desktop/scripts/desktop-build-paths.mjs @@ -29,15 +29,19 @@ export function resolveDesktopBuildTarget( return /** @type {'mac-arm64' | 'mac-x64' | 'win-x64'} */ (target) } +function assertSupportedTarget(target) { + if (!SUPPORTED_TARGETS.has(target)) { + throw new Error(`desktop build paths: unsupported target ${String(target)}`) + } +} + /** * Return the mutable preparation and artifact directories owned by one release target. * @param {'mac-arm64' | 'mac-x64' | 'win-x64'} target - Supported Desktop target name. * @returns {{ root: string, artifacts: string, unsignedArtifacts: string, runtime: string, packageSet: string, dsh: string, dshPnpm: string, electron: string, packedDsh: string, packedVendor: string, packedLandlock: string, downloads: string }} Target paths plus the shared immutable download cache. */ export function desktopTargetBuildPaths(target) { - if (!SUPPORTED_TARGETS.has(target)) { - throw new Error(`desktop build paths: unsupported target ${String(target)}`) - } + assertSupportedTarget(target) const root = join(BUILD_ROOT, 'targets', target) const packed = join(root, 'packed') return { @@ -56,6 +60,20 @@ export function desktopTargetBuildPaths(target) { } } +/** + * Return the platform and architecture of the payload one release target prepares. + * Windows is prepared as x64 only, so this differs from the build host on an arm64 Windows machine. + * @param {'mac-arm64' | 'mac-x64' | 'win-x64'} target - Supported Desktop target name. + * @returns {{ platform: 'darwin' | 'win32', arch: 'arm64' | 'x64' }} Platform and architecture of the prepared payload. + */ +export function desktopTargetPlatform(target) { + assertSupportedTarget(target) + return { + platform: /** @type {'darwin' | 'win32'} */ (target === 'win-x64' ? 'win32' : 'darwin'), + arch: /** @type {'arm64' | 'x64'} */ (target === 'mac-arm64' ? 'arm64' : 'x64'), + } +} + /** * Resolve the paths owned by the target selected in a packaging environment. * @param {NodeJS.ProcessEnv} env - Packaging environment. diff --git a/apps/desktop/scripts/dev.ts b/apps/desktop/scripts/dev.ts index a56cb029f0..fdd724e579 100644 --- a/apps/desktop/scripts/dev.ts +++ b/apps/desktop/scripts/dev.ts @@ -7,7 +7,7 @@ import { join, resolve } from 'node:path' import { parseArgs } from 'node:util' import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts' import type { DesktopRelease } from '../src/release.ts' -import { developmentRuntimeDirectory } from './desktop-build-paths.mjs' +import { developmentRuntimeDirectory, resolveDesktopBuildTarget } from './desktop-build-paths.mjs' import { prepareDevelopmentProject } from './development-project.ts' import { prepareDevelopmentApp } from './development-app.ts' import { preparePrimaryRuntime } from './prepare-primary-runtime.ts' @@ -117,6 +117,7 @@ async function main(): Promise { hostDir: join(REPOSITORY_ROOT, 'apps', 'desktop-host'), dependencyDir: join(REPOSITORY_ROOT, 'node_modules', '.pnpm', 'node_modules'), release, + target: resolveDesktopBuildTarget(), }) await preparePrimaryRuntime() await launchElectron() diff --git a/apps/desktop/scripts/development-project.ts b/apps/desktop/scripts/development-project.ts index 7e17269cf0..d544ce6d36 100644 --- a/apps/desktop/scripts/development-project.ts +++ b/apps/desktop/scripts/development-project.ts @@ -16,7 +16,8 @@ import { dirname, join } from 'node:path' import { createDevelopmentProjectMetadata } from '../src/project-manager.ts' import type { DesktopRelease } from '../src/release.ts' import { DESKTOP_RUNTIME_FILE, type DesktopRuntimeDescriptor } from '../src/runtime-tree.ts' -import { resolveDesktopBuildTarget } from './desktop-build-paths.mjs' +import type { DesktopAutoUpdateTarget } from './desktop-auto-update-environment.mjs' +import { desktopTargetPlatform } from './desktop-build-paths.mjs' interface PackageManifest { readonly name?: string @@ -36,6 +37,8 @@ export interface DevelopmentProjectOptions { readonly dependencyDir: string /** Release identity written into the disposable project metadata. */ readonly release: DesktopRelease + /** Build target whose prepared payload the disposable project runs against. */ + readonly target: DesktopAutoUpdateTarget } function readManifest(path: string): PackageManifest { @@ -116,7 +119,7 @@ function mirrorWorkspaceDependencies(roots: readonly string[], destinationRoot: /** * Replace one disposable project with links to the current built workspace. - * @param options - Project destination, CLI package, and release identity. + * @param options - Project destination, CLI package, release identity, and build target. * @returns the absolute project directory supplied by the caller. */ export function prepareDevelopmentProject(options: DevelopmentProjectOptions): string { @@ -159,10 +162,8 @@ export function prepareDevelopmentProject(options: DevelopmentProjectOptions): s const manifest = readManifest(join(destinationModules, name, 'package.json')) return typeof manifest.version === 'string' ? [{ name, version: manifest.version, path: `node_modules/${name}` }] : [] }) - const target = resolveDesktopBuildTarget() const runtime: DesktopRuntimeDescriptor = { schemaVersion: 1, release: options.release, - platform: target === 'win-x64' ? 'win32' : 'darwin', - arch: target === 'mac-arm64' ? 'arm64' : 'x64', sharedPackages, files: [] } + ...desktopTargetPlatform(options.target), sharedPackages, files: [] } writeFileSync(join(options.projectDir, DESKTOP_RUNTIME_FILE), `${JSON.stringify(runtime, undefined, 2)}\n`) return options.projectDir } diff --git a/apps/desktop/scripts/test-workspace-updates.ts b/apps/desktop/scripts/test-workspace-updates.ts index 8f6a372513..41f40caa09 100644 --- a/apps/desktop/scripts/test-workspace-updates.ts +++ b/apps/desktop/scripts/test-workspace-updates.ts @@ -8,7 +8,7 @@ import { fileURLToPath } from 'node:url' import { createDevelopmentProjectMetadata, createPluginProfile } from '../src/project-manager.ts' import { removeOwnedDirectory } from '../src/owned-directory.ts' import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts' -import { developmentRuntimeDirectory } from './desktop-build-paths.mjs' +import { desktopTargetPlatform, developmentRuntimeDirectory, resolveDesktopBuildTarget } from './desktop-build-paths.mjs' const repo = resolve(import.meta.dirname, '../../..') const interactive = process.argv.includes('--interactive') @@ -28,10 +28,11 @@ try { nodeVersion: execFileSync(electron, ['-p', 'process.versions.node'], { encoding: 'utf8', env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, windowsHide: true }).trim(), pnpmVersion: pnpm.version, hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION } + const target = resolveDesktopBuildTarget() createDevelopmentProjectMetadata(project, release) createPluginProfile(profile) await writeFile(join(project, 'desktop-runtime.json'), JSON.stringify({ - schemaVersion: 1, release, platform: process.platform, arch: process.arch, files: [], + schemaVersion: 1, release, ...desktopTargetPlatform(target), files: [], sharedPackages: ['@deepseek-ai/dsh', '@deepseek-ai/dsh-desktop-host'] .map(name => ({ name, version: manifest.version, path: `node_modules/${name}` })), })) @@ -44,7 +45,6 @@ try { for (const owner of [application, project]) { await symlink(join(repo, 'node_modules/.pnpm/node_modules'), join(owner, 'node_modules'), process.platform === 'win32' ? 'junction' : 'dir') } - const target = `${process.platform === 'darwin' ? 'mac' : 'win'}-${process.arch}` const targetRoot = join(application, '.desktop-build/targets', target) await mkdir(targetRoot, { recursive: true }) await symlink(join(repo, 'apps/desktop/.desktop-build/targets', target, 'runtime'), join(targetRoot, 'runtime'), diff --git a/apps/desktop/tests/desktop-build-paths.spec.ts b/apps/desktop/tests/desktop-build-paths.spec.ts index 3475c8691d..a5e9faf296 100644 --- a/apps/desktop/tests/desktop-build-paths.spec.ts +++ b/apps/desktop/tests/desktop-build-paths.spec.ts @@ -2,6 +2,7 @@ import { join, sep } from 'node:path' import { describe, expect, it } from 'vitest' import { desktopTargetBuildPaths, + desktopTargetPlatform, developmentRuntimeDirectory, resolveDesktopBuildTarget, } from '../scripts/desktop-build-paths.mjs' @@ -49,6 +50,13 @@ describe('desktop build paths', () => { .toContain(join('targets', 'win-x64', 'runtime', 'primary-runtime')) }) + it('maps every target to the platform and architecture of the payload it prepares', () => { + expect(desktopTargetPlatform('mac-arm64')).toEqual({ platform: 'darwin', arch: 'arm64' }) + expect(desktopTargetPlatform('mac-x64')).toEqual({ platform: 'darwin', arch: 'x64' }) + expect(desktopTargetPlatform('win-x64')).toEqual({ platform: 'win32', arch: 'x64' }) + expect(() => desktopTargetPlatform('linux-x64' as 'mac-x64')).toThrow(/unsupported target/u) + }) + it('resolves environment overrides and rejects unsupported targets', () => { expect(resolveDesktopBuildTarget({ DSH_DESKTOP_TARGET_PLATFORM: 'darwin', diff --git a/apps/desktop/tests/development-project.spec.ts b/apps/desktop/tests/development-project.spec.ts index 07e0a0a5ca..d08b82d3ac 100644 --- a/apps/desktop/tests/development-project.spec.ts +++ b/apps/desktop/tests/development-project.spec.ts @@ -46,9 +46,10 @@ describe('desktop development project', () => { writeFileSync(join(host, 'lib/index.js'), '') writeFileSync(join(dependency, 'package.json'), JSON.stringify({ name: 'unhoisted', version: '1.2.3' })) symlinkSync(dependency, join(cli, 'node_modules/unhoisted'), process.platform === 'win32' ? 'junction' : 'dir') - const project = prepareDevelopmentProject({ projectDir: join(root, 'runtime'), cliDir: cli, hostDir: host, dependencyDir: hoisted, release: release() }) + const project = prepareDevelopmentProject({ projectDir: join(root, 'runtime'), cliDir: cli, hostDir: host, dependencyDir: hoisted, release: release(), target: 'mac-arm64' }) expect(realpathSync(join(project, 'node_modules/unhoisted'))).toBe(realpathSync(dependency)) - const descriptor = JSON.parse(readFileSync(join(project, 'desktop-runtime.json'), 'utf8')) as { sharedPackages: unknown[] } + const descriptor = JSON.parse(readFileSync(join(project, 'desktop-runtime.json'), 'utf8')) as { platform: string; arch: string; sharedPackages: unknown[] } + expect(descriptor).toMatchObject({ platform: 'darwin', arch: 'arm64' }) expect(descriptor.sharedPackages).toContainEqual({ name: 'unhoisted', version: '1.2.3', path: 'node_modules/unhoisted' }) }) @@ -76,6 +77,7 @@ describe('desktop development project', () => { hostDir: host, dependencyDir: dependencies, release: release(), + target: 'win-x64', }) expect(realpathSync(join(project, 'node_modules', '@deepseek-ai', 'dsh'))).toBe(realpathSync(cli)) expect(realpathSync(join(project, 'node_modules', '@deepseek-ai', 'dsh-desktop-host'))).toBe(realpathSync(host)) @@ -88,6 +90,8 @@ describe('desktop development project', () => { } expect(manifest.dependencies['@deepseek-ai/dsh']).toBe('1.2.3') expect(manifest.dependencies['@deepseek-ai/dsh-desktop-host']).toBe('1.2.3') + const descriptor = JSON.parse(readFileSync(join(project, 'desktop-runtime.json'), 'utf8')) as { platform: string; arch: string } + expect(descriptor).toMatchObject({ platform: 'win32', arch: 'x64' }) const manager = new DesktopProjectManager(resolveDesktopPaths(join(root, 'home')), { dsh: project, }) @@ -115,6 +119,7 @@ describe('desktop development project', () => { hostDir: host, dependencyDir: dependencies, release: release(), + target: 'mac-x64', })).toThrow(/must be @deepseek-ai\/dsh@1\.2\.3/u) }) }) diff --git a/apps/desktop/tests/welcome-flow.e2e.ts b/apps/desktop/tests/welcome-flow.e2e.ts index a9bb43c8f1..2f6174a3ef 100644 --- a/apps/desktop/tests/welcome-flow.e2e.ts +++ b/apps/desktop/tests/welcome-flow.e2e.ts @@ -98,6 +98,9 @@ describe.skipIf(!existsSync(builtHost))('built Desktop welcome flow', () => { nodeVersion: process.versions.node, hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION, }, + // This suite assembles a synthetic project on hosts that prepare no Desktop target, and + // nothing it exercises compares the descriptor's platform/arch outside packaging. + target: 'mac-x64', }) cpSync(join(repository, 'packages/skill/skill-office/assets'), join(root, 'runtime/office-skills'), { recursive: true }) const paths = resolveDesktopPaths(home) From 7fe9c06fa64125c97026c0b958b19675312358de Mon Sep 17 00:00:00 2001 From: Kaige-Gao Date: Wed, 23 Sep 2026 16:16:30 +0800 Subject: [PATCH 07/36] feat(chat): preview image links on hover and expand inline images --- .../clickable-links-gallery/ui.expected.md | 3 +- .../expected/markdown-images/ui.expected.md | 27 ++++-- .../file-reference-prompt.expected.md | 2 +- apps/web/tests/markdown-images.e2e.ts | 40 +++++++-- .../client/ui-attachment/README.i18n.yaml | 4 +- packages/client/ui-attachment/README.md | 2 +- packages/client/ui-attachment/README.zh.md | 2 +- .../client/ui-attachment/src/MessageImage.tsx | 4 +- .../src/client/ComposerAttachments.tsx | 2 +- .../client/ui-attachment/src/client/labels.ts | 2 +- .../tests/image-lightbox.client.spec.tsx | 2 +- packages/client/ui-chat/README.i18n.yaml | 4 +- packages/client/ui-chat/README.md | 2 + packages/client/ui-chat/README.zh.md | 2 + .../src/client/chat/AssistantMarkdown.tsx | 12 +-- .../ui-chat/src/client/chat/ChatView.tsx | 10 ++- packages/client/ui-chat/src/client/locale.ts | 10 +++ ...stant-markdown-path-images.client.spec.tsx | 11 ++- .../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/src/index.ts | 4 +- .../ui-deliverables/tests/prompt.host.spec.ts | 2 +- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 3 + packages/client/ui-primitives/README.zh.md | 3 + .../ui-primitives/src/HoverCard.module.css | 11 +++ .../client/ui-primitives/src/HoverCard.tsx | 31 +++++-- .../src/ImageLightbox.module.css | 5 +- .../src/ImageLightbox.tsx | 5 +- .../ui-primitives/src/ImagePreview.module.css | 24 +++++ .../client/ui-primitives/src/ImagePreview.tsx | 39 +++++++++ packages/client/ui-primitives/src/index.ts | 3 + .../src/markdown/MarkdownDelegate.tsx | 9 +- .../src/markdown/MarkdownText.module.css | 23 +++++ .../src/markdown/local-image-syntax.ts | 28 ++++++ .../ui-primitives/src/markdown/parse.ts | 9 +- .../ui-primitives/src/markdown/render.tsx | 72 ++++++++++----- .../tests/image-preview.client.spec.tsx | 19 ++++ .../markdown-image-entries.client.spec.tsx | 87 +++++++++++++++++++ .../src/client/markdown/path-images.ts | 4 +- packages/util/workspace-path/README.i18n.yaml | 4 +- packages/util/workspace-path/README.md | 2 + packages/util/workspace-path/README.zh.md | 2 + packages/util/workspace-path/src/index.ts | 12 +++ .../tests/file-media-url.spec.ts | 20 +++++ .../system-prompt.expected.md | 2 +- .../web/ptc-round/system-prompt.expected.md | 2 +- 48 files changed, 492 insertions(+), 86 deletions(-) rename packages/client/{ui-attachment => ui-primitives}/src/ImageLightbox.module.css (86%) rename packages/client/{ui-attachment => ui-primitives}/src/ImageLightbox.tsx (90%) create mode 100644 packages/client/ui-primitives/src/ImagePreview.module.css create mode 100644 packages/client/ui-primitives/src/ImagePreview.tsx create mode 100644 packages/client/ui-primitives/src/markdown/local-image-syntax.ts create mode 100644 packages/client/ui-primitives/tests/image-preview.client.spec.tsx create mode 100644 packages/client/ui-primitives/tests/markdown-image-entries.client.spec.tsx create mode 100644 packages/util/workspace-path/tests/file-media-url.spec.ts diff --git a/apps/web/tests/expected/clickable-links-gallery/ui.expected.md b/apps/web/tests/expected/clickable-links-gallery/ui.expected.md index ab3230e5bd..120dd9b7ee 100644 --- a/apps/web/tests/expected/clickable-links-gallery/ui.expected.md +++ b/apps/web/tests/expected/clickable-links-gallery/ui.expected.md @@ -137,7 +137,8 @@ - code: notes.md - text: untouched. - paragraph: - - img "Token preview" + - 'button "View full image: Token preview"': + - img "Token preview" - text: css - button "Wrap lines" [pressed] - button "Copy" diff --git a/apps/web/tests/expected/markdown-images/ui.expected.md b/apps/web/tests/expected/markdown-images/ui.expected.md index 199f2afb89..31dee5c21a 100644 --- a/apps/web/tests/expected/markdown-images/ui.expected.md +++ b/apps/web/tests/expected/markdown-images/ui.expected.md @@ -11,15 +11,28 @@ - button "Took {{duration}}" [disabled] - heading "Markdown images" [level=2] - paragraph: - - img "Remote test image" -- paragraph: Local test image + - 'button "View full image: Remote test image"': + - img "Remote test image" - paragraph: - - img "Workspace test image" -- paragraph: Oversized image + - 'button "View full image: Local test image"': + - img "Local test image" - paragraph: - - img "Outside workspace image" -- paragraph: Missing image -- paragraph: {{cwd}}/corrupt.png + - 'button "View full image: Workspace test image"': + - img "Workspace test image" +- paragraph: + - button "View comparison" +- paragraph: + - 'button "View full image: Space path"': + - img "Space path" +- paragraph: + - 'button "View full image: Encoded path"': + - img "Encoded path" +- paragraph: Image preview unavailable · Oversized image +- paragraph: + - 'button "View full image: Outside workspace image"': + - img "Outside workspace image" +- paragraph: Image preview unavailable · Missing image +- paragraph: Image preview unavailable · {{cwd}}/corrupt.png - paragraph: REMOTE_IMAGE_DONE - button "Copy" - button "Good response" diff --git a/apps/web/tests/expected/web-runtime-context/file-reference-prompt.expected.md b/apps/web/tests/expected/web-runtime-context/file-reference-prompt.expected.md index f4bdf601f8..7a9e1faff5 100644 --- a/apps/web/tests/expected/web-runtime-context/file-reference-prompt.expected.md +++ b/apps/web/tests/expected/web-runtime-context/file-reference-prompt.expected.md @@ -1 +1 @@ -Prefer showing the primary results within your final response alongside a brief explanation. Markdown file links such as [Report](path/to/report.html) open the file in the sidebar preview. For images, you can add an inline preview such as ![Preview](/absolute/path/image.png); include a file link as well so the image remains accessible in clients that cannot display it inline. Do not call present just to list edited source files, or run commands to check whether a diff view will appear. Use present when a separate file card helps the user open the complete deliverable, especially Office documents, spreadsheets, and slide decks. Each presented file adds a card below the reply, with preview and native-open actions. Usually select the 1-2 most important deliverables; include more when needed, but at most 4 files in a single present call. Avoid repeating results already shown inline unless the separate card adds useful access. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix. +Prefer showing the primary results within your final response alongside a brief explanation. Use ![Description]() when an image supports an explanation or comparison; it appears inline and opens a larger preview on click. Use [Description]() when referring to an image or listing files; image links offer a hover preview and open in the sidebar on click. Enclose Markdown file destinations in angle brackets, especially paths containing spaces. Do not call present just to list edited source files, or run commands to check whether a diff view will appear. Use present when a separate file card helps the user open the complete deliverable, including images, Office documents, spreadsheets, and slide decks. Each presented file adds a card below the reply, with preview and native-open actions. Usually select the 1-2 most important deliverables; include more when needed, but at most 4 files in a single present call. Avoid repeating results already shown inline unless the separate card adds useful access. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix. diff --git a/apps/web/tests/markdown-images.e2e.ts b/apps/web/tests/markdown-images.e2e.ts index b8f18c6c67..ed93628d8b 100644 --- a/apps/web/tests/markdown-images.e2e.ts +++ b/apps/web/tests/markdown-images.e2e.ts @@ -1,5 +1,5 @@ // Real browser image loading and failure fallbacks through the shipped Web composition. -import { open, writeFile } from 'node:fs/promises' +import { mkdir, open, writeFile } from 'node:fs/promises' import { createServer, type Server } from 'node:http' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -112,6 +112,12 @@ function markdownImageFixture(remoteUrl: string, outsidePath: string): string { '', `![${WORKSPACE_ALT}]({{cwd}}/valid.png)`, '', + '[View comparison](./valid.png)', + '', + '![Space path]({{cwd}}/test workspace/测试图.png)', + '', + '![Encoded path]({{cwd}}/test%20workspace/测试图.png)', + '', '![Oversized image]({{cwd}}/oversized.png)', '', `![Outside workspace image](${outsidePath})`, @@ -164,6 +170,9 @@ describe('web e2e: Markdown image rendering', () => { imageOrigin = await startImageOrigin() scaffold = await launchWebScaffold({}) await writeFile(join(scaffold.workspaceCwd, 'valid.png'), PNG) + await writeFile(join(scaffold.workspaceCwd, 'local-image.png'), PNG) + await mkdir(join(scaffold.workspaceCwd, 'test workspace')) + await writeFile(join(scaffold.workspaceCwd, 'test workspace/测试图.png'), PNG) await writeFile(join(scaffold.workspaceCwd, 'corrupt.png'), 'invalid image') const oversized = await open(join(scaffold.workspaceCwd, 'oversized.png'), 'w') try { @@ -237,11 +246,13 @@ describe('web e2e: Markdown image rendering', () => { borderRadius: '8px', decoding: 'async', loading: 'lazy', - maxWidth: '100%', + maxWidth: 'min(100%, 640px)', referrerPolicy: 'no-referrer', }) - expect(await page.getByRole('img', { name: LOCAL_ALT }).count()).toBe(0) - expect(await page.getByText(LOCAL_ALT, { exact: true }).count()).toBe(1) + await expect.poll(() => page.getByRole('img', { name: LOCAL_ALT }).evaluate(element => (element as HTMLImageElement).naturalWidth)).toBe(1) + for (const name of ['Space path', 'Encoded path']) { + await expect.poll(() => page.getByRole('img', { name, exact: true }).evaluate(element => (element as HTMLImageElement).naturalWidth)).toBe(1) + } expect(imageOrigin.requests).toEqual([{ path: '/image.png', referer: undefined }]) const workspaceImage = page.getByRole('img', { name: WORKSPACE_ALT }) @@ -253,23 +264,40 @@ describe('web e2e: Markdown image rendering', () => { const outsideImage = page.getByRole('img', { name: 'Outside workspace image' }) await expect.poll(() => outsideImage.evaluate(element => (element as HTMLImageElement).naturalWidth)).toBe(1) for (const alt of ['Oversized image', 'Missing image']) { - await page.getByText(alt, { exact: true }).waitFor() + await page.getByText(`Image preview unavailable · ${alt}`, { exact: true }).waitFor() expect(await page.getByRole('img', { name: alt }).count()).toBe(0) } - await page.getByText(join(scaffold.workspaceCwd, 'corrupt.png'), { exact: true }).waitFor() + await page.getByText(`Image preview unavailable · ${join(scaffold.workspaceCwd, 'corrupt.png')}`, { exact: true }).waitFor() expect(mediaResponses).toEqual(new Map([ [join(scaffold.workspaceCwd, 'valid.png'), 200], + [`${scaffold.workspaceCwd}/./local-image.png`, 200], + [join(scaffold.workspaceCwd, 'test workspace/测试图.png'), 200], [join(scaffold.workspaceCwd, 'oversized.png'), 413], [join(scaffold.persistenceRoot, 'outside.png'), 200], [join(scaffold.workspaceCwd, 'missing.png'), 404], [join(scaffold.workspaceCwd, 'corrupt.png'), 200], ])) + await page.getByRole('button', { name: `View full image: ${WORKSPACE_ALT}`, exact: true }).click() + const lightbox = page.getByRole('dialog', { name: 'Image preview', exact: true }) + await lightbox.waitFor() + await lightbox.getByRole('button', { name: 'Close image preview' }).click() + await lightbox.waitFor({ state: 'detached' }) + const link = page.getByRole('button', { name: 'View comparison', exact: true }) + await link.hover() + const hoverImage = page.getByRole('img', { name: './valid.png', exact: true }) + await hoverImage.waitFor() + await expect.poll(() => hoverImage.evaluate(element => (element as HTMLImageElement).naturalWidth)).toBe(1) + await link.press('Escape') + await hoverImage.waitFor({ state: 'detached' }) + const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) .split(SEED_ID).join('{{seededId}}') await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md']) + await link.click() + await expect.poll(() => page.locator('[data-image-preview] img').evaluate(element => (element as HTMLImageElement).naturalWidth)).toBe(1) }, 60_000) }) diff --git a/packages/client/ui-attachment/README.i18n.yaml b/packages/client/ui-attachment/README.i18n.yaml index 0fad5492e0..a3dcf88ee8 100644 --- a/packages/client/ui-attachment/README.i18n.yaml +++ b/packages/client/ui-attachment/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-attachment/README.md -README.md: 5a3e697189936a79df19ff9b976f06e0dd6191fb -README.zh.md: 4168650770f42e7b94146041f37ffc440a5ec52e +README.md: af2ed79cc2ede546db06187df4750b9d0fa698dd +README.zh.md: 64d12869c5038ceb19675d554f9055411cfac433 diff --git a/packages/client/ui-attachment/README.md b/packages/client/ui-attachment/README.md index 5a3e697189..af2ed79cc2 100644 --- a/packages/client/ui-attachment/README.md +++ b/packages/client/ui-attachment/README.md @@ -58,7 +58,7 @@ The plugin waits for `conversation.input.attachments`, `conversation.message.ima | [`src/AttachmentRail.tsx`](src/AttachmentRail.tsx) | Horizontal attachment overflow, wheel translation, edge arrows | | [`src/client/MessageImages.tsx`](src/client/MessageImages.tsx) | Per-message gallery + lightbox assembly | | [`src/MessageImage.tsx`](src/MessageImage.tsx) | Single image sizing, load/retry, click-to-open; local submission-echo previews render their object URL directly | -| [`src/ImageLightbox.tsx`](src/ImageLightbox.tsx) | Document-level modal preview over the shared mask | +| [`ImageLightbox`](../ui-primitives/src/ImageLightbox.tsx) | Document-level modal preview over the shared mask | | [`src/DropOverlay.tsx`](src/DropOverlay.tsx) | Pointer-inert drag invitation portal | diff --git a/packages/client/ui-attachment/README.zh.md b/packages/client/ui-attachment/README.zh.md index 4168650770..64d12869c5 100644 --- a/packages/client/ui-attachment/README.zh.md +++ b/packages/client/ui-attachment/README.zh.md @@ -58,7 +58,7 @@ Trajectory 附件行使用 48px 方形缩略图,完整缩放图片而不裁剪 | [`src/AttachmentRail.tsx`](src/AttachmentRail.tsx) | 附件横向溢出、滚轮转换、边缘箭头 | | [`src/client/MessageImages.tsx`](src/client/MessageImages.tsx) | 每消息画廊+灯箱的组装 | | [`src/MessageImage.tsx`](src/MessageImage.tsx) | 单图尺寸、加载/重试、点击打开;本地提交回显预览直接显示其 object URL | -| [`src/ImageLightbox.tsx`](src/ImageLightbox.tsx) | 铺在共享遮罩上的文档级模态预览 | +| [`ImageLightbox`](../ui-primitives/src/ImageLightbox.tsx) | 铺在共享遮罩上的文档级模态预览 | | [`src/DropOverlay.tsx`](src/DropOverlay.tsx) | 不接收指针事件的拖放提示 portal | diff --git a/packages/client/ui-attachment/src/MessageImage.tsx b/packages/client/ui-attachment/src/MessageImage.tsx index caf8fe08f8..7f350bbc94 100644 --- a/packages/client/ui-attachment/src/MessageImage.tsx +++ b/packages/client/ui-attachment/src/MessageImage.tsx @@ -1,8 +1,8 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import { IconLoadingOutlineRegular, IconRefreshOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' -import { ImageLightbox } from './ImageLightbox.tsx' -import type { ImageLightboxLabels } from './ImageLightbox.tsx' +import { ImageLightbox } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ImageLightboxLabels } from '@deepseek-ai/dsh-client-ui-primitives' import css from './MessageImage.module.css' /** Loads a session-authorized durable image URL and may expose a cached URL synchronously. */ diff --git a/packages/client/ui-attachment/src/client/ComposerAttachments.tsx b/packages/client/ui-attachment/src/client/ComposerAttachments.tsx index 7c9054890d..0065410b9d 100644 --- a/packages/client/ui-attachment/src/client/ComposerAttachments.tsx +++ b/packages/client/ui-attachment/src/client/ComposerAttachments.tsx @@ -7,7 +7,7 @@ import { AttachmentRail } from '../AttachmentRail.tsx' import type { AttachmentRailItem } from '../AttachmentRail.tsx' import { DropOverlay } from '../DropOverlay.tsx' import { FileCard } from '../FileCard.tsx' -import { ImageLightbox } from '../ImageLightbox.tsx' +import { ImageLightbox } from '@deepseek-ai/dsh-client-ui-primitives' import { attachmentRailLabels, dropOverlayLabels, fileCardLabels, lightboxLabels } from './labels.ts' import { installDocumentDropEvents } from './drop-events.ts' import css from './ComposerAttachments.module.css' diff --git a/packages/client/ui-attachment/src/client/labels.ts b/packages/client/ui-attachment/src/client/labels.ts index 990f8959c7..257a362d97 100644 --- a/packages/client/ui-attachment/src/client/labels.ts +++ b/packages/client/ui-attachment/src/client/labels.ts @@ -2,7 +2,7 @@ import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' import type { AttachmentRailLabels } from '../AttachmentRail.tsx' import type { DropOverlayLabels } from '../DropOverlay.tsx' import type { FileCardLabels } from '../FileCard.tsx' -import type { ImageLightboxLabels } from '../ImageLightbox.tsx' +import type { ImageLightboxLabels } from '@deepseek-ai/dsh-client-ui-primitives' import type { MessageImageLabels } from '../MessageImage.tsx' /** diff --git a/packages/client/ui-attachment/tests/image-lightbox.client.spec.tsx b/packages/client/ui-attachment/tests/image-lightbox.client.spec.tsx index 5959fad3d4..3efde85334 100644 --- a/packages/client/ui-attachment/tests/image-lightbox.client.spec.tsx +++ b/packages/client/ui-attachment/tests/image-lightbox.client.spec.tsx @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render } from '@testing-library/react' -import { ImageLightbox } from '../src/ImageLightbox.tsx' +import { ImageLightbox } from '@deepseek-ai/dsh-client-ui-primitives' afterEach(cleanup) diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index f30604f200..ab7779ba68 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/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-chat/README.md -README.md: 1aa7d71b3f932a7a14ef6d23e9ace7eda444fc3b -README.zh.md: 2239234448e1e00fd395634de39a96c2a80a35f7 +README.md: 655b01885a27fd6d79ec9c9f81291bc75581c9d5 +README.zh.md: 748a304ae0b888c0b5b0a3b64254f557ad55568d diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 1aa7d71b3f..655b01885a 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -33,6 +33,8 @@ File-mention providers receive the viewed Session ID with the closing-turn owner Chat supplies file and HTTP(S) navigation through one `MarkdownDelegateProvider` around its node list. Assistant Markdown file links open in the right Sidebar after the message settles, including references to unmodified files. Relative paths resolve in the viewed Session's workspace; absolute paths retain the same Session's filesystem access. `#L24` and `#L24-L30` navigate to the first specified line and reuse an existing file tab. Missing files show the preview's error state. +Standalone Markdown images show contained previews and open the shared image lightbox; local paths resolve against the viewed workspace after settlement. Image file links keep their sidebar activation and show a thumbnail after hover dwell or keyboard focus. Escape dismisses the thumbnail. Failed images retain a localized status and their description; no duplicate-image filtering is applied. + Settings → General → Open chat links in selects the destination for ordinary clicks on Chat HTTP(S) links: In-App Sidebar (default) opens a new right-Sidebar Browser tab, while Default Browser opens an external tab. The setting is shown only while the Sidebar Browser is available. If the Sidebar Browser is not registered, both choices use the external browser; modified clicks retain native behavior. The `ui-chat.linkOpening` preference persists on loopback browsers and stays process-local when settings cannot persist writes. Sent file references and skills confirmed by the message’s logged invocation also open in the right Sidebar. File paths use the viewed Session; skill names resolve through its current input-trigger source. Both use the prose file-link dotted underline on hover or focus. Sessions, directories, and command labels remain non-navigating references. diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 2239234448..748a304ae0 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -33,6 +33,8 @@ kind: "package-reference" Chat 在节点列表外通过一个 `MarkdownDelegateProvider` 提供文件及 HTTP(S) 导航。Assistant Markdown 文件链接在消息落定后可于右侧栏打开,包括未修改文件的引用。相对路径基于当前查看的 Session 工作区解析;绝对路径仍使用同一 Session 的文件系统访问。`#L24` 和 `#L24-L30` 定位到指定起始行,并复用现有文件标签。文件缺失时显示预览错误状态。 +独立的 Markdown 图片以内嵌预览显示,点击打开共享图片浮层;本地路径在消息落定后基于当前查看的工作区解析。图片文件链接保持点击打开侧栏,鼠标停留或键盘聚焦时显示缩略图,Esc 关闭缩略图。图片加载失败时保留本地化状态与图片说明;不执行重复图片过滤。 + 设置 → 通用设置 → 网页链接默认打开方式控制普通点击 Chat HTTP(S) 链接时的目标:「应用内侧边栏」(默认)打开新的右侧 Sidebar Browser tab,「默认浏览器」打开外部标签页。该设置项仅在 Sidebar Browser 可用时显示。若 Sidebar Browser 未注册,两种选择均使用外部浏览器;带修饰键的点击保留原生行为。`ui-chat.linkOpening` 偏好在回环地址浏览器中持久化,设置无法持久化写入时仅在当前进程内生效。已发送的文件引用及消息日志确认调用的 skill 也可在右侧栏打开预览。文件路径使用当前查看的 Session;skill 名称由该 Session 当前的输入触发源解析。两者悬停或聚焦时均使用正文文件链接的虚线下划线。会话、目录和命令标签仍只作为引用展示。 diff --git a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx index 1dd235d3c1..41d8c5b8c1 100644 --- a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx @@ -1,4 +1,5 @@ import { Fragment, memo, useMemo } from 'react' +import { fileMediaUrl } from '@deepseek-ai/dsh-util-workspace-path' import type { ReactNode } from 'react' import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' import type { MarkdownFileMentions, MarkdownPathImages } from '@deepseek-ai/dsh-client-ui-primitives' @@ -10,16 +11,17 @@ import { useSearchableHidden } from './searchable-hidden.ts' import css from './AssistantMarkdown.module.css' /** - * Resolve an authored POSIX image path against the document's file API. + * Resolve an authored absolute image path against the document's file API. * @param base - canonical `document.baseURI` at render time. - * @param value - authored markdown destination. + * @param value - authored Markdown destination; URL escapes are decoded once. * @returns an absolute HTTP(S) file-API URL, or undefined for unsupported * protocols and non-local paths. */ export function localPathMediaUrl(base: string, value: string): string | undefined { - if (!value.startsWith('/') || value.startsWith('//')) return undefined - if (!base.startsWith('http:') && !base.startsWith('https:')) return undefined - return new URL(`api/file?path=${encodeURIComponent(value)}`, base).href + let path: string + try { path = decodeURIComponent(value.split(/[?#]/u)[0] ?? '') } + catch { return undefined } // Malformed URL escapes cannot identify a file. + return fileMediaUrl(base, path) } export interface AssistantMarkdownProps { diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index a7c9ef0bae..0514068aed 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -20,6 +20,7 @@ import { assertNever } from '@deepseek-ai/dsh-util-values' import { TurnNavigator } from './TurnNavigator.tsx' import { mergeTurnRailItems } from './turn-rail-items.ts' import { useChatScroll } from './use-chat-scroll.ts' +import { fileMediaUrl, resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path' import css from './ChatView.module.css' /** Host/OS refusal text for the file-open dialog; empty throws keep a locale fallback. */ @@ -120,6 +121,13 @@ export function ChatView({ const inbox = useProjection('inbox') as unknown as InboxState | undefined // Workspace root off the session list row: path summaries display relative to it. const cwd = useSessions(s => s.byId[sessionId]?.cwd) + const fileImages = useMemo(() => ({ + resolve: (path: string) => fileMediaUrl(document.baseURI, resolveWorkspacePath(cwd, path)), + labels: { + open: t('image.open'), loading: t('image.loading'), failed: t('image.failed'), + dialog: t('image.dialog'), close: t('image.close'), + }, + }), [cwd, t]) const running = useSession(s => s.running) const openState = useSession(s => s.openState) const openError = useSession(s => s.openError) @@ -237,7 +245,7 @@ export function ChatView({ )} - + { expect(localPathMediaUrl(BASE, '')).toBeUndefined() expect(localPathMediaUrl(BASE, '//cdn.example.com/x.png')).toBeUndefined() expect(localPathMediaUrl(BASE, 'relative.png')).toBeUndefined() - expect(localPathMediaUrl(BASE, 'C:\\tmp\\x.png')).toBeUndefined() + expect(new URL(localPathMediaUrl(BASE, 'C:\\tmp\\x.png')!).searchParams.get('path')).toBe('C:\\tmp\\x.png') }) it('encodes the full path including spaces', () => { @@ -84,3 +84,12 @@ describe('AssistantMarkdown local-path images', () => { expect(container.textContent).toContain('diagram') }) }) + +it('decodes Markdown URL escapes once before encoding the file query', () => { + for (const [authored, path] of [ + ['/work/test%20workspace/图.png', '/work/test workspace/图.png'], + ['/work/100%25%23.png', '/work/100%#.png'], + ['/work/literal%2520.png', '/work/literal%20.png'], + ]) expect(new URL(localPathMediaUrl(BASE, authored!)!).searchParams.get('path')).toBe(path) + expect(localPathMediaUrl(BASE, '/work/bad%escape.png')).toBeUndefined() +}) diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index ec9b6a2d71..ff3260836e 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: c52db38df0c800f52de4eebb3a9692eabef6c9df -README.zh.md: 785c2215746e36ead16fe08b1a21b04a58ef418c +README.md: 6ec8f1282c8173b83bcb24f40741cd5eab02fb79 +README.zh.md: b3140ff4c28702fd63f71c259954831acf1eb32f diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index c52db38df0..6ec8f1282c 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -89,7 +89,7 @@ Read these pages when the deliverables surface is not enough. They move from the #### What the model sees -The guidance prefers primary results in the final reply: file links open Sidebar previews, and image previews include a file link for clients without inline-image support. It recommends separate `present` cards for complete file deliverables, especially Office files, usually selecting the most important one or two, with at most four files per call when more are needed. It discourages cards solely for code edits and extra commands to inspect diff visibility. This guidance applies even with developer tools disabled: it accepts fewer file surfaces to avoid duplicate presentation and unnecessary tool calls. It asks the model to link every existing-file mention outside commands, configuration expressions, and code blocks, including repeats and tables. Labels default to filenames or clear aliases, with only enough parent directories to distinguish files. Precise references display `filename:24` or `filename:24–30`; their destinations retain full relative or absolute paths with `#L24` or `#L24-L30` anchors. The display suffix contains neither `#` nor `L`. +The guidance prefers primary results in the final reply: inline images support explanations and comparisons, image links offer hover previews and open in the Sidebar, and image file cards serve separate deliverables. File destinations use angle brackets, including paths with spaces. It recommends separate `present` cards for complete file deliverables, especially Office files, usually selecting the most important one or two, with at most four files per call when more are needed. It discourages cards solely for code edits and extra commands to inspect diff visibility. This guidance applies even with developer tools disabled: it accepts fewer file surfaces to avoid duplicate presentation and unnecessary tool calls. It asks the model to link every existing-file mention outside commands, configuration expressions, and code blocks, including repeats and tables. Labels default to filenames or clear aliases, with only enough parent directories to distinguish files. Precise references display `filename:24` or `filename:24–30`; their destinations retain full relative or absolute paths with `#L24` or `#L24-L30` anchors. The display suffix contains neither `#` nor `L`. #### Token effect diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 785c221574..b3140ff4c2 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -89,7 +89,7 @@ Node 半部注册[模型体验](#model-experience)所述的静态 `ui:deliverabl #### 模型看到的内容 -提示词建议在最终回复中展示主要结果:文件链接打开 Sidebar 预览,图片预览同时保留文件链接,供不支持行内图片的客户端使用。对于需要完整文件的交付,尤其是 Office 文件,建议使用独立的 `present` 卡片,通常选最重要的一两项,需要时可以更多,但每次调用最多四项。提示词建议避免仅为列出代码改动生成卡片,也不为确认 diff 是否显示额外运行命令。开发者工具关闭时仍采用这条建议,接受较少的文件展示入口,以减少重复展示和不必要的工具调用。文件引用要求链接命令、配置表达式和代码块以外的每次现有文件提及,包括重复提及和表格。标签默认使用文件名或清楚的别名,仅添加足以区分文件的父目录。精确引用显示为 `filename:24` 或 `filename:24–30`;目标保留完整相对路径或绝对路径,以及 `#L24` 或 `#L24-L30` 锚点。显示后缀不含 `#` 或 `L`。 +提示词建议在最终回复中展示主要结果:内嵌图片用于讲解与对比,图片链接提供悬停预览并在点击时打开 Sidebar,图片文件卡片用于独立交付。文件目标路径使用尖括号包裹,包括含空格的路径。对于需要完整文件的交付,尤其是 Office 文件,建议使用独立的 `present` 卡片,通常选最重要的一两项,需要时可以更多,但每次调用最多四项。提示词建议避免仅为列出代码改动生成卡片,也不为确认 diff 是否显示额外运行命令。开发者工具关闭时仍采用这条建议,接受较少的文件展示入口,以减少重复展示和不必要的工具调用。文件引用要求链接命令、配置表达式和代码块以外的每次现有文件提及,包括重复提及和表格。标签默认使用文件名或清楚的别名,仅添加足以区分文件的父目录。精确引用显示为 `filename:24` 或 `filename:24–30`;目标保留完整相对路径或绝对路径,以及 `#L24` 或 `#L24-L30` 锚点。显示后缀不含 `#` 或 `L`。 #### Token 影响 diff --git a/packages/client/ui-deliverables/src/index.ts b/packages/client/ui-deliverables/src/index.ts index 33ede599ca..7762a67d16 100644 --- a/packages/client/ui-deliverables/src/index.ts +++ b/packages/client/ui-deliverables/src/index.ts @@ -15,9 +15,9 @@ export const inject = ['systemPrompt', 'connection', 'sessionQuery', 'sessionCon /** Static Web guidance for primary outputs and existing-file references. */ const FILE_REFERENCE_PROMPT = 'Prefer showing the primary results within your final response alongside a brief explanation. ' - + 'Markdown file links such as [Report](path/to/report.html) open the file in the sidebar preview. For images, you can add an inline preview such as ![Preview](/absolute/path/image.png); include a file link as well so the image remains accessible in clients that cannot display it inline. ' + + 'Use ![Description]() when an image supports an explanation or comparison; it appears inline and opens a larger preview on click. Use [Description]() when referring to an image or listing files; image links offer a hover preview and open in the sidebar on click. Enclose Markdown file destinations in angle brackets, especially paths containing spaces. ' + 'Do not call present just to list edited source files, or run commands to check whether a diff view will appear. ' - + 'Use present when a separate file card helps the user open the complete deliverable, especially Office documents, spreadsheets, and slide decks. Each presented file adds a card below the reply, with preview and native-open actions. ' + + 'Use present when a separate file card helps the user open the complete deliverable, including images, Office documents, spreadsheets, and slide decks. Each presented file adds a card below the reply, with preview and native-open actions. ' + 'Usually select the 1-2 most important deliverables; include more when needed, but at most 4 files in a single present call. Avoid repeating results already shown inline unless the separate card adds useful access. ' + 'Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. ' + 'Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix.' diff --git a/packages/client/ui-deliverables/tests/prompt.host.spec.ts b/packages/client/ui-deliverables/tests/prompt.host.spec.ts index 9758dba3c5..249792ec88 100644 --- a/packages/client/ui-deliverables/tests/prompt.host.spec.ts +++ b/packages/client/ui-deliverables/tests/prompt.host.spec.ts @@ -29,7 +29,7 @@ describe('ui-deliverables node plugin', () => { const section = (await ctx.systemPrompt.assemble()).sections .find(entry => entry.name === 'ui:deliverable-file-references') - expect(section?.text).toMatchInlineSnapshot('"Prefer showing the primary results within your final response alongside a brief explanation. Markdown file links such as [Report](path/to/report.html) open the file in the sidebar preview. For images, you can add an inline preview such as ![Preview](/absolute/path/image.png); include a file link as well so the image remains accessible in clients that cannot display it inline. Do not call present just to list edited source files, or run commands to check whether a diff view will appear. Use present when a separate file card helps the user open the complete deliverable, especially Office documents, spreadsheets, and slide decks. Each presented file adds a card below the reply, with preview and native-open actions. Usually select the 1-2 most important deliverables; include more when needed, but at most 4 files in a single present call. Avoid repeating results already shown inline unless the separate card adds useful access. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix."') + expect(section?.text).toMatchInlineSnapshot('"Prefer showing the primary results within your final response alongside a brief explanation. Use ![Description]() when an image supports an explanation or comparison; it appears inline and opens a larger preview on click. Use [Description]() when referring to an image or listing files; image links offer a hover preview and open in the sidebar on click. Enclose Markdown file destinations in angle brackets, especially paths containing spaces. Do not call present just to list edited source files, or run commands to check whether a diff view will appear. Use present when a separate file card helps the user open the complete deliverable, including images, Office documents, spreadsheets, and slide decks. Each presented file adds a card below the reply, with preview and native-open actions. Usually select the 1-2 most important deliverables; include more when needed, but at most 4 files in a single present call. Avoid repeating results already shown inline unless the separate card adds useful access. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix."') // Only live request pins track current guidance; seeded sessions retain their recorded prompt. for (const scenario of ['fresh-round-trip', 'ptc-round']) { diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 66c0f146c7..b7c783080c 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/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-primitives/README.md -README.md: 5b8a16739504d8da169e04f64b0feb684f47f8c3 -README.zh.md: 291beb0cef60584ec86d349e8be8a11a1ae1694b +README.md: 349921e171336dc16f9ae38ac815a9e042bc447c +README.zh.md: eec967d30243566ce2f78e1b325c1af96d663519 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 5b8a167395..349921e171 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -54,6 +54,7 @@ Check this table before writing a control in a feature package. A plugin cannot | `OnboardingSurface` | First-run stage that holds the application root inert. | | `Tooltip` | Hover text anchored to a cloned child; optional `portal` rendering escapes clipping containers and ancestor stacking contexts that cap the bubble's z-index. | | `HoverCard` | Hover preview the pointer can rest on and select from; optional copy button. | +| `ImageLightbox`, `ImagePreview` | Shared image modal and passive contained preview with loading/failure states. | | `Toast` | Transient top-center banner held for the owner's `holdMs`. | | `SettingsForm`, `SettingsValueField`, `SettingsSecretField` | The frame and the controls of a plugin's settings page: the frame takes its copy as `labels`, saves only on its button, and discards on unmount; a value field shows staged text with the overridden badge and reset; a secret field starts blank and reports only whether a value is configured. | | `SettingsFormModel`, `settingsNumberField`, `settingsTextField` | The staged-edit model behind such a page over a settings scope: drafts are staged and written on save, a field is overridden by its presence in the user layer, and a save that did not land keeps its drafts. | @@ -95,6 +96,8 @@ The nearest `MarkdownDelegateProvider` supplies optional `openExternalLink` and `JsonTree` clamps collapsed strings to `collapsedStringLines` (three by default). Expanded strings show raw text, retain sibling commas, and fit within the window and outer scrolling containers. Resize and ancestor-scroll events update that limit. Row copy feedback updates independently of JSON value rendering; pending clipboard writes cannot update a different row or an unmounted tree. +`ImageLightbox` is the shared original-image modal with focus restoration and Escape dismissal; `ImagePreview` is a passive, contained thumbnail with owner-supplied loading/failure labels. `HoverCard.inline` keeps a file link in the text flow and uses the shared menu material, keyboard focus, and viewport-clamped placement. `MarkdownDelegateProvider.fileImages` supplies a decoded-path resolver and complete image labels: settled image links gain hover previews, and standalone images gain lightbox activation. Image-only anchors retain their single navigation target. The parser recovers only complete, unescaped, standalone local image references with bare spaces and an unambiguous supported image suffix; code and ambiguous destinations remain literal. + ### Localizing copy The atoms cannot read the application locale, so every piece of user-facing copy arrives through required label props. `HoverCard`, `TerminalBlock`, `JsonTree`, `CodeBlock`, `MarkdownText`, `JsonBlock`, `ConnectionIndicator`, `Modal`, `DiffBlock`, `ReadBlock`, `SearchBlock`, and `WebBlock` accept complete localized labels. The package owns no language fallback; omission fails typechecking, and each feature maps its typed `t` seat into the primitive's label interface. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 291beb0cef..eec967d302 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -54,6 +54,7 @@ kind: "package-library" | `OnboardingSurface` | 首次运行的引导舞台,期间保持应用根节点 inert。 | | `Tooltip` | 锚定在克隆子元素上的悬停文本;可通过 `portal` 渲染到外层,避免被容器裁剪,或受祖先层叠上下文限制其 z-index。 | | `HoverCard` | 指针可停留、可选中的悬停预览;可选带复制按钮。 | +| `ImageLightbox`, `ImagePreview` | 共享图片浮层,以及带加载和失败状态的等比预览。 | | `Toast` | 顶部居中的瞬时横幅,保持时长由所有者的 `holdMs` 决定。 | | `SettingsForm`、`SettingsValueField`、`SettingsSecretField` | 插件设置页的框架与控件:框架以 `labels` 接收文案,只在按钮点击时保存,卸载即丢弃;值字段显示暂存文本以及已覆盖标签和重置;密文字段每次为空,只报告是否已配置。 | | `SettingsFormModel`、`settingsNumberField`、`settingsTextField` | 这类页面背后基于设置 scope 的暂存编辑模型:草稿先暂存、保存时写入,字段是否被覆盖看用户层是否含有它,未落地的保存保留草稿。 | @@ -95,6 +96,8 @@ kind: "package-library" `JsonTree` 把折叠字符串限制为 `collapsedStringLines` 行(默认三行)。展开后显示原始文本、保留同级逗号,并限制在窗口与外层滚动容器内;尺寸变化和祖先滚动事件会更新此限制。行复制反馈独立于 JSON 值渲染更新;尚未完成的剪贴板写入不会更新另一行或已卸载的树。 +`ImageLightbox` 是共享原图浮层,支持焦点恢复与 Esc 关闭;`ImagePreview` 是被动的等比缩略图,由调用方提供加载及失败文案。`HoverCard.inline` 使文件链接保持在文本流内,并使用共享菜单材质、键盘聚焦和视口内定位。`MarkdownDelegateProvider.fileImages` 提供已解码路径解析器与完整图片文案:消息落定后的图片链接支持悬停预览,独立图片支持点击放大。仅包含图片的链接保留单一导航目标。解析器仅恢复完整、未转义、独立成段且带明确图片扩展名的含裸空格本地图片引用;代码与有歧义的目标保持原文。 + ### 本地化文案 这些原子组件无法读取应用 locale,因此每段面向用户的文案都必须通过 label prop 提供。`HoverCard`、`TerminalBlock`、`JsonTree`、`CodeBlock`、`MarkdownText`、`JsonBlock`、`ConnectionIndicator`、`Modal`、`DiffBlock`、`ReadBlock`、`SearchBlock` 与 `WebBlock` 接收完整的本地化 label。本包不拥有语言回退;遗漏会导致类型检查失败,各功能会把带类型的 `t` 席位映射到 primitive 的 label 接口。 diff --git a/packages/client/ui-primitives/src/HoverCard.module.css b/packages/client/ui-primitives/src/HoverCard.module.css index e23a27e11e..db4805c894 100644 --- a/packages/client/ui-primitives/src/HoverCard.module.css +++ b/packages/client/ui-primitives/src/HoverCard.module.css @@ -79,3 +79,14 @@ @media (prefers-reduced-motion: reduce) { .preview { transition: none; } } + +.inline { display: inline; } +.media { + width: min(300px, calc(100vw - 16px)); + padding: 8px; + border: 0; + color: var(--dsw-alias-label-primary); + background: var(--dsw-specific-menu); + backdrop-filter: var(--dsw-menu-backdrop-filter); + box-shadow: var(--dsw-elevation-panel); +} diff --git a/packages/client/ui-primitives/src/HoverCard.tsx b/packages/client/ui-primitives/src/HoverCard.tsx index b0c8ae86f8..dfb752ee35 100644 --- a/packages/client/ui-primitives/src/HoverCard.tsx +++ b/packages/client/ui-primitives/src/HoverCard.tsx @@ -32,9 +32,11 @@ const VIEWPORT_MARGIN = 8 */ export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false, - copyText, copyLabel, copiedLabel, variant = 'compact', widthAnchorRef, + copyText, copyLabel, copiedLabel, variant = 'compact', widthAnchorRef, inline = false, }: { anchor: ReactNode + /** Inline media preview using the shared menu material and keyboard focus. */ + inline?: boolean content: ReactNode openDelayMs?: number disabled?: boolean @@ -148,11 +150,19 @@ export function HoverCard({ }) return } + if (inline) { + const w = cardRef.current?.offsetWidth ?? 300 + setPos({ + left: Math.max(VIEWPORT_MARGIN, Math.min(r.left, window.innerWidth - w - VIEWPORT_MARGIN)), + top: Math.max(overlayTopMargin(VIEWPORT_MARGIN), Math.min(r.bottom + ANCHOR_GAP, window.innerHeight - h - VIEWPORT_MARGIN)), + }) + return + } const top = r.top + h > window.innerHeight - VIEWPORT_MARGIN ? window.innerHeight - h - VIEWPORT_MARGIN : r.top setPos({ left: r.right + ANCHOR_GAP, top }) } place() - const observer = variant === 'preview' && typeof ResizeObserver !== 'undefined' ? new ResizeObserver(place) : null + const observer = (variant === 'preview' || inline) && typeof ResizeObserver !== 'undefined' ? new ResizeObserver(place) : null for (const element of [cardRef.current, rootRef.current, widthAnchorRef?.current]) { if (element !== null && element !== undefined) observer?.observe(element) } @@ -163,7 +173,7 @@ export function HoverCard({ window.removeEventListener('scroll', place, true) window.removeEventListener('resize', place) } - }, [open, variant, widthAnchorRef, positioned]) + }, [open, variant, widthAnchorRef, positioned, inline]) // The first placement ran before the card mounted (height read 0): once the // card's real height is measurable, correct the bottom-edge clamp. The @@ -202,7 +212,7 @@ export function HoverCard({ const card = open && pos !== null && (
{ - if (disabled) return + className={clsx(css.root, inline && css.inline)} + onFocus={inline ? () => { if (!disabled) { cancelClose(); setPhase('open') } } : undefined} + onBlur={inline ? (event) => { + if (!event.currentTarget.contains(event.relatedTarget)) { clearTimer(); cancelClose(); close() } + } : undefined} + onKeyDown={inline ? (event) => { + if (event.key === 'Escape') { event.stopPropagation(); clearTimer(); cancelClose(); close() } + } : undefined} + onPointerEnter={(event) => { + if (disabled || (inline && event.pointerType === 'touch')) return // Coming back inside during the grace (the gap, or the card itself) // keeps the current card rather than restarting the dwell. cancelClose() diff --git a/packages/client/ui-attachment/src/ImageLightbox.module.css b/packages/client/ui-primitives/src/ImageLightbox.module.css similarity index 86% rename from packages/client/ui-attachment/src/ImageLightbox.module.css rename to packages/client/ui-primitives/src/ImageLightbox.module.css index dffba50e82..520b050d02 100644 --- a/packages/client/ui-attachment/src/ImageLightbox.module.css +++ b/packages/client/ui-primitives/src/ImageLightbox.module.css @@ -24,7 +24,8 @@ object-fit: contain; border-radius: 12px; background: var(--dsw-specific-input-major); - box-shadow: var(--dsw-shadow-lv3); + border: 0; + box-shadow: var(--dsw-elevation-prominent); } .close { @@ -43,3 +44,5 @@ color: var(--dsw-alias-label-primary); cursor: pointer; } + +.close:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 3px; } diff --git a/packages/client/ui-attachment/src/ImageLightbox.tsx b/packages/client/ui-primitives/src/ImageLightbox.tsx similarity index 90% rename from packages/client/ui-attachment/src/ImageLightbox.tsx rename to packages/client/ui-primitives/src/ImageLightbox.tsx index c2aff2eeb3..76c8aa2875 100644 --- a/packages/client/ui-attachment/src/ImageLightbox.tsx +++ b/packages/client/ui-primitives/src/ImageLightbox.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef } from 'react' import { createPortal } from 'react-dom' -import { IconCloseOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconCloseOutlineRegular } from './icons/index.tsx' import css from './ImageLightbox.module.css' /** Lightbox strings the owner resolves from its own locale namespace. */ @@ -37,7 +37,8 @@ export function ImageLightbox({ src, alt, labels, onClose }: { restoreRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null closeRef.current?.focus() const onKeyDown = (event: globalThis.KeyboardEvent): void => { - if (event.key === 'Escape') onClose() + if (event.key === 'Escape') { event.stopPropagation(); onClose() } + if (event.key === 'Tab') { event.preventDefault(); closeRef.current?.focus() } } window.addEventListener('keydown', onKeyDown) return () => { diff --git a/packages/client/ui-primitives/src/ImagePreview.module.css b/packages/client/ui-primitives/src/ImagePreview.module.css new file mode 100644 index 0000000000..037782c02d --- /dev/null +++ b/packages/client/ui-primitives/src/ImagePreview.module.css @@ -0,0 +1,24 @@ +.frame { + position: relative; + display: grid; + place-items: center; + width: 100%; + height: 180px; + overflow: hidden; + border-radius: 8px; + background: var(--dsw-alias-bg-base); +} +.image { width: 100%; height: 100%; min-height: 0; object-fit: contain; opacity: 0; } +.image[data-ready] { opacity: 1; } +.status { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 12px; + color: var(--dsw-alias-label-tertiary); + font-size: 12px; + line-height: 18px; +} diff --git a/packages/client/ui-primitives/src/ImagePreview.tsx b/packages/client/ui-primitives/src/ImagePreview.tsx new file mode 100644 index 0000000000..ffe6fec1b9 --- /dev/null +++ b/packages/client/ui-primitives/src/ImagePreview.tsx @@ -0,0 +1,39 @@ +/** Passive, contained image preview for Markdown image links. */ +import { useState } from 'react' +import clsx from 'clsx' +import { IconLoadingOutlineRegular } from './icons/index.tsx' +import css from './ImagePreview.module.css' + +/** + * Render an image without introducing a second activation target. + * @param props - Source, accessible description, localized status, and owner layout class. + * @returns A contained preview with loading or failure status. + */ +export function ImagePreview({ src, alt, loadingLabel, failedLabel, className }: { + src: string + alt: string + loadingLabel: string + failedLabel: string + className?: string | undefined +}) { + return +} + +function Preview({ src, alt, loadingLabel, failedLabel, className }: { + src: string + alt: string + loadingLabel: string + failedLabel: string + className: string | undefined +}) { + const [state, setState] = useState<'loading' | 'ready' | 'failed'>('loading') + return + {state !== 'failed' && {alt} { setState('ready') }} onError={() => { setState('failed') }} />} + {state !== 'ready' && + {state === 'loading' && } + {state === 'loading' ? loadingLabel : failedLabel} + } + +} diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index b8ec370298..def473220c 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -102,3 +102,6 @@ export * from './icons/index.tsx' export { PluginArtworkTerminal, PluginArtworkLoop, PluginArtworkSubagent, PluginArtworkSearch, PluginArtworkDefault, } from './plugin-artwork.tsx' +export { ImageLightbox } from './ImageLightbox.tsx' +export type { ImageLightboxLabels } from './ImageLightbox.tsx' +export { ImagePreview } from './ImagePreview.tsx' diff --git a/packages/client/ui-primitives/src/markdown/MarkdownDelegate.tsx b/packages/client/ui-primitives/src/markdown/MarkdownDelegate.tsx index 0dfcb56cf0..681e8f5608 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownDelegate.tsx +++ b/packages/client/ui-primitives/src/markdown/MarkdownDelegate.tsx @@ -1,6 +1,7 @@ /** Consumer-owned navigation for Markdown links. */ import { createContext, useContext, useMemo } from 'react' import type { ReactNode } from 'react' +import type { ImageLightboxLabels } from '../ImageLightbox.tsx' /** * Handle one sanitized absolute HTTP(S) URL selected from Markdown. @@ -10,6 +11,11 @@ export type MarkdownExternalLinkHandler = (href: string) => void /** Navigation capabilities supplied by the nearest Markdown owner. */ export interface MarkdownDelegate { + /** Image previews for decoded local paths in this owner's workspace. */ + readonly fileImages?: { + resolve: (path: string) => string | undefined + labels: ImageLightboxLabels & { open: string; loading: string; failed: string } + } | undefined /** Ordinary HTTP(S) activation; absent handlers retain native anchor behavior. */ readonly openExternalLink?: MarkdownExternalLinkHandler | undefined /** @@ -37,8 +43,9 @@ export function MarkdownDelegateProvider({ children, openExternalLink, openFile, + fileImages, }: MarkdownDelegateProviderProps): ReactNode { - const delegate = useMemo(() => ({ openExternalLink, openFile }), [openExternalLink, openFile]) + const delegate = useMemo(() => ({ openExternalLink, openFile, fileImages }), [openExternalLink, openFile, fileImages]) return ( {children} diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css index e3c5b31953..3f495bc594 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css @@ -442,3 +442,26 @@ .compact :global(.katex-display) { margin: 4px 0; } + +.imageButton { + display: inline-block; + max-width: 100%; + padding: 0; + border: 0; + border-radius: 8px; + background: transparent; + vertical-align: middle; + cursor: zoom-in; +} +.imageButton .image { max-width: min(100%, 640px); max-height: 360px; } +.imageButton:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 3px; } +.previewName { + display: block; + margin-top: 8px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--dsw-alias-label-secondary); + font-size: 12px; + line-height: 18px; +} diff --git a/packages/client/ui-primitives/src/markdown/local-image-syntax.ts b/packages/client/ui-primitives/src/markdown/local-image-syntax.ts new file mode 100644 index 0000000000..a22c1b9d81 --- /dev/null +++ b/packages/client/ui-primitives/src/markdown/local-image-syntax.ts @@ -0,0 +1,28 @@ +/** Recover standalone local image references with bare spaces without changing code or source offsets. */ +import type { Root, RootContent } from 'mdast' + +/** + * Recover only unambiguous, unescaped image-only paragraphs containing a local path with spaces. + * @param root - Parsed Markdown tree, modified in place. + * @param source - Original source used to distinguish authored syntax from escaped examples. + * @returns The same root with recovered image nodes. + */ +export function recoverLocalImages(root: Root, source: string): Root { + const visit = (node: Root | RootContent): void => { + if (node.type === 'paragraph' && node.children.length === 1) { + const child = node.children[0] + if (child?.type === 'text' && child.position !== undefined + && source.slice(child.position.start.offset, child.position.end.offset) === child.value) { + const pattern = /^!\[([^\]\n]*)\]\(((?:\/(?!\/)|\.{1,2}\/|[a-z]:[\\/])[^\n<>()[\]"']+\.(?:png|jpe?g|gif|webp|svg|bmp|ico))\)$/iu + const match = pattern.exec(child.value) + if (match !== null && match[2]?.includes(' ')) { + node.children = [{ type: 'image', alt: match[1] ?? '', url: match[2], position: child.position }] + } + } + } else if ('children' in node) { + for (const child of node.children) visit(child) + } + } + visit(root) + return root +} diff --git a/packages/client/ui-primitives/src/markdown/parse.ts b/packages/client/ui-primitives/src/markdown/parse.ts index 50f8f179b9..46559c3f47 100644 --- a/packages/client/ui-primitives/src/markdown/parse.ts +++ b/packages/client/ui-primitives/src/markdown/parse.ts @@ -9,6 +9,7 @@ */ import type { Root } from 'mdast' +import { recoverLocalImages } from './local-image-syntax.ts' import { fromMarkdown } from 'mdast-util-from-markdown' import { gfmFromMarkdown } from 'mdast-util-gfm' import { mathFromMarkdown } from 'mdast-util-math' @@ -24,10 +25,10 @@ import { mathCompatibility } from './mathCompatibility.ts' * @returns The mdast root. */ export function parseGfm(text: string): Root { - return fromMarkdown(text, { + return recoverLocalImages(fromMarkdown(text, { extensions: [gfm(), cjkFriendlyStrong()], mdastExtensions: [gfmFromMarkdown()], - }) + }), text) } /** @@ -37,8 +38,8 @@ export function parseGfm(text: string): Root { * @returns The mdast root. */ export function parseGfmWithMath(text: string): Root { - return fromMarkdown(text, { + return recoverLocalImages(fromMarkdown(text, { extensions: [gfm(), cjkFriendlyStrong(), mathCompatibility(), math()], mdastExtensions: [gfmFromMarkdown(), mathFromMarkdown()], - }) + }), text) } diff --git a/packages/client/ui-primitives/src/markdown/render.tsx b/packages/client/ui-primitives/src/markdown/render.tsx index 84a9c2857a..10b3e59fb1 100644 --- a/packages/client/ui-primitives/src/markdown/render.tsx +++ b/packages/client/ui-primitives/src/markdown/render.tsx @@ -16,7 +16,7 @@ * may add node types this renderer has no mapping for. */ -import { Fragment, createElement, useState } from 'react' +import { Fragment, createElement, useCallback, useState } from 'react' import type { Key, ReactNode } from 'react' import clsx from 'clsx' import type * as Md from 'mdast' @@ -28,6 +28,9 @@ import { parseFileLink } from './file-link.ts' import { renderTexToReact } from './katex.tsx' import { LinkIconMedium, classifyLinkPath } from '../LinkIcon.tsx' import { useMarkdownDelegate } from './MarkdownDelegate.tsx' +import { HoverCard } from '../HoverCard.tsx' +import { ImageLightbox } from '../ImageLightbox.tsx' +import { ImagePreview } from '../ImagePreview.tsx' import type { PositionedBlock } from './incremental.ts' import css from './MarkdownText.module.css' @@ -579,19 +582,27 @@ function MarkdownFileLink({ file, glyph, children }: { readonly glyph: boolean readonly children: ReactNode[] }): ReactNode { - const { openFile } = useMarkdownDelegate() + const { openFile, fileImages } = useMarkdownDelegate() if (openFile === undefined) return <>{children} - return ( + const preview = glyph && classifyLinkPath(file.path) === 'image' ? fileImages : undefined + const src = preview?.resolve(file.path) + const anchor = ( ) + if (src === undefined || preview === undefined) return anchor + return + + {file.path.split(/[\\/]/u).pop()} + } /> } /** @@ -610,28 +621,43 @@ function inlineCodeHttpUrl(value: string): string | undefined { } function renderImage(url: string, alt: string, key: Key, context: MarkdownRenderContext): ReactNode { - const imageSrc = imageSource(url, context.pathImages) - if (imageSrc === undefined) { - return {alt} - } - return + return } -/** Failed loads retain the authored alt or destination; a new source remounts the image. */ -function MarkdownImage({ src, alt, destination }: { src: string; alt: string; destination: string }): ReactNode { +function MarkdownImage({ destination, alt, pathImages, streaming, inLink }: { + destination: string + alt: string + pathImages: MarkdownPathImages | undefined + streaming: boolean + inLink: boolean +}): ReactNode { + const { fileImages } = useMarkdownDelegate() + const file = streaming ? undefined : parseFileLink(destination) + const src = (file === undefined ? undefined : fileImages?.resolve(file.path)) ?? imageSource(destination, pathImages) + if (src === undefined) return {alt} + return +} + +function LoadedMarkdownImage({ src, alt, destination, preview }: { + src: string + alt: string + destination: string + preview: ReturnType['fileImages'] +}): ReactNode { const [failed, setFailed] = useState(false) - if (failed) return {alt || destination} - return ( - {alt} { setFailed(true) }} - loading="lazy" - decoding="async" - referrerPolicy="no-referrer" - /> - ) + const [open, setOpen] = useState(false) + const close = useCallback(() => { setOpen(false) }, []) + if (failed) return {preview === undefined ? '' : `${preview.labels.failed} · `}{alt || destination} + const img = {alt} { setFailed(true) }} loading="lazy" decoding="async" referrerPolicy="no-referrer" /> + if (preview === undefined) return img + return <> + + {open && } + } /** The bracketed source text a reference reverts to when its definition is missing. */ diff --git a/packages/client/ui-primitives/tests/image-preview.client.spec.tsx b/packages/client/ui-primitives/tests/image-preview.client.spec.tsx new file mode 100644 index 0000000000..97389ad9ab --- /dev/null +++ b/packages/client/ui-primitives/tests/image-preview.client.spec.tsx @@ -0,0 +1,19 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, expect, it } from 'vitest' +import { ImagePreview } from '../src/ImagePreview.tsx' + +afterEach(cleanup) +it('keeps status inside the preview and resets failures when the source changes', () => { + const props = { src: '/one.png', alt: 'Comparison', loadingLabel: 'Loading image', failedLabel: 'Image unavailable' } + const { rerender } = render() + expect(screen.getByRole('status').textContent).toBe('Loading image') + fireEvent.error(screen.getByRole('img')) + expect(screen.getByRole('status').textContent).toBe('Image unavailable') + expect(screen.queryByRole('img')).toBeNull() + rerender() + expect(screen.getByRole('status').textContent).toBe('Loading image') + fireEvent.load(screen.getByRole('img')) + expect(screen.queryByRole('status')).toBeNull() + expect(screen.queryByRole('button')).toBeNull() +}) diff --git a/packages/client/ui-primitives/tests/markdown-image-entries.client.spec.tsx b/packages/client/ui-primitives/tests/markdown-image-entries.client.spec.tsx new file mode 100644 index 0000000000..ba8dd1a4f2 --- /dev/null +++ b/packages/client/ui-primitives/tests/markdown-image-entries.client.spec.tsx @@ -0,0 +1,87 @@ +// @vitest-environment jsdom +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { MarkdownDelegateProvider } from '../src/markdown/MarkdownDelegate.tsx' +import { MarkdownText } from './markdown-test-components.tsx' +import { parseGfm, parseGfmWithMath } from '../src/markdown/parse.ts' + +afterEach(() => { cleanup(); vi.useRealTimers() }) +const labels = { open: 'View full image', dialog: 'Image preview', close: 'Close preview', loading: 'Loading image', failed: 'Image unavailable' } +const fileImages = { resolve: (path: string) => `https://example.com/api/file?path=${encodeURIComponent('/workspace/' + path)}`, labels } +function mount(text: string, streaming = false) { + const openFile = vi.fn() + const view = render( + + ) + return { ...view, openFile } +} + +it('opens inline images in the shared lightbox, restores focus, and never opens the sidebar', () => { + const { openFile } = mount('![Compare](<.artifacts/对比 image.png>)') + const trigger = screen.getByRole('button', { name: 'View full image: Compare' }) + trigger.focus() + expect(new URL(screen.getByRole('img').getAttribute('src')!).searchParams.get('path')).toBe('/workspace/.artifacts/对比 image.png') + fireEvent.click(trigger) + expect(screen.getByRole('dialog', { name: 'Image preview' })).toBeTruthy() + fireEvent.keyDown(window, { key: 'Tab' }) + expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Close preview' })) + fireEvent.click(screen.getByRole('button', { name: 'Close preview' })) + expect(screen.queryByRole('dialog')).toBeNull() + expect(document.activeElement).toBe(trigger) + expect(openFile).not.toHaveBeenCalled() +}) + +it('loads a link preview only after hover dwell and keeps its existing sidebar activation', () => { + vi.useFakeTimers() + const { openFile } = mount('[View comparison](.artifacts/对比%20image.png)') + const link = screen.getByRole('button', { name: 'View comparison' }) + expect(screen.queryByRole('img')).toBeNull() + fireEvent.pointerEnter(link.parentElement!) + act(() => { vi.advanceTimersByTime(499) }) + expect(screen.queryByRole('img')).toBeNull() + act(() => { vi.advanceTimersByTime(1) }) + const image = screen.getByRole('img') + expect(new URL(image.getAttribute('src')!).searchParams.get('path')).toBe('/workspace/.artifacts/对比 image.png') + fireEvent.load(image) + expect(screen.queryByText('Loading image')).toBeNull() + fireEvent.pointerDown(link) + fireEvent.click(link) + expect(screen.queryByRole('img')).toBeNull() + expect(openFile).toHaveBeenCalledWith('.artifacts/对比 image.png', undefined) +}) + +it('supports keyboard previews, Escape dismissal, and an inert failure state', () => { + mount('[View comparison](graph.png)') + const link = screen.getByRole('button', { name: 'View comparison' }) + fireEvent.focus(link) + fireEvent.error(screen.getByRole('img')) + expect(screen.getByText('Image unavailable')).toBeTruthy() + fireEvent.keyDown(link, { key: 'Escape' }) + expect(screen.queryByText('Image unavailable')).toBeNull() +}) + +it('leaves non-image links and streaming paths without a preview', () => { + mount('[Report](report.pdf)') + fireEvent.focus(screen.getByRole('button', { name: 'Report' })) + expect(screen.queryByRole('img')).toBeNull() + cleanup() + mount('![Compare](image.png)', true) + expect(screen.queryByRole('img')).toBeNull() +}) + +it('keeps image-only links as one activation target', () => { + const { container, openFile } = mount('[![Compare](image.png)](image.png)') + expect(container.querySelectorAll('button')).toHaveLength(1) + fireEvent.click(screen.getByRole('button')) + expect(openFile).toHaveBeenCalledWith('image.png', undefined) + expect(screen.queryByRole('dialog')).toBeNull() +}) + +it.each([parseGfm, parseGfmWithMath])('recovers standalone bare-space paths without promoting code or escaped syntax', (parse) => { + const source = '![图](/work/test workspace/测试文件/图.png)' + expect(parse(source).children[0]).toMatchObject({ children: [{ type: 'image', url: '/work/test workspace/测试文件/图.png' }] }) + for (const example of ['`' + source + '`', '```md\n' + source + '\n```', '\\' + source, 'Example: ' + source, + '![图](https://example.com/test image.png)', '![图](/work/test (image).png)']) { + expect(JSON.stringify(parse(example))).not.toContain('"type":"image"') + } +}) diff --git a/packages/client/ui-sidebar-documentpreview/src/client/markdown/path-images.ts b/packages/client/ui-sidebar-documentpreview/src/client/markdown/path-images.ts index e70f3cfaf8..7030cc9c26 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/markdown/path-images.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/markdown/path-images.ts @@ -1,5 +1,5 @@ /** Local Markdown image destinations served by the authenticated file route. */ -import { isAbsoluteWorkspacePath, pathPartsOf } from '@deepseek-ai/dsh-util-workspace-path' +import { fileMediaUrl, isAbsoluteWorkspacePath, pathPartsOf } from '@deepseek-ai/dsh-util-workspace-path' /** * Build a file URL, resolving relative destinations beside the previewed file. @@ -25,5 +25,5 @@ export function markdownImageUrl(base: string, documentPath: string | undefined, if (documentPath === undefined) return undefined path = pathPartsOf(documentPath).directory + path } - return new URL(`api/file?path=${encodeURIComponent(path)}`, base).href + return fileMediaUrl(base, path) } diff --git a/packages/util/workspace-path/README.i18n.yaml b/packages/util/workspace-path/README.i18n.yaml index 0f1d1603b1..1ebe67bac4 100644 --- a/packages/util/workspace-path/README.i18n.yaml +++ b/packages/util/workspace-path/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/workspace-path/README.md -README.md: ab7787f3649c8f75a0f0e0745bc78ea32edb5925 -README.zh.md: 270c79f7bb57def13a7656e8f76f900c1e094f4f +README.md: 2de3157ee8bbe5ea216be303c1a1520d4b92126c +README.zh.md: b46402b0ecf4025224768a29af1ea984ff7d497f diff --git a/packages/util/workspace-path/README.md b/packages/util/workspace-path/README.md index ab7787f364..2de3157ee8 100644 --- a/packages/util/workspace-path/README.md +++ b/packages/util/workspace-path/README.md @@ -26,6 +26,8 @@ A resource address is `dsh-resource:///…`, and the type — the URI host `sessionFileAddress(sessionId, path)` normalizes `\` to `/` and drops leading `./`, but preserves leading `/` characters. Every id and path segment is component-encoded with `:` kept literal. `fileAddressFor(sessionId, cwd, path)` always builds a Session address: paths inside `cwd` become relative; other absolute paths, including when `cwd` is unknown, stay absolute within that Session address. `absoluteFileAddress(absolutePath)` builds only the Session-less form. `parseFileAddress(address)` checks the exact file-address prefix, ignores query and fragment suffixes, decodes each segment, and returns `{ scope, sessionId, path }` for a Session address or `{ scope, path }` for the Session-less form. Another type or scheme, an unknown scope, a missing id or path, or a malformed escape returns `undefined`. +`fileMediaUrl(base, path)` builds an authenticated file-route URL from an already-decoded absolute path. It preserves literal percent signs and deployment prefixes, and rejects non-HTTP application bases, relative paths, network-path prefixes, and control characters. Markdown owners decode authored URLs before calling it; native file-card paths are passed unchanged. + ----- ## Known Limitations and Deferred Work diff --git a/packages/util/workspace-path/README.zh.md b/packages/util/workspace-path/README.zh.md index 270c79f7bb..b46402b0ec 100644 --- a/packages/util/workspace-path/README.zh.md +++ b/packages/util/workspace-path/README.zh.md @@ -26,6 +26,8 @@ kind: "package-library" `sessionFileAddress(sessionId, path)` 将 `\` 归一为 `/`,去掉前导 `./`,但保留前导 `/` 字符。id 和每个路径段都做组件编码,`:` 保持字面。`fileAddressFor(sessionId, cwd, path)` 始终构造 Session 地址:`cwd` 内的路径转为相对路径;其他绝对路径(包括 `cwd` 未知时)仍作为该 Session 地址内的绝对路径。`absoluteFileAddress(absolutePath)` 只构造不带 Session 的形式。`parseFileAddress(address)` 检查精确的文件地址前缀、忽略查询与片段后缀、逐段解码,并为 Session 地址返回 `{ scope, sessionId, path }`,为不带 Session 的形式返回 `{ scope, path }`。其他 type 或 scheme、未知作用域、缺 id 或路径,或错误转义都返回 `undefined`。 +`fileMediaUrl(base, path)` 从已解码的绝对路径生成带认证文件路由的 URL,保留字面量百分号与部署前缀,并拒绝非 HTTP 应用基址、相对路径、网络路径前缀和控制字符。Markdown 调用方先解码所写 URL;原生文件卡片路径原样传入。 + ----- ## 已知限制与暂缓事项 diff --git a/packages/util/workspace-path/src/index.ts b/packages/util/workspace-path/src/index.ts index 00a1db7732..84dfd8885b 100644 --- a/packages/util/workspace-path/src/index.ts +++ b/packages/util/workspace-path/src/index.ts @@ -110,3 +110,15 @@ export function relativizeToCwd(text: string, cwd: string | undefined): string { if (text.startsWith(`${root}/`) || text.startsWith(`${root}\\`)) return text.slice(root.length + 1) return text } + +/** + * Address a decoded absolute file path through the authenticated file route. + * @param base - HTTP(S) application base, including its deployment prefix. + * @param path - Native file path; URL escapes in authored Markdown must already be decoded. + * @returns File URL, or undefined for unsupported transports and non-absolute paths. + */ +export function fileMediaUrl(base: string, path: string): string | undefined { + if (!/^https?:/u.test(base) || !isAbsoluteWorkspacePath(path) + || /^[/\\]{2}/u.test(path) || /[\u0000-\u001f\u007f]/u.test(path)) return undefined + return new URL(`api/file?path=${encodeURIComponent(path)}`, base).href +} diff --git a/packages/util/workspace-path/tests/file-media-url.spec.ts b/packages/util/workspace-path/tests/file-media-url.spec.ts new file mode 100644 index 0000000000..0053373989 --- /dev/null +++ b/packages/util/workspace-path/tests/file-media-url.spec.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { fileMediaUrl } from '../src/index.ts' + +describe('fileMediaUrl', () => { + it.each(['/work/测试 文件#100%.png', 'C:\\work\\测试 文件.png'])( + 'preserves the decoded native path %s under a mounted application', (path) => { + const url = new URL(fileMediaUrl('https://host/tools/dsh/', path)!) + expect(url.pathname).toBe('/tools/dsh/api/file') + expect(url.searchParams.get('path')).toBe(path) + }, + ) + it.each(['image.png', '//host/image.png', '\\\\host\\image.png', '/work/\u0000.png', '/work/\n.png'])( + 'rejects a non-local or invalid path %s', (path) => { + expect(fileMediaUrl('https://host/', path)).toBeUndefined() + }, + ) + it('requires an HTTP application base', () => { + expect(fileMediaUrl('file:///app/', '/work/image.png')).toBeUndefined() + }) +}) diff --git a/snapshots/web/fresh-round-trip/system-prompt.expected.md b/snapshots/web/fresh-round-trip/system-prompt.expected.md index e05a41da44..55df661994 100644 --- a/snapshots/web/fresh-round-trip/system-prompt.expected.md +++ b/snapshots/web/fresh-round-trip/system-prompt.expected.md @@ -30,7 +30,7 @@ Use subagent in the background by default. Start independent delegations togethe Use subagent_fork in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. -Prefer showing the primary results within your final response alongside a brief explanation. Markdown file links such as [Report](path/to/report.html) open the file in the sidebar preview. For images, you can add an inline preview such as ![Preview](/absolute/path/image.png); include a file link as well so the image remains accessible in clients that cannot display it inline. Do not call present just to list edited source files, or run commands to check whether a diff view will appear. Use present when a separate file card helps the user open the complete deliverable, especially Office documents, spreadsheets, and slide decks. Each presented file adds a card below the reply, with preview and native-open actions. Usually select the 1-2 most important deliverables; include more when needed, but at most 4 files in a single present call. Avoid repeating results already shown inline unless the separate card adds useful access. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix. +Prefer showing the primary results within your final response alongside a brief explanation. Use ![Description]() when an image supports an explanation or comparison; it appears inline and opens a larger preview on click. Use [Description]() when referring to an image or listing files; image links offer a hover preview and open in the sidebar on click. Enclose Markdown file destinations in angle brackets, especially paths containing spaces. Do not call present just to list edited source files, or run commands to check whether a diff view will appear. Use present when a separate file card helps the user open the complete deliverable, including images, Office documents, spreadsheets, and slide decks. Each presented file adds a card below the reply, with preview and native-open actions. Usually select the 1-2 most important deliverables; include more when needed, but at most 4 files in a single present call. Avoid repeating results already shown inline unless the separate card adds useful access. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix. The DeepSeek Harness implementation checkout is at {{sourceRoot}}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself. diff --git a/snapshots/web/ptc-round/system-prompt.expected.md b/snapshots/web/ptc-round/system-prompt.expected.md index 7b0afba97a..fb616a1ef9 100644 --- a/snapshots/web/ptc-round/system-prompt.expected.md +++ b/snapshots/web/ptc-round/system-prompt.expected.md @@ -546,7 +546,7 @@ declare const tools: { } ``` -Prefer showing the primary results within your final response alongside a brief explanation. Markdown file links such as [Report](path/to/report.html) open the file in the sidebar preview. For images, you can add an inline preview such as ![Preview](/absolute/path/image.png); include a file link as well so the image remains accessible in clients that cannot display it inline. Do not call present just to list edited source files, or run commands to check whether a diff view will appear. Use present when a separate file card helps the user open the complete deliverable, especially Office documents, spreadsheets, and slide decks. Each presented file adds a card below the reply, with preview and native-open actions. Usually select the 1-2 most important deliverables; include more when needed, but at most 4 files in a single present call. Avoid repeating results already shown inline unless the separate card adds useful access. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix. +Prefer showing the primary results within your final response alongside a brief explanation. Use ![Description]() when an image supports an explanation or comparison; it appears inline and opens a larger preview on click. Use [Description]() when referring to an image or listing files; image links offer a hover preview and open in the sidebar on click. Enclose Markdown file destinations in angle brackets, especially paths containing spaces. Do not call present just to list edited source files, or run commands to check whether a diff view will appear. Use present when a separate file card helps the user open the complete deliverable, including images, Office documents, spreadsheets, and slide decks. Each presented file adds a card below the reply, with preview and native-open actions. Usually select the 1-2 most important deliverables; include more when needed, but at most 4 files in a single present call. Avoid repeating results already shown inline unless the separate card adds useful access. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix. The DeepSeek Harness implementation checkout is at {{sourceRoot}}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself. From fe4d18fda377684d02fb9fbbcfc73019fd4cde23 Mon Sep 17 00:00:00 2001 From: mektpoy Date: Wed, 23 Sep 2026 16:29:28 +0800 Subject: [PATCH 08/36] test(desktop): provide the required primary runtime directory in the welcome startup spec `welcome-startup.spec.ts` imports the unpackaged main entry, which now requires `DSH_DESKTOP_PRIMARY_RUNTIME_DIR`. Stub it beside the neighbouring `DSH_DESKTOP_*` values so the suite reaches the welcome startup it covers. --- apps/desktop/tests/welcome-startup.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/tests/welcome-startup.spec.ts b/apps/desktop/tests/welcome-startup.spec.ts index 4a823afdf9..146b2297eb 100644 --- a/apps/desktop/tests/welcome-startup.spec.ts +++ b/apps/desktop/tests/welcome-startup.spec.ts @@ -148,6 +148,7 @@ it('starts the Host for welcome onboarding and opens the workspace on skip witho vi.stubEnv('DSH_DESKTOP_NODE_BINARY', '/runtime/node') vi.stubEnv('DSH_DESKTOP_PNPM_ENTRY', '/runtime/pnpm') vi.stubEnv('DSH_DESKTOP_DSH_DIR', '/runtime/dsh') + vi.stubEnv('DSH_DESKTOP_PRIMARY_RUNTIME_DIR', '/runtime/primary-runtime') vi.stubEnv('DSH_DESKTOP_HOST_INSPECT_PORT', undefined) vi.stubEnv('DSH_DESKTOP_OPEN_DEVTOOLS', '0') vi.stubEnv('DSH_DESKTOP_MANDATORY_UPDATE_CONFIG', undefined) From 4cfd292a7ba6fcac697731da4252a5754d59c74a Mon Sep 17 00:00:00 2001 From: imccyu Date: Wed, 23 Sep 2026 17:07:09 +0800 Subject: [PATCH 09/36] fix(gateway): bound stream cancellation retention --- .../2026-09-19-remote-duplex-stream.i18n.yaml | 4 +- .../2026-09-19-remote-duplex-stream.md | 12 ++ .../2026-09-19-remote-duplex-stream.zh.md | 12 ++ packages/api/gateway/README.i18n.yaml | 4 +- packages/api/gateway/README.md | 2 + packages/api/gateway/README.zh.md | 2 + .../api/gateway/src/client/stream-client.ts | 10 +- packages/api/gateway/src/index.ts | 31 +++-- .../gateway/tests/gateway-stream.host.spec.ts | 112 ++++++++++++++++++ .../api/gateway/tests/gateway.client.spec.ts | 76 ++++++++++++ 10 files changed, 250 insertions(+), 15 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.i18n.yaml index d5903d0fc2..9a6d0df300 100644 --- a/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.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 .agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.md -2026-09-19-remote-duplex-stream.md: 0f1f6a575f6871d37822c5829fe4531261b8e95b -2026-09-19-remote-duplex-stream.zh.md: d3982aa433aa3ce84b945bb5831cef8064472853 +2026-09-19-remote-duplex-stream.md: ea8e8cebcc8285c97ebdb70f4e37805d3ce8bfe9 +2026-09-19-remote-duplex-stream.zh.md: 2e119bf054e20d076e22758178b5e0168092be18 diff --git a/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.md b/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.md index 0f1f6a575f..ea8e8cebcc 100644 --- a/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.md +++ b/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.md @@ -276,6 +276,12 @@ Parsing rules: `item` has exactly the keys `type` and `streamId` plus an optiona | `item` received after `end` | Any | The stream fails with a `gateway/protocol` error frame and aborts; the socket stays open | | `item` / `end` with an unknown `streamId` | Any | Ignored, like `cancel`: after the Host ends a stream and deletes its id, uplink frames still in flight from the Client are normal and must not take down the other streams on the same socket | +### Cancellation wait lifetime + +`cancellableStream`, `UplinkDecoder.next()`, and the Client uplink pump each create a cancellation or stop promise per read. `Promise.race` does not remove the losing input's reaction when a read completes: reusing a stream-wide pending promise retains completed races and their item results until that promise settles or becomes unreachable. Read-local promises keep retained cancellation state independent of the number of delivered items. + +The Host downlink clears its current reject callback after each read and checks cancellation before reading again. The uplink decoder retains only pending reads: abort rejects them, while normal close resolves all of them with `done`, including overlapping reads. The Client pump drops its stop promise after each read and does not start another read after stopping. Source iterators retain their existing release ownership; closing the Host uplink still precedes returning the method iterator. + ### Ordering guarantees Frames on one socket arrive in total order: a logical stream's `open`, `item`…, `end` reach the Host in send order, and uplink items enter `uplink()` in the order of `send`. There is no cross-direction ordering guarantee between uplink and downlink; a protocol that needs request-response pairing carries its own sequence numbers. @@ -538,6 +544,10 @@ for await (const reply of stream) replies.push(reply) // ['> a', '> b'] ## Alternatives considered +**A shared cancellation controller for all three reading loops.** Each loop already has an owner with distinct close behavior. Read-local promises bound retained state without moving those responsibilities into a new abstraction. + +**Requiring source iterators to cancel their own `next()`.** Gateway accepts arbitrary iterables, whose pending reads need not respond to cancellation or `return()`. Gateway must still wake its own waiters independently of source cleanup. + **A separate `mode: 'duplex'` plus a reserved parameter `uplink: AsyncIterable` alongside `signal`.** Special logic everywhere: the analyzer must recognize a second reserved parameter and its position rules, the generated signature gains a parameter, load time must validate `duplex ⟺ uplink`, and because the mux does not know the method mode, the Gateway must reject uplink frames on behalf of `'stream'` methods. Duplex is an inherent property of the transport-layer stream, not a mode of the method: the carrier already has `streamId`, `open`, and `cancel`; the uplink adds just two more frames, which any stream can receive and which are simply not sent when unused. **Declaring the uplink type in a decorator option, `@Remote({ mode: 'stream', uplink: 'JobInputFrame' })`.** A string reference to a type name is not checked by the compiler and breaks on rename. The return-type alias puts both types in one compiler-visible place. @@ -572,6 +582,8 @@ for await (const reply of stream) replies.push(reply) // ['> a', '> b'] ## Testing +Retention cases in `gateway-stream.host.spec.ts` and `gateway.client.spec.ts` count live iterator results after a full GC while the streams remain open. Lifecycle cases cover normal close waking multiple pending uplink reads, cancellation between reads, synchronous abort followed by a source exception, and termination during a Client send. + - `packages/api/gateway/tests/echo-stream.host.spec.ts`: the same echo method passes through the WebSocket carrier and through the in-process carrier. - Gateway Host: exact-key validation in frame parsing; `UplinkInbox` buffering during opening, `item` after `end`, overflow, ignored unknown `streamId`, and cleanup on socket closure; `UplinkDecoder` delivery with and without a codec and abort on decode failure; `uplink()` taken only once; the `extend({ invocation })` view and the origin of `peer`; `cancellableStream` closing the uplink before `return()`. - Gateway Client: the handle's `send` (JSON validation, queued before open, throws after termination), idempotent `end`, `dispose` driving the `cancel` frame and ending later reads; the pump's three exits; the pump stopping as soon as a terminal frame arrives. diff --git a/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.zh.md b/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.zh.md index d3982aa433..2e119bf054 100644 --- a/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.zh.md @@ -276,6 +276,12 @@ export type RemoteStreamServerMessage = | `end` 之后又收到 `item` | 任意 | 该流以 `gateway/protocol` 错误帧失败并中止;socket 不关闭 | | 未知 `streamId` 的 `item` / `end` | 任意 | 忽略,与 `cancel` 相同:Host 结束流并删除 id 后,客户端在途的上行帧属正常现象,不能连累同一 socket 上的其他流 | +### 取消等待的生命周期 + +`cancellableStream`、`UplinkDecoder.next()` 和 Client 上行泵各自为单次读取创建取消或停止 Promise。读取完成后,`Promise.race` 不会移除另一输入上的 reaction:复用流级未完成 Promise 会保留已完成的竞争及其读取结果,直到该 Promise 完成或不可达。单次读取的 Promise 使取消状态的保留量不随已交付项数增长。 + +Host 下行在每次读取后清除当前拒绝回调,并在再次读取前检查取消状态。上行解码器只保留尚未完成的读取:取消时拒绝这些读取,正常关闭则以 `done` 唤醒全部读取,包括重叠的读取。Client 泵在每次读取后移除停止 Promise,停止后不再发起读取。源迭代器的释放仍由原有所有者负责;Host 仍先关闭上行,再返回方法迭代器。 + ### 顺序保证 同一条 socket 上的帧全序到达:一条逻辑流的 `open`、`item`…、`end` 按发送顺序到达 Host;上行项进入 `uplink()` 的顺序就是 `send` 的顺序。上行与下行之间没有跨方向顺序保证;需要请求应答配对的协议自带序号。 @@ -538,6 +544,10 @@ for await (const reply of stream) replies.push(reply) // ['> a', '> b'] ## 考虑过的替代方案 +**三处读取循环共用一个取消控制器。** 每处循环已有自己的所有者和不同的关闭行为。单次读取的 Promise 能限制保留状态,不需要把这些职责迁入新抽象。 + +**要求源迭代器自行取消 `next()`。** Gateway 接受任意 iterable,其待处理读取不一定响应取消或 `return()`。Gateway 仍需独立于源清理来唤醒自己的等待者。 + **独立的 `mode: 'duplex'` 加一个与 `signal` 同级的保留参数 `uplink: AsyncIterable`。** 每一处都是特殊逻辑:analyzer 要认第二个保留参数及其位置规则,生成签名多一个参数,加载期要校验 `duplex ⟺ uplink`,mux 不知道方法模式所以网关要替 `'stream'` 方法拒绝上行帧。双工是传输层流的固有性质,不是方法的一种模式:载体本来就有 `streamId`、`open`、`cancel`,上行只是再加两种帧,任何流都能收,不用就不发。 **用装饰器选项声明上行类型,`@Remote({ mode: 'stream', uplink: 'JobInputFrame' })`。** 字符串引用类型名不受编译器约束,重命名即断。返回类型别名把两个类型写在同一个编译器可见的位置。 @@ -572,6 +582,8 @@ for await (const reply of stream) replies.push(reply) // ['> a', '> b'] ## 测试 +`gateway-stream.host.spec.ts` 和 `gateway.client.spec.ts` 中的保留对象用例在流保持打开时进行全量 GC,再统计存活的迭代结果。生命周期用例覆盖正常关闭唤醒多个待处理上行读取、两次读取之间的取消、源同步取消后抛错,以及 Client 发送期间流结束。 + - `packages/api/gateway/tests/echo-stream.host.spec.ts`:同一个 echo 方法分别穿过 WebSocket 载体与进程内载体。 - 网关 Host:帧解析的精确键校验;`UplinkInbox` 的 opening 缓冲、`end` 后 `item`、超限、未知 `streamId` 忽略、socket 关闭时的收尾;`UplinkDecoder` 有无 codec 两种交付与解码失败中止;`uplink()` 只能取一次;`extend({ invocation })` 视图与 `peer` 的来源;`cancellableStream` 先关 uplink 再 `return()`。 - 网关 Client:句柄的 `send`(JSON 校验、open 前入队、终止后抛错)、`end` 幂等、`dispose` 驱动 `cancel` 帧并结束后续读取;泵的三种退出;终止帧到达即停泵。 diff --git a/packages/api/gateway/README.i18n.yaml b/packages/api/gateway/README.i18n.yaml index 7f47a6c9eb..b7daafd1e3 100644 --- a/packages/api/gateway/README.i18n.yaml +++ b/packages/api/gateway/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/gateway/README.md -README.md: 1a746a83eb966fba6ae40b61061d42bf478e1c84 -README.zh.md: 7b37548f6ad894b761457df80278eeed7aa6ca7b +README.md: e0c986e290b1744017fca99e3a2553a68d754ef6 +README.zh.md: 8be11a7c5aea1e299c4ca45cde4b1c9dfa08ec4b diff --git a/packages/api/gateway/README.md b/packages/api/gateway/README.md index 1a746a83eb..e0c986e290 100644 --- a/packages/api/gateway/README.md +++ b/packages/api/gateway/README.md @@ -38,6 +38,8 @@ When the launcher supplies `ctx.appReady`, Gateway registers the WebSocket upgra Every stream also carries a Client-to-Host uplink on the same logical stream. A method declares the uplink item type as the second type argument of its return type, `RemoteStream` from [`dsh-typert-protocol`](../../typert/protocol/README.md); `In` defaults to `never`, and a method that returns `Iterable`, `AsyncIterable`, or `RemoteStream` declares that it reads none. The running method reads its call as `this.ctx.invocation`: the Gateway derives the receiver from a Context carrying that `RemoteInvocation` — `request`, `service`, `peer`, `signal`, and `uplink()` — so nothing enters the parameter list, and `ctx.invocation` is `undefined` on a Context no Remote call derived. `uplink()` is available once per call. With a descriptor codec it decodes every item before delivery; without one — an SRC method, or a method whose `In` is `never` — it delivers JSON-safe `unknown` values. The Client sends each item as an `item` frame on the open logical stream, a top-level `undefined` item as an `item` frame without `value`, and its half-close as an `end` frame; the method observes the half-close as the end of the `uplink()` iteration and cancellation through `signal`. A rejected item fails the whole stream with `gateway/input-invalid` (field `uplink`). Uplink items are validated one by one at the Host because they arrive from the browser; downlink items are values the Host method produced and pass through unchecked. When the method finishes its downlink the Gateway returns the uplink iterator and drops unread items; items sent to a method that never takes its uplink wait in the inbox until then. Each logical stream buffers at most `streamInboxBytes` (262144 by default) of uplink frame bytes; overflow fails the stream with `gateway/uplink-overflow`, and an item after `end` fails it with `gateway/protocol`, in both cases without closing the socket. `item`, `end`, and `cancel` frames for a stream id the Host has already finished are dropped; only a duplicate `open` closes the socket. Gateway-owned streams such as `$events` return their uplink as soon as they open, so their `item` frames are dropped instead of buffered. A unary method may also call `uplink()`; its items are readable only while the method runs. Every stream speaks for the operator Peer: a WebSocket's streams for the Peer Connection admitted at upgrade, whose scope disposal closes the socket, and an in-process carrier's for the operator directly. +Stream cancellation and stopping keep read-local state, not a history of delivered items, on the Host downlink, Host uplink, and Client uplink pump. Completing the downlink also wakes pending uplink reads. + Host composition can register one application event source through `registerRemoteEvents()`. Gateway reserves the internal `$events` logical endpoint for that source, accepts only empty `args`, and aborts streams opened by the registration when the source is withdrawn. API Remotes owns the event selection, argument validation, per-Client queues, and the Host home sent in the opening `{ type: 'ready', clientId, host: { home } }` frame. Its source factory attaches incremental listeners synchronously, so the Client publishes the generation and starts baseline reads only after incremental delivery is ready. diff --git a/packages/api/gateway/README.zh.md b/packages/api/gateway/README.zh.md index 7b37548f6a..8be11a7c5a 100644 --- a/packages/api/gateway/README.zh.md +++ b/packages/api/gateway/README.zh.md @@ -38,6 +38,8 @@ Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandle 每条流还在同一条逻辑流上携带从 Client 到 Host 的上行。方法把上行项类型声明为返回类型的第二个类型参数——来自 [`dsh-typert-protocol`](../../typert/protocol/README.zh.md) 的 `RemoteStream`;`In` 缺省为 `never`,返回 `Iterable`、`AsyncIterable` 或 `RemoteStream` 的方法即声明自己不读上行。运行中的方法通过 `this.ctx.invocation` 读取本次调用:Gateway 从携带该 `RemoteInvocation`(`request`、`service`、`peer`、`signal` 与 `uplink()`)的 Context 派生接收者,因此没有任何东西进入参数列表,而非 Remote 调用派生的 Context 上 `ctx.invocation` 为 `undefined`。`uplink()` 每次调用只能取一次。描述符带 codec 时它在交付前逐项解码;不带时——SRC 方法,或 `In` 为 `never` 的方法——它交付经 JSON 安全校验的 `unknown` 值。Client 在已打开的逻辑流上把每一项作为 `item` 帧发送(顶层 `undefined` 项是不带 `value` 的 `item` 帧),以 `end` 帧表示半关闭;方法通过 `uplink()` 迭代结束感知半关闭,通过 `signal` 感知取消。被拒绝的项使整条流以 `gateway/input-invalid`(字段 `uplink`)失败。上行项在 Host 逐项校验,因为它们来自浏览器;下行项是 Host 方法产出的值,不经校验原样透传。方法结束下行时 Gateway 调用 uplink 迭代器的 return 并丢弃未读的项;发给从未取用 uplink 的方法的项在 inbox 里等到那时为止。每条逻辑流最多缓冲 `streamInboxBytes`(默认 262144)字节的上行帧;超限使流以 `gateway/uplink-overflow` 失败,`end` 之后的项使流以 `gateway/protocol` 失败,两种情况都不关闭 socket。发给 Host 已结束的流 id 的 `item`、`end` 与 `cancel` 帧被丢弃;只有重复的 `open` 会关闭 socket。`$events` 这类 Gateway 自有的流在打开时立即 return 其 uplink,因此它们的 `item` 帧被丢弃而不缓冲。一元方法也可以调用 `uplink()`;其项只在方法运行期间可读。每条流都代表操作者 Peer:WebSocket 上的流代表 Connection 在升级时接纳的 Peer,该 scope 释放时 socket 关闭;进程内载体的流直接代表操作者。 +Host 下行、Host 上行和 Client 上行泵的取消与停止状态归属单次读取,不保留已交付项的历史。下行结束也会唤醒尚未完成的上行读取。 + Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source。Gateway 为它保留内部 `$events` logical endpoint,只接受空 `args`,并在 source 撤回时中止该注册打开的流。事件名单、参数校验、每个 Client 的队列及 opening `{ type: 'ready', clientId, host: { home } }` frame 中的 Host home 由 API Remotes 拥有。source factory 在返回 iterable 前同步挂好增量 listener,因此 Client 只在增量投递就绪后发布 generation 并开始 baseline 读取。 diff --git a/packages/api/gateway/src/client/stream-client.ts b/packages/api/gateway/src/client/stream-client.ts index 7a580c5fac..92255a10ee 100644 --- a/packages/api/gateway/src/client/stream-client.ts +++ b/packages/api/gateway/src/client/stream-client.ts @@ -158,7 +158,7 @@ export class RemoteStreamMuxClient { signal: AbortSignal, inbox: StreamInbox, ): UplinkPump { - const stopped = Promise.withResolvers>() + let stopped: PromiseWithResolvers> | undefined const interruption: IteratorReturnResult = { value: undefined, done: true } const uplinkIterator = uplink[Symbol.asyncIterator]() const state = { stopping: false, exhausted: false, released: false } @@ -171,7 +171,12 @@ export class RemoteStreamMuxClient { const done = (async (): Promise => { try { while (true) { + const stoppingBeforeRead = state.stopping + if (stoppingBeforeRead) return + // A stop promise belongs to one read, not the whole uplink history. + stopped = Promise.withResolvers>() const next = await Promise.race([uplinkIterator.next(), stopped.promise]) + stopped = undefined if (state.stopping || next === interruption || signal.aborted || this.socket !== socket) return if (next.done === true) { state.exhausted = true @@ -183,6 +188,7 @@ export class RemoteStreamMuxClient { } catch (error) { inbox.fail(error) } finally { + stopped = undefined release() } })() @@ -191,7 +197,7 @@ export class RemoteStreamMuxClient { stop: () => { if (state.stopping) return state.stopping = true - stopped.resolve(interruption) + stopped?.resolve(interruption) release() }, } diff --git a/packages/api/gateway/src/index.ts b/packages/api/gateway/src/index.ts index 777da35065..7c7a29b66a 100644 --- a/packages/api/gateway/src/index.ts +++ b/packages/api/gateway/src/index.ts @@ -1164,19 +1164,24 @@ async function *cancellableStream( ? Reflect.apply(asyncFactory, source, []) as AsyncIterator : Reflect.apply(syncFactory as (...args: never[]) => Iterator, source, []) let rejectAbort: ((error: unknown) => void) | undefined - const aborted = new Promise((_resolve, reject) => { rejectAbort = reject }) const onAbort = (): void => { rejectAbort?.(streamAbortFailure(endpoint, signal.reason)) } signal.addEventListener('abort', onAbort, { once: true }) try { - if (signal.aborted) throw streamAbortFailure(endpoint, signal.reason) while (true) { + if (signal.aborted) throw streamAbortFailure(endpoint, signal.reason) + // Reusing a pending cancellation promise retains every completed race. + const aborted = new Promise((_resolve, reject) => { rejectAbort = reject }) + // next() can abort and throw synchronously before the race subscribes. + void aborted.catch(() => undefined) const next = await Promise.race([Promise.resolve(iterator.next()), aborted]) + rejectAbort = undefined if (next.done === true) return yield next.value } } finally { + rejectAbort = undefined signal.removeEventListener('abort', onAbort) // The uplink closes first so a method blocked on `uplink.next()` unwinds // before its iterator is asked to return. @@ -1209,9 +1214,10 @@ function remoteCancelled(endpoint: string, cause: unknown): RemoteError<'gateway */ class UplinkDecoder implements AsyncIterable, AsyncIterator { private readonly source: AsyncIterator - private readonly interrupted = Promise.withResolvers>() + private readonly interrupted = new Set>>() private readonly onAbort = (): void => { - this.interrupted.reject(streamAbortFailure(this.endpoint, this.signal.reason)) + const failure = streamAbortFailure(this.endpoint, this.signal.reason) + for (const read of this.interrupted) read.reject(failure) } private closed = false @@ -1223,9 +1229,6 @@ class UplinkDecoder implements AsyncIterable, AsyncIterator { private readonly abort: (reason: unknown) => void, ) { this.source = uplink[Symbol.asyncIterator]() - // The rejection settles the race inside next(); an abort with no read in - // flight must not surface as an unhandled rejection. - void this.interrupted.promise.catch(() => undefined) signal.addEventListener('abort', this.onAbort, { once: true }) } @@ -1237,7 +1240,17 @@ class UplinkDecoder implements AsyncIterable, AsyncIterator { // A cancelled stream reports the cancellation on every read, closed or not. if (this.signal.aborted) throw streamAbortFailure(this.endpoint, this.signal.reason) if (this.closed) return UPLINK_DONE - const next = await Promise.race([this.source.next(), this.interrupted.promise]) + // Only pending reads belong to the decoder; finish() must wake all of them. + const interrupted = Promise.withResolvers>() + this.interrupted.add(interrupted) + // The source can abort and throw synchronously before the race subscribes. + void interrupted.promise.catch(() => undefined) + let next: IteratorResult + try { + next = await Promise.race([this.source.next(), interrupted.promise]) + } finally { + this.interrupted.delete(interrupted) + } if (next.done === true) { this.finish() return UPLINK_DONE @@ -1270,7 +1283,7 @@ class UplinkDecoder implements AsyncIterable, AsyncIterator { private finish(): void { this.closed = true this.signal.removeEventListener('abort', this.onAbort) - this.interrupted.resolve(UPLINK_DONE) + for (const read of this.interrupted) read.resolve(UPLINK_DONE) } } diff --git a/packages/api/gateway/tests/gateway-stream.host.spec.ts b/packages/api/gateway/tests/gateway-stream.host.spec.ts index 4c2f0e27c6..87aaaa9f75 100644 --- a/packages/api/gateway/tests/gateway-stream.host.spec.ts +++ b/packages/api/gateway/tests/gateway-stream.host.spec.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto' import { once } from 'node:events' +import { queryObjects } from 'node:v8' import { afterEach, describe, expect, it, vi } from 'vitest' import WebSocket, { type RawData } from 'ws' import { Context, symbols } from '@deepseek-ai/cordis' @@ -56,6 +57,7 @@ class FeedService extends TypertRemoteService { readonly peeked: unknown[] = [] readonly peers: PeerScope[] = [] leftover: AsyncIterator | undefined + source: Iterable = [] returns = 0 constructor(ctx: Context) { @@ -82,6 +84,11 @@ class FeedService extends TypertRemoteService { yield `${label}:two` } + @Remote({ mode: 'stream' }) + items(): Iterable { + return this.source + } + @Remote({ mode: 'stream' }) *invalid(): Iterable { yield 42 as unknown as string @@ -541,6 +548,110 @@ describe('Typert Remote streams', () => { await expect(iterator.return?.()).resolves.toEqual({ done: true, value: undefined }) }) + it('finishes every pending uplink read when the downlink returns', async () => { + const { ctx, service } = await setup(false) + const reads = [ + Promise.withResolvers>(), + Promise.withResolvers>(), + ] + let index = 0 + const returned = vi.fn(async (): Promise> => ({ done: true, value: undefined })) + const source = await ctx.typertGateway.stream({ + namespace: 'feed', method: 'hold', args: {}, + uplink: { [Symbol.asyncIterator]: () => ({ next: () => reads[index++]!.promise, return: returned }) }, + }) + const iterator = source[Symbol.asyncIterator]() + const completed: IteratorResult[] = [] + const pending: Promise[] = [] + try { + await expect(iterator.next()).resolves.toEqual({ done: false, value: 'held' }) + const uplink = service.leftover! + pending.push(...reads.map(() => uplink.next().then((result) => { completed.push(result) }))) + await iterator.return?.() + expect(completed).toEqual([ + { done: true, value: undefined }, + { done: true, value: undefined }, + ]) + expect(returned).toHaveBeenCalledOnce() + } finally { + for (const read of reads) read.resolve({ done: true, value: undefined }) + await Promise.all(pending) + await iterator.return?.() + } + }) + + it.each(['downlink', 'uplink'] as const)('releases consumed %s read results while the stream stays open', async (direction) => { + class ReadResult implements IteratorYieldResult { + readonly done = false + readonly value = 'item' + } + const { ctx, service } = await setup(false) + service.source = { [Symbol.iterator]: () => ({ next: () => new ReadResult() }) } + const source = await ctx.typertGateway.stream(direction === 'downlink' + ? { namespace: 'feed', method: 'items', args: {} } + : { + namespace: 'feed', method: 'echo', args: { prefix: '' }, + uplink: { [Symbol.asyncIterator]: () => ({ next: async () => new ReadResult() }) }, + }) + const iterator = source[Symbol.asyncIterator]() + try { + for (const count of [64, 256]) { + for (let index = 0; index < count; index++) { + await expect(iterator.next()).resolves.toEqual({ done: false, value: 'item' }) + } + // queryObjects collects garbage; the suspended read can retain its last result, not the history. + expect(queryObjects(ReadResult, { format: 'count' })).toBeLessThanOrEqual(2) + } + } finally { + await iterator.return?.() + } + }) + + it('does not read another downlink item after cancellation between reads', async () => { + const { ctx, service } = await setup(false) + const abort = new AbortController() + const next = vi.fn((): IteratorResult => ({ done: false, value: 'item' })) + service.source = { [Symbol.iterator]: () => ({ next }) } + const source = await ctx.typertGateway.stream({ + namespace: 'feed', method: 'items', args: {}, signal: abort.signal, + }) + const iterator = source[Symbol.asyncIterator]() + try { + await expect(iterator.next()).resolves.toEqual({ done: false, value: 'item' }) + abort.abort(new Error('caller cancelled between reads')) + await expect(iterator.next()).rejects.toMatchObject({ code: 'gateway/cancelled' }) + expect(next).toHaveBeenCalledOnce() + } finally { + await iterator.return?.() + } + }) + + it.each(['downlink', 'uplink'] as const)('handles a synchronous %s read that aborts and throws', async (direction) => { + const { ctx, service } = await setup(false) + const abort = new AbortController() + const failure = new Error('source aborted and threw') + const next = (): never => { + abort.abort(failure) + throw failure + } + service.source = { [Symbol.iterator]: () => ({ next }) } + const source = await ctx.typertGateway.stream(direction === 'downlink' + ? { namespace: 'feed', method: 'items', args: {}, signal: abort.signal } + : { + namespace: 'feed', method: 'hold', args: {}, signal: abort.signal, + uplink: { [Symbol.asyncIterator]: () => ({ next }) }, + }) + const iterator = source[Symbol.asyncIterator]() + try { + if (direction === 'uplink') { + await expect(iterator.next()).resolves.toEqual({ done: false, value: 'held' }) + } + await expect((direction === 'downlink' ? iterator : service.leftover!).next()).rejects.toBe(failure) + } finally { + await iterator.return?.() + } + }) + it('reports done on every read after the uplink ended', async () => { const { ctx } = await setup(false) await expect(collect(await ctx.typertGateway.stream({ @@ -1461,6 +1572,7 @@ function descriptors(): InvocationDescriptor[] { stream('context', [label], z.string()), { ...stream('follow', [label], z.string()), cancellation: { parameter: 'signal' } }, stream('sync', [label], z.string()), + stream('items', [], z.string()), stream('invalid', [], z.string()), stream('nonJson', [], z.unknown()), stream('missing', [], z.string()), diff --git a/packages/api/gateway/tests/gateway.client.spec.ts b/packages/api/gateway/tests/gateway.client.spec.ts index aeec14ecf1..32f473132b 100644 --- a/packages/api/gateway/tests/gateway.client.spec.ts +++ b/packages/api/gateway/tests/gateway.client.spec.ts @@ -1,3 +1,4 @@ +import { queryObjects } from 'node:v8' import { RemoteError, typertOwnedValue } from '@deepseek-ai/dsh-typert-protocol' import { Context, Service } from '@deepseek-ai/cordis' import type { Fiber } from '@deepseek-ai/cordis' @@ -2804,6 +2805,81 @@ describe('Client Typert API', () => { }) describe('Remote stream client carrier lifecycle', () => { + it('does not pull another uplink item when sending ends the stream', async () => { + await withFakeWebSocket('https://harness.example', async () => { + const client = new RemoteStreamMuxClient() + client.start() + const socket = FakeWebSocket.sockets[0]! + const send = socket.send.bind(socket) + const sent = vi.spyOn(socket, 'send').mockImplementation((text) => { + send(text) + const frame = parseRemoteStreamClientMessage(text) + if (frame.type === 'item') socket.receive({ type: 'end', streamId: frame.streamId }) + }) + const next = vi.fn(async (): Promise> => ({ done: false, value: 'item' })) + const returned = vi.fn(async (): Promise> => ({ done: true, value: undefined })) + try { + const iterator = client.open('probe/attach', {}, new AbortController().signal, { + [Symbol.asyncIterator]: () => ({ next, return: returned }), + })[Symbol.asyncIterator]() + await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined }) + expect(next).toHaveBeenCalledOnce() + expect(returned).toHaveBeenCalledOnce() + } finally { + await client.close() + sent.mockRestore() + } + }) + }) + + it('releases sent uplink read results while the stream stays open', async () => { + await withFakeWebSocket('https://harness.example', async () => { + class ReadResult implements IteratorYieldResult { + readonly done = false + readonly value = 'item' + } + const blocked = Promise.withResolvers() + const lastRead = Promise.withResolvers>() + let reads = 0 + const uplink: AsyncIterable = { + [Symbol.asyncIterator]: () => ({ + next: () => { + if (reads++ < 256) return Promise.resolve(new ReadResult()) + blocked.resolve() + return lastRead.promise + }, + return: async () => { + lastRead.resolve({ done: true, value: undefined }) + return { done: true, value: undefined } + }, + }), + } + const client = new RemoteStreamMuxClient() + client.start() + const iterator = client.open('probe/attach', {}, new AbortController().signal, uplink)[Symbol.asyncIterator]() + const first = iterator.next() + // Closing the carrier during failed assertions can reject this pending read. + void first.catch(() => undefined) + try { + await blocked.promise + const socket = FakeWebSocket.sockets[0]! + expect(socket.sent).toHaveLength(257) + const opened = parseRemoteStreamClientMessage(socket.sent[0]!) + if (opened.type !== 'open') throw new Error('fixture expected an open frame') + socket.receive({ type: 'item', streamId: opened.streamId, value: 'ready' }) + await expect(first).resolves.toEqual({ done: false, value: 'ready' }) + // A full GC runs while the uplink pump is still waiting for its next item. + expect(queryObjects(ReadResult, { format: 'count' })).toBeLessThanOrEqual(2) + socket.receive({ type: 'end', streamId: opened.streamId }) + await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined }) + } finally { + await client.close() + await first.catch(() => undefined) + await iterator.return?.() + } + }) + }) + it('connects to the shell-owned Host while the document uses a local asset origin', async () => { await withFakeWebSocket('dsh-app://app', async () => { vi.stubGlobal('__DSH_TRANSPORT__', { streamBaseUrl: 'http://127.0.0.1:43210' }) From 48392399545a31ae492402d411b8a31ae1de61ae Mon Sep 17 00:00:00 2001 From: Kaige-Gao Date: Wed, 23 Sep 2026 17:12:03 +0800 Subject: [PATCH 10/36] fix(chat): address image preview review and coverage gaps --- .../file-reference-prompt.expected.md | 2 +- apps/web/tests/markdown-images.e2e.ts | 23 +++++- .../src/client/chat/AssistantMarkdown.tsx | 3 + .../client/ui-deliverables/README.i18n.yaml | 4 +- packages/client/ui-deliverables/README.md | 4 +- packages/client/ui-deliverables/README.zh.md | 4 +- packages/client/ui-deliverables/src/index.ts | 2 +- .../ui-deliverables/tests/prompt.host.spec.ts | 2 +- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 6 +- packages/client/ui-primitives/README.zh.md | 6 +- .../ui-primitives/src/HoverCard.module.css | 2 +- .../client/ui-primitives/src/HoverCard.tsx | 37 ++++++---- .../ui-primitives/src/ImageLightbox.tsx | 4 +- .../client/ui-primitives/src/ImagePreview.tsx | 13 ++-- packages/client/ui-primitives/src/index.ts | 1 - .../src/markdown/local-image-syntax.ts | 15 ++-- .../ui-primitives/src/markdown/render.tsx | 5 +- .../tests/hover-card.client.spec.tsx | 74 ++++++++++++++++++- .../tests/image-lightbox.client.spec.tsx | 9 ++- .../markdown-image-entries.client.spec.tsx | 34 ++++++++- .../src/client/markdown/path-images.ts | 3 +- .../system-prompt.expected.md | 2 +- .../web/ptc-round/system-prompt.expected.md | 2 +- 24 files changed, 199 insertions(+), 62 deletions(-) rename packages/client/{ui-attachment => ui-primitives}/tests/image-lightbox.client.spec.tsx (86%) diff --git a/apps/web/tests/expected/web-runtime-context/file-reference-prompt.expected.md b/apps/web/tests/expected/web-runtime-context/file-reference-prompt.expected.md index 7a9e1faff5..62c89bd029 100644 --- a/apps/web/tests/expected/web-runtime-context/file-reference-prompt.expected.md +++ b/apps/web/tests/expected/web-runtime-context/file-reference-prompt.expected.md @@ -1 +1 @@ -Prefer showing the primary results within your final response alongside a brief explanation. Use ![Description]() when an image supports an explanation or comparison; it appears inline and opens a larger preview on click. Use [Description]() when referring to an image or listing files; image links offer a hover preview and open in the sidebar on click. Enclose Markdown file destinations in angle brackets, especially paths containing spaces. Do not call present just to list edited source files, or run commands to check whether a diff view will appear. Use present when a separate file card helps the user open the complete deliverable, including images, Office documents, spreadsheets, and slide decks. Each presented file adds a card below the reply, with preview and native-open actions. Usually select the 1-2 most important deliverables; include more when needed, but at most 4 files in a single present call. Avoid repeating results already shown inline unless the separate card adds useful access. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix. +Prefer showing the primary results within your final response alongside a brief explanation. Use ![Description]() when an image supports an explanation or comparison. Use [Description]() when referring to an image or listing files. Enclose Markdown file destinations in angle brackets, especially paths containing spaces. Do not call present just to list edited source files, or run commands to check whether a diff view will appear. Use present when a separate file card helps the user open the complete deliverable, including images, Office documents, spreadsheets, and slide decks. Each presented file adds a card below the reply, with preview and native-open actions. Usually select the 1-2 most important deliverables; include more when needed, but at most 4 files in a single present call. Avoid repeating results already shown inline unless the separate card adds useful access. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix. diff --git a/apps/web/tests/markdown-images.e2e.ts b/apps/web/tests/markdown-images.e2e.ts index ed93628d8b..bc24459a1e 100644 --- a/apps/web/tests/markdown-images.e2e.ts +++ b/apps/web/tests/markdown-images.e2e.ts @@ -288,7 +288,12 @@ describe('web e2e: Markdown image rendering', () => { const hoverImage = page.getByRole('img', { name: './valid.png', exact: true }) await hoverImage.waitFor() await expect.poll(() => hoverImage.evaluate(element => (element as HTMLImageElement).naturalWidth)).toBe(1) - await link.press('Escape') + await page.keyboard.press('Escape') + await hoverImage.waitFor({ state: 'detached' }) + await link.press('Tab') + await page.keyboard.press('Shift+Tab') + await hoverImage.waitFor() + await page.keyboard.press('Escape') await hoverImage.waitFor({ state: 'detached' }) const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) @@ -297,7 +302,21 @@ describe('web e2e: Markdown image rendering', () => { expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md']) - await link.click() + await link.evaluate((element) => { element.scrollIntoView({ block: 'end' }) }) + await page.mouse.move(0, 0) + await link.hover() + await hoverImage.waitFor() + const linkRect = await link.boundingBox() + if (linkRect === null) throw new Error('image link is not visible') + const previewRect = await hoverImage.evaluate((element) => { + const rect = element.parentElement!.parentElement!.getBoundingClientRect() + return { top: rect.top, bottom: rect.bottom } + }) + expect(previewRect.bottom <= linkRect.y || previewRect.top >= linkRect.y + linkRect.height).toBe(true) + const thumbnailRect = await hoverImage.boundingBox() + if (thumbnailRect === null) throw new Error('image preview is not visible') + expect(thumbnailRect.y + thumbnailRect.height).toBeLessThanOrEqual(previewRect.bottom) + await page.mouse.click(linkRect.x + linkRect.width / 2, linkRect.y + linkRect.height / 2) await expect.poll(() => page.locator('[data-image-preview] img').evaluate(element => (element as HTMLImageElement).naturalWidth)).toBe(1) }, 60_000) }) diff --git a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx index 41d8c5b8c1..d6d7c0d3ad 100644 --- a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx @@ -11,6 +11,9 @@ import { useSearchableHidden } from './searchable-hidden.ts' import css from './AssistantMarkdown.module.css' /** + * Standalone fallback for image destinations (query/fragment suffixes are ignored). + * Chat fileImages resolves decoded file references against cwd; pathImages also + * serves this component outside that provider and accepts legacy image URL suffixes. * Resolve an authored absolute image path against the document's file API. * @param base - canonical `document.baseURI` at render time. * @param value - authored Markdown destination; URL escapes are decoded once. diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index a032d8124f..4eda4bc0fc 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: 5ddf6cfc314a56c096ee75849873471c3f5366ab -README.zh.md: 23b992ee192f2f24e21b3d3ea73877c16b92990e +README.md: 786495d76098ce134ac378858c177cc498bd4c9f +README.zh.md: 9a1f000845ffab755ce23bd570cbe1a79b133676 diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index 5ddf6cfc31..786495d760 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -91,7 +91,7 @@ Read these pages when the deliverables surface is not enough. They move from the #### What the model sees -The guidance prefers primary results in the final reply: inline images support explanations and comparisons, image links offer hover previews and open in the Sidebar, and image file cards serve separate deliverables. File destinations use angle brackets, including paths with spaces. It recommends separate `present` cards for complete file deliverables, especially Office files, usually selecting the most important one or two, with at most four files per call when more are needed. It discourages cards solely for code edits and extra commands to inspect diff visibility. This guidance applies even with developer tools disabled: it accepts fewer file surfaces to avoid duplicate presentation and unnecessary tool calls. It asks the model to link every existing-file mention outside commands, configuration expressions, and code blocks, including repeats and tables. Labels default to filenames or clear aliases, with only enough parent directories to distinguish files. Precise references display `filename:24` or `filename:24–30`; their destinations retain full relative or absolute paths with `#L24` or `#L24-L30` anchors. The display suffix contains neither `#` nor `L`. +The guidance prefers primary results in the final reply: inline images support explanations and comparisons, image links serve references and file lists, and image file cards serve separate deliverables. File destinations use angle brackets, including paths with spaces. It recommends separate `present` cards for complete file deliverables, especially Office files, usually selecting the most important one or two, with at most four files per call when more are needed. It discourages cards solely for code edits and extra commands to inspect diff visibility. This guidance applies even with developer tools disabled: it accepts fewer file surfaces to avoid duplicate presentation and unnecessary tool calls. It asks the model to link every existing-file mention outside commands, configuration expressions, and code blocks, including repeats and tables. Labels default to filenames or clear aliases, with only enough parent directories to distinguish files. Precise references display `filename:24` or `filename:24–30`; their destinations retain full relative or absolute paths with `#L24` or `#L24-L30` anchors. The display suffix contains neither `#` nor `L`. #### Token effect @@ -108,7 +108,7 @@ The section is static at first-party order 9000 for the lifetime of the package These limits define the current deliverables vocabulary. They are current package constraints, not a general file-linking comparison or a task backlog. -- **Inline local images require HTTP(S) and POSIX absolute paths**: Desktop’s `dsh-app:` pages and Windows drive-letter paths do not support inline local images. The guidance retains a Markdown file link so users can open the Sidebar preview. +- **Inline local images require an HTTP(S) page**: Desktop’s `dsh-app:` pages do not support this file route. POSIX absolute paths and Windows drive-letter paths are supported; relative Chat image paths require the viewed Session’s workspace root. Separate image file links are optional. - **Mention matching is exact path or unique basename only** — a suffix mention stays inert; widening the matcher is deferred until a real closing-message shape needs it. - **Terminal-created files require explicit delivery** — the card lists them once git records the change, but delivery cards and inline-code references require `present`; explicit Markdown links can reference existing files directly. - **Declarations do not preserve file contents** — reopening or transferring a Session requires source files accessible through the viewed Session’s filesystem. Missing files, directories, and final symbolic links return 404. diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 23b992ee19..9a1f000845 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -91,7 +91,7 @@ Node 半部注册[模型体验](#model-experience)所述的静态 `ui:deliverabl #### 模型看到的内容 -提示词建议在最终回复中展示主要结果:内嵌图片用于讲解与对比,图片链接提供悬停预览并在点击时打开 Sidebar,图片文件卡片用于独立交付。文件目标路径使用尖括号包裹,包括含空格的路径。对于需要完整文件的交付,尤其是 Office 文件,建议使用独立的 `present` 卡片,通常选最重要的一两项,需要时可以更多,但每次调用最多四项。提示词建议避免仅为列出代码改动生成卡片,也不为确认 diff 是否显示额外运行命令。开发者工具关闭时仍采用这条建议,接受较少的文件展示入口,以减少重复展示和不必要的工具调用。文件引用要求链接命令、配置表达式和代码块以外的每次现有文件提及,包括重复提及和表格。标签默认使用文件名或清楚的别名,仅添加足以区分文件的父目录。精确引用显示为 `filename:24` 或 `filename:24–30`;目标保留完整相对路径或绝对路径,以及 `#L24` 或 `#L24-L30` 锚点。显示后缀不含 `#` 或 `L`。 +提示词建议在最终回复中展示主要结果:内嵌图片用于讲解与对比,图片链接用于引用和文件列表,图片文件卡片用于独立交付。文件目标路径使用尖括号包裹,包括含空格的路径。对于需要完整文件的交付,尤其是 Office 文件,建议使用独立的 `present` 卡片,通常选最重要的一两项,需要时可以更多,但每次调用最多四项。提示词建议避免仅为列出代码改动生成卡片,也不为确认 diff 是否显示额外运行命令。开发者工具关闭时仍采用这条建议,接受较少的文件展示入口,以减少重复展示和不必要的工具调用。文件引用要求链接命令、配置表达式和代码块以外的每次现有文件提及,包括重复提及和表格。标签默认使用文件名或清楚的别名,仅添加足以区分文件的父目录。精确引用显示为 `filename:24` 或 `filename:24–30`;目标保留完整相对路径或绝对路径,以及 `#L24` 或 `#L24-L30` 锚点。显示后缀不含 `#` 或 `L`。 #### Token 影响 @@ -108,7 +108,7 @@ Node 半部注册[模型体验](#model-experience)所述的静态 `ui:deliverabl 这些限制界定了当前产出物词表。它们是当前包约束,不是通用文件链接对比或任务积压。 -- **本地行内图片要求 HTTP(S) 页面和 POSIX 绝对路径**:Desktop 的 `dsh-app:` 页面和 Windows 盘符路径不支持本地行内图片。提示词要求保留 Markdown 文件链接,让用户仍可打开 Sidebar 预览。 +- **本地行内图片要求 HTTP(S) 页面**:Desktop 的 `dsh-app:` 页面不支持此文件路由。支持 POSIX 绝对路径与 Windows 盘符路径;Chat 中的相对图片路径需要当前查看的 Session 工作区根目录。图片的独立文件链接是可选的。 - **提及匹配只认精确路径或唯一 basename**——后缀式提及保持惰性;等真实的收尾消息形态产生需求后再放宽匹配规则。 - **终端创建的文件需要显式交付**——git 记录到改动后卡片会列出它们,但交付卡片和行内代码引用需要调用 `present`;显式 Markdown 链接可以直接引用现有文件。 - **声明不保存文件内容**:重新打开或转移 Session 后,源文件仍需能被当前查看的 Session 文件系统访问。文件缺失、为目录或最终路径为符号链接时返回 404。 diff --git a/packages/client/ui-deliverables/src/index.ts b/packages/client/ui-deliverables/src/index.ts index 7762a67d16..6280740f3c 100644 --- a/packages/client/ui-deliverables/src/index.ts +++ b/packages/client/ui-deliverables/src/index.ts @@ -15,7 +15,7 @@ export const inject = ['systemPrompt', 'connection', 'sessionQuery', 'sessionCon /** Static Web guidance for primary outputs and existing-file references. */ const FILE_REFERENCE_PROMPT = 'Prefer showing the primary results within your final response alongside a brief explanation. ' - + 'Use ![Description]() when an image supports an explanation or comparison; it appears inline and opens a larger preview on click. Use [Description]() when referring to an image or listing files; image links offer a hover preview and open in the sidebar on click. Enclose Markdown file destinations in angle brackets, especially paths containing spaces. ' + + 'Use ![Description]() when an image supports an explanation or comparison. Use [Description]() when referring to an image or listing files. Enclose Markdown file destinations in angle brackets, especially paths containing spaces. ' + 'Do not call present just to list edited source files, or run commands to check whether a diff view will appear. ' + 'Use present when a separate file card helps the user open the complete deliverable, including images, Office documents, spreadsheets, and slide decks. Each presented file adds a card below the reply, with preview and native-open actions. ' + 'Usually select the 1-2 most important deliverables; include more when needed, but at most 4 files in a single present call. Avoid repeating results already shown inline unless the separate card adds useful access. ' diff --git a/packages/client/ui-deliverables/tests/prompt.host.spec.ts b/packages/client/ui-deliverables/tests/prompt.host.spec.ts index 249792ec88..001d73eada 100644 --- a/packages/client/ui-deliverables/tests/prompt.host.spec.ts +++ b/packages/client/ui-deliverables/tests/prompt.host.spec.ts @@ -29,7 +29,7 @@ describe('ui-deliverables node plugin', () => { const section = (await ctx.systemPrompt.assemble()).sections .find(entry => entry.name === 'ui:deliverable-file-references') - expect(section?.text).toMatchInlineSnapshot('"Prefer showing the primary results within your final response alongside a brief explanation. Use ![Description]() when an image supports an explanation or comparison; it appears inline and opens a larger preview on click. Use [Description]() when referring to an image or listing files; image links offer a hover preview and open in the sidebar on click. Enclose Markdown file destinations in angle brackets, especially paths containing spaces. Do not call present just to list edited source files, or run commands to check whether a diff view will appear. Use present when a separate file card helps the user open the complete deliverable, including images, Office documents, spreadsheets, and slide decks. Each presented file adds a card below the reply, with preview and native-open actions. Usually select the 1-2 most important deliverables; include more when needed, but at most 4 files in a single present call. Avoid repeating results already shown inline unless the separate card adds useful access. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix."') + expect(section?.text).toMatchInlineSnapshot('"Prefer showing the primary results within your final response alongside a brief explanation. Use ![Description]() when an image supports an explanation or comparison. Use [Description]() when referring to an image or listing files. Enclose Markdown file destinations in angle brackets, especially paths containing spaces. Do not call present just to list edited source files, or run commands to check whether a diff view will appear. Use present when a separate file card helps the user open the complete deliverable, including images, Office documents, spreadsheets, and slide decks. Each presented file adds a card below the reply, with preview and native-open actions. Usually select the 1-2 most important deliverables; include more when needed, but at most 4 files in a single present call. Avoid repeating results already shown inline unless the separate card adds useful access. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix."') // Only live request pins track current guidance; seeded sessions retain their recorded prompt. for (const scenario of ['fresh-round-trip', 'ptc-round']) { diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index b7c783080c..1747da058c 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/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-primitives/README.md -README.md: 349921e171336dc16f9ae38ac815a9e042bc447c -README.zh.md: eec967d30243566ce2f78e1b325c1af96d663519 +README.md: fbb8ef676e55d1ef2c3c5c8a6c91a12a3c8fe556 +README.zh.md: 822ff17c3c4c6b02339cdacd03d9c52c67a738c7 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 349921e171..fbb8ef676e 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -54,7 +54,7 @@ Check this table before writing a control in a feature package. A plugin cannot | `OnboardingSurface` | First-run stage that holds the application root inert. | | `Tooltip` | Hover text anchored to a cloned child; optional `portal` rendering escapes clipping containers and ancestor stacking contexts that cap the bubble's z-index. | | `HoverCard` | Hover preview the pointer can rest on and select from; optional copy button. | -| `ImageLightbox`, `ImagePreview` | Shared image modal and passive contained preview with loading/failure states. | +| `ImageLightbox` | Shared image modal with focus restoration and Escape dismissal. | | `Toast` | Transient top-center banner held for the owner's `holdMs`. | | `SettingsForm`, `SettingsValueField`, `SettingsSecretField` | The frame and the controls of a plugin's settings page: the frame takes its copy as `labels`, saves only on its button, and discards on unmount; a value field shows staged text with the overridden badge and reset; a secret field starts blank and reports only whether a value is configured. | | `SettingsFormModel`, `settingsNumberField`, `settingsTextField` | The staged-edit model behind such a page over a settings scope: drafts are staged and written on save, a field is overridden by its presence in the user layer, and a save that did not land keeps its drafts. | @@ -86,7 +86,7 @@ The catalog above lists what each export is for; this section covers the behavio The nearest `MarkdownDelegateProvider` supplies optional `openExternalLink` and `openFile` navigation callbacks. Nested providers replace the enclosing capabilities, and callback changes reach already-rendered links without rebuilding Markdown. Its `openFile` makes local Markdown links clickable after settlement. Absolute and workspace-relative paths support percent escapes and `#L24` / `#L24-L30` fragments; ranges open at their first line. Literal `?` and `#` in filenames must be percent-encoded. The tooltip uses the decoded path and supplies the accessible name when the label is empty. The callback receives the decoded path and optional line, while the renderer preserves the label and displays a file icon. Without a callback, local links remain text. URL schemes, queries, unsupported fragments, and malformed destinations never reach the file opener. -`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. A surrounding `MarkdownDelegateProvider` receives sanitized HTTP(S) URLs from ordinary clicks; modified clicks and links outside a provider retain native external-anchor behavior. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty. Changing the image source permits a fresh load. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `projectUserText` projects sent user text into inline plain runs and reference chips for the message bubble and queue rows. Reference labels inherit the consumer’s wrapping policy: long names wrap within a bubble, while queue previews retain their single-line layout. When supplied with `UserTextReferences`, file and skill references become keyboard-accessible preview buttons using the same hover and focus styling as prose file links; the first pointer click can open a preview, while subsequent clicks and existing text selections retain native selection handling. Keyboard activation opens previews even when text is selected. +`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. A surrounding `MarkdownDelegateProvider` receives sanitized HTTP(S) URLs from ordinary clicks; modified clicks and links outside a provider retain native external-anchor behavior. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty; `fileImages` additionally supplies a localized failure prefix. Changing the image source permits a fresh load. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `projectUserText` projects sent user text into inline plain runs and reference chips for the message bubble and queue rows. Reference labels inherit the consumer’s wrapping policy: long names wrap within a bubble, while queue previews retain their single-line layout. When supplied with `UserTextReferences`, file and skill references become keyboard-accessible preview buttons using the same hover and focus styling as prose file links; the first pointer click can open a preview, while subsequent clicks and existing text selections retain native selection handling. Keyboard activation opens previews even when text is selected. `MarkdownText` defaults to `variant="body"`. Use `variant="compact"` for secondary content: its 13px text and 20px line height follow the content-size setting, all heading levels use the same size with weight 600, and paragraphs and lists use tighter spacing. Text, links, and code keep the tertiary color; dotted underlines distinguish links. Code headers scroll with their blocks. Tables and math stay enabled at the surrounding text size and scroll horizontally within the available width. Both variants share the parser and streaming cache. @@ -96,7 +96,7 @@ The nearest `MarkdownDelegateProvider` supplies optional `openExternalLink` and `JsonTree` clamps collapsed strings to `collapsedStringLines` (three by default). Expanded strings show raw text, retain sibling commas, and fit within the window and outer scrolling containers. Resize and ancestor-scroll events update that limit. Row copy feedback updates independently of JSON value rendering; pending clipboard writes cannot update a different row or an unmounted tree. -`ImageLightbox` is the shared original-image modal with focus restoration and Escape dismissal; `ImagePreview` is a passive, contained thumbnail with owner-supplied loading/failure labels. `HoverCard.inline` keeps a file link in the text flow and uses the shared menu material, keyboard focus, and viewport-clamped placement. `MarkdownDelegateProvider.fileImages` supplies a decoded-path resolver and complete image labels: settled image links gain hover previews, and standalone images gain lightbox activation. Image-only anchors retain their single navigation target. The parser recovers only complete, unescaped, standalone local image references with bare spaces and an unambiguous supported image suffix; code and ambiguous destinations remain literal. +`ImageLightbox` is the shared original-image modal with focus restoration and Escape dismissal. The internal thumbnail renderer receives loading/failure labels from its owner. `HoverCard.inline` keeps a file link in the text flow and uses the shared menu material, keyboard-visible focus, and placement above or below the anchor without covering it. Escape closes an open thumbnail even when focus is elsewhere; subsequent Escape presses reach the owner. `MarkdownDelegateProvider.fileImages` supplies a decoded-path resolver and complete image labels: settled image links gain hover previews, and standalone images gain lightbox activation. Image-only anchors retain their single navigation target. The parser recovers only complete, unescaped, standalone local image references with bare spaces and an unambiguous supported image suffix; code and ambiguous destinations remain literal. ### Localizing copy diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index eec967d302..822ff17c3c 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -54,7 +54,7 @@ kind: "package-library" | `OnboardingSurface` | 首次运行的引导舞台,期间保持应用根节点 inert。 | | `Tooltip` | 锚定在克隆子元素上的悬停文本;可通过 `portal` 渲染到外层,避免被容器裁剪,或受祖先层叠上下文限制其 z-index。 | | `HoverCard` | 指针可停留、可选中的悬停预览;可选带复制按钮。 | -| `ImageLightbox`, `ImagePreview` | 共享图片浮层,以及带加载和失败状态的等比预览。 | +| `ImageLightbox` | 共享图片浮层,支持焦点恢复与 Esc 关闭。 | | `Toast` | 顶部居中的瞬时横幅,保持时长由所有者的 `holdMs` 决定。 | | `SettingsForm`、`SettingsValueField`、`SettingsSecretField` | 插件设置页的框架与控件:框架以 `labels` 接收文案,只在按钮点击时保存,卸载即丢弃;值字段显示暂存文本以及已覆盖标签和重置;密文字段每次为空,只报告是否已配置。 | | `SettingsFormModel`、`settingsNumberField`、`settingsTextField` | 这类页面背后基于设置 scope 的暂存编辑模型:草稿先暂存、保存时写入,字段是否被覆盖看用户层是否含有它,未落地的保存保留草稿。 | @@ -86,7 +86,7 @@ kind: "package-library" 最近的 `MarkdownDelegateProvider` 提供可选的 `openExternalLink` 和 `openFile` 导航回调。嵌套 Provider 替换外层能力,回调变化无需重新构建 Markdown 即可到达已渲染链接。其 `openFile` 使本地 Markdown 链接在落定后可点击。绝对路径和工作区相对路径支持百分号转义以及 `#L24` / `#L24-L30` 片段;范围定位到起始行。文件名中的字面 `?` 和 `#` 必须百分号编码。悬停提示使用解码后的路径,并在标签为空时提供可访问名称。回调接收解码后的路径和可选行号,渲染器保留标签并显示文件图标。不传回调时,本地链接仍为文本。URL 协议、查询串、不支持的片段及格式错误的目标不会传给文件打开器。 -`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。外层 `MarkdownDelegateProvider` 会接收普通点击产生的已净化 HTTP(S) URL;带修饰键的点击和 Provider 外的链接保留原生外部 anchor 行为。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径。图片源变化后可重新加载。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`projectUserText` 把已发送的用户文本投影为行内普通文本段与引用 chip,供消息气泡和排队行使用。引用名称继承调用方的换行规则:长名称在气泡内换行,排队预览保持单行布局。传入 `UserTextReferences` 时,文件和 skill 引用成为支持键盘操作的预览按钮,复用正文文件链接的悬停和聚焦样式;第一次指针点击可以打开预览,后续点击和已有选区保留原生选择行为。键盘激活在存在选区时仍可打开预览。 +`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。外层 `MarkdownDelegateProvider` 会接收普通点击产生的已净化 HTTP(S) URL;带修饰键的点击和 Provider 外的链接保留原生外部 anchor 行为。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径;`fileImages` 还提供本地化的失败提示前缀。图片源变化后可重新加载。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`projectUserText` 把已发送的用户文本投影为行内普通文本段与引用 chip,供消息气泡和排队行使用。引用名称继承调用方的换行规则:长名称在气泡内换行,排队预览保持单行布局。传入 `UserTextReferences` 时,文件和 skill 引用成为支持键盘操作的预览按钮,复用正文文件链接的悬停和聚焦样式;第一次指针点击可以打开预览,后续点击和已有选区保留原生选择行为。键盘激活在存在选区时仍可打开预览。 `MarkdownText` 默认为 `variant="body"`。次级内容使用 `variant="compact"`:其 13px 字号与 20px 行高跟随内容字号设置,各级标题保持同一字号并使用 600 字重,段落与列表采用更紧凑的间距。正文、链接和代码均保持 tertiary 颜色,以点状下划线区分链接。代码标题栏随代码块滚动。表格和公式仍然启用,使用周围文字的字号,并在可用宽度内横向滚动。两个变体共享解析器与流式缓存。 @@ -96,7 +96,7 @@ kind: "package-library" `JsonTree` 把折叠字符串限制为 `collapsedStringLines` 行(默认三行)。展开后显示原始文本、保留同级逗号,并限制在窗口与外层滚动容器内;尺寸变化和祖先滚动事件会更新此限制。行复制反馈独立于 JSON 值渲染更新;尚未完成的剪贴板写入不会更新另一行或已卸载的树。 -`ImageLightbox` 是共享原图浮层,支持焦点恢复与 Esc 关闭;`ImagePreview` 是被动的等比缩略图,由调用方提供加载及失败文案。`HoverCard.inline` 使文件链接保持在文本流内,并使用共享菜单材质、键盘聚焦和视口内定位。`MarkdownDelegateProvider.fileImages` 提供已解码路径解析器与完整图片文案:消息落定后的图片链接支持悬停预览,独立图片支持点击放大。仅包含图片的链接保留单一导航目标。解析器仅恢复完整、未转义、独立成段且带明确图片扩展名的含裸空格本地图片引用;代码与有歧义的目标保持原文。 +`ImageLightbox` 是共享原图浮层,支持焦点恢复与 Esc 关闭。包内缩略图渲染器由调用方提供加载及失败文案。`HoverCard.inline` 使文件链接保持在文本流内,并使用共享菜单材质、键盘可见焦点,并在锚点上方或下方定位而不遮挡锚点。即使焦点位于其他位置,Esc 也会关闭已打开的缩略图;后续 Esc 按键继续传给 owner。`MarkdownDelegateProvider.fileImages` 提供已解码路径解析器与完整图片文案:消息落定后的图片链接支持悬停预览,独立图片支持点击放大。仅包含图片的链接保留单一导航目标。解析器仅恢复完整、未转义、独立成段且带明确图片扩展名的含裸空格本地图片引用;代码与有歧义的目标保持原文。 ### 本地化文案 diff --git a/packages/client/ui-primitives/src/HoverCard.module.css b/packages/client/ui-primitives/src/HoverCard.module.css index db4805c894..c6a1757d86 100644 --- a/packages/client/ui-primitives/src/HoverCard.module.css +++ b/packages/client/ui-primitives/src/HoverCard.module.css @@ -82,7 +82,7 @@ .inline { display: inline; } .media { - width: min(300px, calc(100vw - 16px)); + overflow: auto; padding: 8px; border: 0; color: var(--dsw-alias-label-primary); diff --git a/packages/client/ui-primitives/src/HoverCard.tsx b/packages/client/ui-primitives/src/HoverCard.tsx index dfb752ee35..aa8aec1613 100644 --- a/packages/client/ui-primitives/src/HoverCard.tsx +++ b/packages/client/ui-primitives/src/HoverCard.tsx @@ -11,6 +11,7 @@ import css from './HoverCard.module.css' const PREVIEW_FADE_MS = 100 const PREVIEW_MAX_HEIGHT = 420 const PREVIEW_INSET = 24 +const INLINE_PREVIEW_WIDTH = 300 const ANCHOR_GAP = 8 const VIEWPORT_MARGIN = 8 @@ -23,6 +24,7 @@ const VIEWPORT_MARGIN = 8 * @param props.variant - compact card beside the anchor, or a preview above/below it * with 24px side insets, a 420px height cap, frame-top clearance, and 100ms opacity transitions. * @param props.widthAnchorRef - optional element whose width and horizontal position size the preview. + * @param props.inline - keep the anchor in prose; show a contained preview on hover or keyboard focus. * @param props.disabled - suppress opening; turning true dismisses an open card. * @param props.copyText - optional primary value copied by activation and * included in the card's accessible name. @@ -114,15 +116,17 @@ export function HoverCard({ }, []) useEffect(() => { - if (!open || variant !== 'preview') return + if (!open || (variant !== 'preview' && !inline)) return const dismiss = (event: KeyboardEvent): void => { if (event.key !== 'Escape') return + if (inline) event.stopPropagation() + clearTimer() cancelClose() close() } - window.addEventListener('keydown', dismiss) - return () => { window.removeEventListener('keydown', dismiss) } - }, [open, variant, cancelClose, close]) + window.addEventListener('keydown', dismiss, inline) + return () => { window.removeEventListener('keydown', dismiss, inline) } + }, [open, variant, inline, cancelClose, close]) // Fixed-position from the anchor rect before paint; track the anchor while // open (capture-phase scroll catches nested panes), as in Menu portal mode. @@ -151,10 +155,18 @@ export function HoverCard({ return } if (inline) { - const w = cardRef.current?.offsetWidth ?? 300 + const height = Math.max(h, cardRef.current?.scrollHeight ?? 0) + const width = Math.max(0, Math.min(INLINE_PREVIEW_WIDTH, window.innerWidth - VIEWPORT_MARGIN * 2)) + const topMargin = overlayTopMargin(VIEWPORT_MARGIN) + const belowTop = Math.max(topMargin, r.bottom + ANCHOR_GAP) + const above = Math.max(0, r.top - ANCHOR_GAP - topMargin) + const below = Math.max(0, window.innerHeight - belowTop - VIEWPORT_MARGIN) + const onTop = height > below && above > below + const maxHeight = onTop ? above : below setPos({ - left: Math.max(VIEWPORT_MARGIN, Math.min(r.left, window.innerWidth - w - VIEWPORT_MARGIN)), - top: Math.max(overlayTopMargin(VIEWPORT_MARGIN), Math.min(r.bottom + ANCHOR_GAP, window.innerHeight - h - VIEWPORT_MARGIN)), + left: Math.max(VIEWPORT_MARGIN, Math.min(r.left, window.innerWidth - width - VIEWPORT_MARGIN)), + top: onTop ? r.top - ANCHOR_GAP - Math.min(height, maxHeight) : belowTop, + width, maxHeight, }) return } @@ -179,13 +191,13 @@ export function HoverCard({ // card's real height is measurable, correct the bottom-edge clamp. The // correction converges — a clamped top satisfies the guard, so it runs once. useLayoutEffect(() => { - if (!open || pos === null || variant === 'preview') return + if (!open || pos === null || variant === 'preview' || inline) return /* v8 ignore next -- the card is mounted whenever pos is set, so the ref is attached here. */ const h = cardRef.current?.offsetHeight ?? 0 if (pos.top + h > window.innerHeight - VIEWPORT_MARGIN) { setPos({ left: pos.left, top: window.innerHeight - h - VIEWPORT_MARGIN }) } - }, [open, pos, variant]) + }, [open, pos, variant, inline]) const copy = async (text: string): Promise => { if (copied || copyingRef.current) return @@ -248,13 +260,12 @@ export function HoverCard({ { if (!disabled) { cancelClose(); setPhase('open') } } : undefined} + onFocus={inline ? (event) => { + if (!disabled && event.target.matches(':focus-visible')) { cancelClose(); setPhase('open') } + } : undefined} onBlur={inline ? (event) => { if (!event.currentTarget.contains(event.relatedTarget)) { clearTimer(); cancelClose(); close() } } : undefined} - onKeyDown={inline ? (event) => { - if (event.key === 'Escape') { event.stopPropagation(); clearTimer(); cancelClose(); close() } - } : undefined} onPointerEnter={(event) => { if (disabled || (inline && event.pointerType === 'touch')) return // Coming back inside during the grace (the gap, or the card itself) diff --git a/packages/client/ui-primitives/src/ImageLightbox.tsx b/packages/client/ui-primitives/src/ImageLightbox.tsx index 76c8aa2875..893ed2fe95 100644 --- a/packages/client/ui-primitives/src/ImageLightbox.tsx +++ b/packages/client/ui-primitives/src/ImageLightbox.tsx @@ -40,9 +40,9 @@ export function ImageLightbox({ src, alt, labels, onClose }: { if (event.key === 'Escape') { event.stopPropagation(); onClose() } if (event.key === 'Tab') { event.preventDefault(); closeRef.current?.focus() } } - window.addEventListener('keydown', onKeyDown) + window.addEventListener('keydown', onKeyDown, true) return () => { - window.removeEventListener('keydown', onKeyDown) + window.removeEventListener('keydown', onKeyDown, true) restoreRef.current?.focus() } }, [onClose]) diff --git a/packages/client/ui-primitives/src/ImagePreview.tsx b/packages/client/ui-primitives/src/ImagePreview.tsx index ffe6fec1b9..1875a151c8 100644 --- a/packages/client/ui-primitives/src/ImagePreview.tsx +++ b/packages/client/ui-primitives/src/ImagePreview.tsx @@ -1,33 +1,30 @@ /** Passive, contained image preview for Markdown image links. */ import { useState } from 'react' -import clsx from 'clsx' import { IconLoadingOutlineRegular } from './icons/index.tsx' import css from './ImagePreview.module.css' /** * Render an image without introducing a second activation target. - * @param props - Source, accessible description, localized status, and owner layout class. + * @param props - Source, accessible description, localized status. * @returns A contained preview with loading or failure status. */ -export function ImagePreview({ src, alt, loadingLabel, failedLabel, className }: { +export function ImagePreview({ src, alt, loadingLabel, failedLabel }: { src: string alt: string loadingLabel: string failedLabel: string - className?: string | undefined }) { - return + return } -function Preview({ src, alt, loadingLabel, failedLabel, className }: { +function Preview({ src, alt, loadingLabel, failedLabel }: { src: string alt: string loadingLabel: string failedLabel: string - className: string | undefined }) { const [state, setState] = useState<'loading' | 'ready' | 'failed'>('loading') - return + return {state !== 'failed' && {alt} { setState('ready') }} onError={() => { setState('failed') }} />} diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index def473220c..ab82a9f6a5 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -104,4 +104,3 @@ export { } from './plugin-artwork.tsx' export { ImageLightbox } from './ImageLightbox.tsx' export type { ImageLightboxLabels } from './ImageLightbox.tsx' -export { ImagePreview } from './ImagePreview.tsx' diff --git a/packages/client/ui-primitives/src/markdown/local-image-syntax.ts b/packages/client/ui-primitives/src/markdown/local-image-syntax.ts index a22c1b9d81..c59a323923 100644 --- a/packages/client/ui-primitives/src/markdown/local-image-syntax.ts +++ b/packages/client/ui-primitives/src/markdown/local-image-syntax.ts @@ -1,5 +1,6 @@ /** Recover standalone local image references with bare spaces without changing code or source offsets. */ -import type { Root, RootContent } from 'mdast' +import type { Root, RootContent, PhrasingContent } from 'mdast' +import { classifyFileType } from '../FileTypeIcon.tsx' /** * Recover only unambiguous, unescaped image-only paragraphs containing a local path with spaces. @@ -10,13 +11,13 @@ import type { Root, RootContent } from 'mdast' export function recoverLocalImages(root: Root, source: string): Root { const visit = (node: Root | RootContent): void => { if (node.type === 'paragraph' && node.children.length === 1) { - const child = node.children[0] - if (child?.type === 'text' && child.position !== undefined + const [child] = node.children as [PhrasingContent] + if (child.type === 'text' && child.position !== undefined && source.slice(child.position.start.offset, child.position.end.offset) === child.value) { - const pattern = /^!\[([^\]\n]*)\]\(((?:\/(?!\/)|\.{1,2}\/|[a-z]:[\\/])[^\n<>()[\]"']+\.(?:png|jpe?g|gif|webp|svg|bmp|ico))\)$/iu - const match = pattern.exec(child.value) - if (match !== null && match[2]?.includes(' ')) { - node.children = [{ type: 'image', alt: match[1] ?? '', url: match[2], position: child.position }] + const pattern = /^!\[([^\]\n]*)\]\(((?:\/(?!\/)|\.{1,2}\/|[a-z]:[\\/])[^\n<>()[\]"']+\.[a-z\d]+)\)$/iu + const match = pattern.exec(child.value) as [string, string, string] | null + if (match !== null && match[2].includes(' ') && classifyFileType(match[2]) === 'image') { + node.children = [{ type: 'image', alt: match[1], url: match[2], position: child.position }] } } } else if ('children' in node) { diff --git a/packages/client/ui-primitives/src/markdown/render.tsx b/packages/client/ui-primitives/src/markdown/render.tsx index 10b3e59fb1..aefe70f2ec 100644 --- a/packages/client/ui-primitives/src/markdown/render.tsx +++ b/packages/client/ui-primitives/src/markdown/render.tsx @@ -584,6 +584,7 @@ function MarkdownFileLink({ file, glyph, children }: { }): ReactNode { const { openFile, fileImages } = useMarkdownDelegate() if (openFile === undefined) return <>{children} + // Pure-image anchors already contain their preview and keep one navigation target. const preview = glyph && classifyLinkPath(file.path) === 'image' ? fileImages : undefined const src = preview?.resolve(file.path) const anchor = ( @@ -598,7 +599,7 @@ function MarkdownFileLink({ file, glyph, children }: { ) if (src === undefined || preview === undefined) return anchor - return {file.path.split(/[\\/]/u).pop()} @@ -655,7 +656,7 @@ function LoadedMarkdownImage({ src, alt, destination, preview }: { return <> + onClick={() => { setOpen(true) }}>{img} {open && } } diff --git a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx index a71922c828..a933f6d3c2 100644 --- a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx +++ b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx @@ -6,7 +6,7 @@ import { POINTER_GRACE_MS } from '../src/pointer-grace.ts' afterEach(cleanup) beforeEach(() => { vi.useFakeTimers() }) -afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals() }) +afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); vi.restoreAllMocks() }) /** Anchor wrapper rect: the card positions from this (jsdom rects are all-zero by default). */ function stubAnchorRect(anchor: HTMLElement, rect: { top: number; right: number }): void { @@ -566,3 +566,75 @@ describe('HoverCard', () => { expect(screen.queryByText('card body')).toBeNull() }) }) + +it('opens inline previews only for keyboard focus and closes when focus leaves the anchor', () => { + const anchor = <> + const view = render(thumbnail} />) + const first = screen.getByRole('button', { name: 'first' }) + const second = screen.getByRole('button', { name: 'second' }) + const matches = vi.spyOn(first, 'matches').mockReturnValue(false) + fireEvent.focus(first) + expect(screen.queryByText('thumbnail')).toBeNull() + matches.mockReturnValue(true) + fireEvent.focus(first) + expect(screen.getByText('thumbnail')).toBeTruthy() + fireEvent.blur(first, { relatedTarget: second }) + expect(screen.getByText('thumbnail')).toBeTruthy() + fireEvent.blur(second, { relatedTarget: document.body }) + expect(screen.queryByText('thumbnail')).toBeNull() + view.rerender(thumbnail} />) + fireEvent.focus(first) + expect(screen.queryByText('thumbnail')).toBeNull() + matches.mockRestore() +}) + +it('dismisses an inline hover without anchor focus and lets the next Escape reach the owner', () => { + const ownerKey = vi.fn() + render(
+ + image link} content={thumbnail} /> +
) + const outside = screen.getByRole('button', { name: 'outside' }) + outside.focus() + const wrapper = screen.getByRole('button', { name: 'image link' }).parentElement! + // jsdom lacks PointerEvent; preserve its pointerType through a regular DOM event. + const touch = new Event('pointerover', { bubbles: true }) + Object.defineProperty(touch, 'pointerType', { value: 'touch' }) + fireEvent(wrapper, touch) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.queryByText('thumbnail')).toBeNull() + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + expect(document.activeElement).toBe(outside) + fireEvent.keyDown(outside, { key: 'a' }) + expect(screen.getByText('thumbnail')).toBeTruthy() + ownerKey.mockClear() + fireEvent.keyDown(outside, { key: 'Escape' }) + expect(screen.queryByText('thumbnail')).toBeNull() + expect(ownerKey).not.toHaveBeenCalled() + fireEvent.keyDown(outside, { key: 'Escape' }) + expect(ownerKey).toHaveBeenCalledOnce() +}) + +it('flips inline media above a bottom link and constrains it without covering the anchor', () => { + render(image link} content={thumbnail} />) + const wrapper = screen.getByRole('button').parentElement! + wrapper.getBoundingClientRect = () => DOMRect.fromRect({ x: window.innerWidth - 100, y: window.innerHeight - 60, width: 80, height: 24 }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + const card = screen.getByText('thumbnail').parentElement! + Object.defineProperty(card, 'offsetHeight', { configurable: true, value: 28 }) + Object.defineProperty(card, 'scrollHeight', { configurable: true, value: 220 }) + fireEvent.resize(window) + expect(Number.parseFloat(card.style.top) + 220).toBe(window.innerHeight - 68) + expect(Number.parseFloat(card.style.left) + Number.parseFloat(card.style.width)).toBe(window.innerWidth - 8) + // Neither side fits: constrain the larger side and keep the link unobstructed. + Object.defineProperty(card, 'offsetHeight', { configurable: true, value: 1000 }) + fireEvent.resize(window) + expect(card.style.top).toBe('8px') + expect(Number.parseFloat(card.style.maxHeight)).toBe(window.innerHeight - 76) + wrapper.getBoundingClientRect = () => DOMRect.fromRect({ x: 30, y: 10, width: 80, height: 24 }) + fireEvent.resize(window) + expect(card.style.top).toBe('42px') + expect(Number.parseFloat(card.style.maxHeight)).toBe(window.innerHeight - 50) +}) diff --git a/packages/client/ui-attachment/tests/image-lightbox.client.spec.tsx b/packages/client/ui-primitives/tests/image-lightbox.client.spec.tsx similarity index 86% rename from packages/client/ui-attachment/tests/image-lightbox.client.spec.tsx rename to packages/client/ui-primitives/tests/image-lightbox.client.spec.tsx index 3efde85334..93fd5a5c4a 100644 --- a/packages/client/ui-attachment/tests/image-lightbox.client.spec.tsx +++ b/packages/client/ui-primitives/tests/image-lightbox.client.spec.tsx @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render } from '@testing-library/react' -import { ImageLightbox } from '@deepseek-ai/dsh-client-ui-primitives' +import { ImageLightbox } from '../src/ImageLightbox.tsx' afterEach(cleanup) @@ -19,7 +19,12 @@ describe('ImageLightbox', () => { expect(document.activeElement).toBe(close) fireEvent.keyDown(window, { key: 'a' }) expect(onClose).not.toHaveBeenCalled() - fireEvent.keyDown(window, { key: 'Escape' }) + const outerKey = vi.fn() + document.body.addEventListener('keydown', outerKey) + try { + fireEvent.keyDown(close, { key: 'Escape' }) + expect(outerKey).not.toHaveBeenCalled() + } finally { document.body.removeEventListener('keydown', outerKey) } fireEvent.click(close) expect(onClose).toHaveBeenCalledTimes(2) view.unmount() diff --git a/packages/client/ui-primitives/tests/markdown-image-entries.client.spec.tsx b/packages/client/ui-primitives/tests/markdown-image-entries.client.spec.tsx index ba8dd1a4f2..1d7a95b692 100644 --- a/packages/client/ui-primitives/tests/markdown-image-entries.client.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown-image-entries.client.spec.tsx @@ -5,7 +5,7 @@ import { MarkdownDelegateProvider } from '../src/markdown/MarkdownDelegate.tsx' import { MarkdownText } from './markdown-test-components.tsx' import { parseGfm, parseGfmWithMath } from '../src/markdown/parse.ts' -afterEach(() => { cleanup(); vi.useRealTimers() }) +afterEach(() => { cleanup(); vi.useRealTimers(); vi.restoreAllMocks() }) const labels = { open: 'View full image', dialog: 'Image preview', close: 'Close preview', loading: 'Loading image', failed: 'Image unavailable' } const fileImages = { resolve: (path: string) => `https://example.com/api/file?path=${encodeURIComponent('/workspace/' + path)}`, labels } function mount(text: string, streaming = false) { @@ -53,7 +53,9 @@ it('loads a link preview only after hover dwell and keeps its existing sidebar a it('supports keyboard previews, Escape dismissal, and an inert failure state', () => { mount('[View comparison](graph.png)') const link = screen.getByRole('button', { name: 'View comparison' }) - fireEvent.focus(link) + // jsdom does not model keyboard input modality for :focus-visible. + vi.spyOn(link, 'matches').mockReturnValue(true) + act(() => { link.focus() }) fireEvent.error(screen.getByRole('img')) expect(screen.getByText('Image unavailable')).toBeTruthy() fireEvent.keyDown(link, { key: 'Escape' }) @@ -85,3 +87,31 @@ it.each([parseGfm, parseGfmWithMath])('recovers standalone bare-space paths with expect(JSON.stringify(parse(example))).not.toContain('"type":"image"') } }) + +it('keeps an unavailable image link navigable without showing a hover preview', () => { + const openFile = vi.fn() + render( undefined }}> + + ) + const link = screen.getByRole('button', { name: 'Image' }) + fireEvent.focus(link) + expect(screen.queryByRole('img')).toBeNull() + fireEvent.click(link) + expect(openFile).toHaveBeenCalledWith('relative.png', undefined) +}) + +it('labels empty-alt image activation and retains a localized failure description', () => { + mount('![](image.png)') + expect(screen.getByRole('button', { name: 'View full image' })).toBeTruthy() + fireEvent.error(document.querySelector('img')!) + expect(screen.getByText('Image unavailable · image.png')).toBeTruthy() + expect(screen.queryByRole('button')).toBeNull() +}) + +it.each([parseGfm, parseGfmWithMath])('uses the shared image categories for bare-space recovery', (parse) => { + for (const extension of ['avif', 'tif', 'tiff', 'heic', 'heif']) { + expect(parse(`![图](/work/test dir/photo.${extension})`).children[0]) + .toMatchObject({ children: [{ type: 'image', url: `/work/test dir/photo.${extension}` }] }) + } + expect(JSON.stringify(parse('![report](/work/test dir/report.pdf)'))).not.toContain('"type":"image"') +}) diff --git a/packages/client/ui-sidebar-documentpreview/src/client/markdown/path-images.ts b/packages/client/ui-sidebar-documentpreview/src/client/markdown/path-images.ts index 7030cc9c26..ae7d5c6991 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/markdown/path-images.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/markdown/path-images.ts @@ -9,7 +9,6 @@ import { fileMediaUrl, isAbsoluteWorkspacePath, pathPartsOf } from '@deepseek-ai * @returns an HTTP(S) file URL, or undefined for unsupported or malformed destinations. */ export function markdownImageUrl(base: string, documentPath: string | undefined, destination: string): string | undefined { - if (!base.startsWith('http:') && !base.startsWith('https:')) return undefined const suffix = destination.search(/[?#]/u) let path: string try { @@ -18,7 +17,7 @@ export function markdownImageUrl(base: string, documentPath: string | undefined, // Malformed percent escapes do not identify a local file. return undefined } - if (path.length === 0 || path.includes('\0') || /^[/\\]{2}/u.test(path)) return undefined + if (path.length === 0) return undefined const windowsDrive = /^[a-z]:[/\\]/iu.test(path) if (!windowsDrive && /^[a-z][a-z\d+.-]*:/iu.test(path)) return undefined if (!isAbsoluteWorkspacePath(path)) { diff --git a/snapshots/web/fresh-round-trip/system-prompt.expected.md b/snapshots/web/fresh-round-trip/system-prompt.expected.md index 55df661994..618ad7a41a 100644 --- a/snapshots/web/fresh-round-trip/system-prompt.expected.md +++ b/snapshots/web/fresh-round-trip/system-prompt.expected.md @@ -30,7 +30,7 @@ Use subagent in the background by default. Start independent delegations togethe Use subagent_fork in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. -Prefer showing the primary results within your final response alongside a brief explanation. Use ![Description]() when an image supports an explanation or comparison; it appears inline and opens a larger preview on click. Use [Description]() when referring to an image or listing files; image links offer a hover preview and open in the sidebar on click. Enclose Markdown file destinations in angle brackets, especially paths containing spaces. Do not call present just to list edited source files, or run commands to check whether a diff view will appear. Use present when a separate file card helps the user open the complete deliverable, including images, Office documents, spreadsheets, and slide decks. Each presented file adds a card below the reply, with preview and native-open actions. Usually select the 1-2 most important deliverables; include more when needed, but at most 4 files in a single present call. Avoid repeating results already shown inline unless the separate card adds useful access. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix. +Prefer showing the primary results within your final response alongside a brief explanation. Use ![Description]() when an image supports an explanation or comparison. Use [Description]() when referring to an image or listing files. Enclose Markdown file destinations in angle brackets, especially paths containing spaces. Do not call present just to list edited source files, or run commands to check whether a diff view will appear. Use present when a separate file card helps the user open the complete deliverable, including images, Office documents, spreadsheets, and slide decks. Each presented file adds a card below the reply, with preview and native-open actions. Usually select the 1-2 most important deliverables; include more when needed, but at most 4 files in a single present call. Avoid repeating results already shown inline unless the separate card adds useful access. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix. The DeepSeek Harness implementation checkout is at {{sourceRoot}}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself. diff --git a/snapshots/web/ptc-round/system-prompt.expected.md b/snapshots/web/ptc-round/system-prompt.expected.md index fb616a1ef9..7089411c70 100644 --- a/snapshots/web/ptc-round/system-prompt.expected.md +++ b/snapshots/web/ptc-round/system-prompt.expected.md @@ -546,7 +546,7 @@ declare const tools: { } ``` -Prefer showing the primary results within your final response alongside a brief explanation. Use ![Description]() when an image supports an explanation or comparison; it appears inline and opens a larger preview on click. Use [Description]() when referring to an image or listing files; image links offer a hover preview and open in the sidebar on click. Enclose Markdown file destinations in angle brackets, especially paths containing spaces. Do not call present just to list edited source files, or run commands to check whether a diff view will appear. Use present when a separate file card helps the user open the complete deliverable, including images, Office documents, spreadsheets, and slide decks. Each presented file adds a card below the reply, with preview and native-open actions. Usually select the 1-2 most important deliverables; include more when needed, but at most 4 files in a single present call. Avoid repeating results already shown inline unless the separate card adds useful access. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix. +Prefer showing the primary results within your final response alongside a brief explanation. Use ![Description]() when an image supports an explanation or comparison. Use [Description]() when referring to an image or listing files. Enclose Markdown file destinations in angle brackets, especially paths containing spaces. Do not call present just to list edited source files, or run commands to check whether a diff view will appear. Use present when a separate file card helps the user open the complete deliverable, including images, Office documents, spreadsheets, and slide decks. Each presented file adds a card below the reply, with preview and native-open actions. Usually select the 1-2 most important deliverables; include more when needed, but at most 4 files in a single present call. Avoid repeating results already shown inline unless the separate card adds useful access. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix. The DeepSeek Harness implementation checkout is at {{sourceRoot}}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself. From 45f5cd34b02fc20e6d6f6e78938a6faa1ef1b7ef Mon Sep 17 00:00:00 2001 From: imccyu Date: Wed, 23 Sep 2026 17:20:38 +0800 Subject: [PATCH 11/36] fix(gateway): pass explicit undefined in retention test --- packages/api/gateway/tests/gateway.client.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/api/gateway/tests/gateway.client.spec.ts b/packages/api/gateway/tests/gateway.client.spec.ts index 32f473132b..b0c7d4f390 100644 --- a/packages/api/gateway/tests/gateway.client.spec.ts +++ b/packages/api/gateway/tests/gateway.client.spec.ts @@ -2845,7 +2845,7 @@ describe('Remote stream client carrier lifecycle', () => { [Symbol.asyncIterator]: () => ({ next: () => { if (reads++ < 256) return Promise.resolve(new ReadResult()) - blocked.resolve() + blocked.resolve(undefined) return lastRead.promise }, return: async () => { @@ -2875,7 +2875,7 @@ describe('Remote stream client carrier lifecycle', () => { } finally { await client.close() await first.catch(() => undefined) - await iterator.return?.() + await iterator.return?.(undefined) } }) }) From bd2a49a2ca3ef47d8fa2e165a129140c0851402b Mon Sep 17 00:00:00 2001 From: Kaige-Gao Date: Wed, 23 Sep 2026 17:22:55 +0800 Subject: [PATCH 12/36] fix(chat): use elevated scrollbar colors for image previews --- packages/client/ui-primitives/src/HoverCard.module.css | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/client/ui-primitives/src/HoverCard.module.css b/packages/client/ui-primitives/src/HoverCard.module.css index c6a1757d86..dcd2698ad8 100644 --- a/packages/client/ui-primitives/src/HoverCard.module.css +++ b/packages/client/ui-primitives/src/HoverCard.module.css @@ -82,6 +82,8 @@ .inline { display: inline; } .media { + --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); + --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); overflow: auto; padding: 8px; border: 0; From e4234651ee810765447f291dedb1dc54e3202c4f Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 23 Sep 2026 17:52:13 +0800 Subject: [PATCH 13/36] fix(web): support spreadsheet tab gestures and readable name box --- apps/web/tests/document-preview.e2e.ts | 13 +++++++++++++ .../ui-sidebar-documentpreview/README.i18n.yaml | 4 ++-- .../client/ui-sidebar-documentpreview/README.md | 2 +- .../client/ui-sidebar-documentpreview/README.zh.md | 2 +- .../src/client/excel/ExcelBody.module.css | 9 +++++++++ 5 files changed, 26 insertions(+), 4 deletions(-) diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index 14c2728288..b55c5acfda 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -238,6 +238,16 @@ it.skipIf(MODE === 'record').each(['en-US', 'zh-CN'])('fills the spreadsheet pan expect(await selection.innerText()).toBe('A1') await expect.poll(() => excel.locator('.fortune-sheettab-scroll').count()).toBe(width === 360 ? 2 : 0) } + const gridOffset = await excel.locator('.luckysheet-scrollbar-x').evaluate(node => node.scrollLeft) + await tabScroller.hover() + await page.mouse.wheel(180, 0) + await expect.poll(() => tabScroller.evaluate(node => node.scrollLeft)).toBeGreaterThan(0) + await page.mouse.wheel(-1000, 0) + await expect.poll(() => tabScroller.evaluate(node => node.scrollLeft)).toBe(0) + expect(await activeSheet.innerText()).toBe('会议信息') + expect(await selection.innerText()).toBe('A1') + expect(await zoom.innerText()).toBe('100%') + expect(await excel.locator('.luckysheet-scrollbar-x').evaluate(node => node.scrollLeft)).toBe(gridOffset) for (const direction of ['right', 'left'] as const) { const arrow = excel.locator(`#fortune-sheettab-${direction}scroll`) for (let step = 0; step < 8; step += 1) { @@ -277,6 +287,9 @@ it.skipIf(MODE === 'record').each(['en-US', 'zh-CN'])('fills the spreadsheet pan await openPreviewFile(column, filesTab, preview, name) await excel.locator('.fortune-sheet-overlay').waitFor() await expectExcelLayout(excel) + expect(await selection.evaluate(node => ({ + text: getComputedStyle(node).color, background: getComputedStyle(node).backgroundColor, + }))).toEqual({ text: 'rgb(0, 0, 0)', background: 'rgb(255, 255, 255)' }) if (name === 'values.csv') { await excel.locator('.fortune-sheet-overlay').click({ position: { x: 70, y: 30 } }) await expect.poll(() => formula.textContent()).toBe(longCellText) diff --git a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml index 946ec07ffb..87dd80cf92 100644 --- a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml +++ b/packages/client/ui-sidebar-documentpreview/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-sidebar-documentpreview/README.md -README.md: 0571c4537f5ff98d8ec1713c297397da1941780b -README.zh.md: 949e66546b8a4f25a388178dea47f90a8d6d324c +README.md: 8861b11babfa39909ea639bb3caf0dad6f5ccfa3 +README.zh.md: bfd80bdc8f03a181a526c281fe603299ee3a1bc8 diff --git a/packages/client/ui-sidebar-documentpreview/README.md b/packages/client/ui-sidebar-documentpreview/README.md index 0571c4537f..8861b11bab 100644 --- a/packages/client/ui-sidebar-documentpreview/README.md +++ b/packages/client/ui-sidebar-documentpreview/README.md @@ -66,7 +66,7 @@ Initial reads, additional pages, and HTML/PDF/image preparation share the ongoin ## Excel preview -Open `.xlsx`, `.xls`, `.csv`, and `.tsv` directly in the browser with worksheet tabs, cell selection, copying, and a read-only formula bar. XLSX retains fonts, solid fills, borders, number formats, rich text, merged cells, row and column sizes, hidden rows/columns/sheets, and frozen headings. XLS retains saved values, formulas, number formats, merges, and available row/column metadata; fonts, borders, and frozen panes are unsupported. Workbooks display saved formula results without recalculating; missing results remain blank, and a compact formula-bar warning marks workbooks whose displayed results may be incomplete or inaccurate. The workbook viewport fills the preview pane and follows its size changes without reloading its sheets or selection. Worksheet tabs start at the left edge; overflowing tabs scroll with left/right navigation beside a separate zoom control. Selection statistics and inactive worksheet menu arrows are hidden. Frozen headings remain fixed while scrolling, without freeze dividers or drag handles. Spreadsheet preview does not call the Office conversion service. +Open `.xlsx`, `.xls`, `.csv`, and `.tsv` directly in the browser with worksheet tabs, cell selection, copying, and a read-only formula bar. XLSX retains fonts, solid fills, borders, number formats, rich text, merged cells, row and column sizes, hidden rows/columns/sheets, and frozen headings. XLS retains saved values, formulas, number formats, merges, and available row/column metadata; fonts, borders, and frozen panes are unsupported. Workbooks display saved formula results without recalculating; missing results remain blank, and a compact formula-bar warning marks workbooks whose displayed results may be incomplete or inaccurate. The workbook viewport fills the preview pane and follows its size changes without reloading its sheets or selection. Worksheet tabs start at the left edge; overflowing tabs scroll with horizontal trackpad gestures or left/right navigation beside a separate zoom control. Spreadsheet controls retain dark text on their light background in both app themes. Selection statistics and inactive worksheet menu arrows are hidden. Frozen headings remain fixed while scrolling, without freeze dividers or drag handles. Spreadsheet preview does not call the Office conversion service. XLSX preview omits DrawingML parts from an in-memory copy before parsing and ignores worksheet drawing references. The original file and worksheet XML remain unchanged. A notice above the table lists detected charts, images, shapes, and conditional formatting that are not displayed, and recommends opening the workbook in a system application. Files without these detected features have no notice; formula warnings remain separate. diff --git a/packages/client/ui-sidebar-documentpreview/README.zh.md b/packages/client/ui-sidebar-documentpreview/README.zh.md index 949e66546b..bfd80bdc8f 100644 --- a/packages/client/ui-sidebar-documentpreview/README.zh.md +++ b/packages/client/ui-sidebar-documentpreview/README.zh.md @@ -66,7 +66,7 @@ PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `` 静态 ## Excel 预览 -直接在浏览器中打开 `.xlsx`、`.xls`、`.csv` 和 `.tsv`,支持工作表标签、单元格选择、复制和只读公式栏。XLSX 保留字体、纯色填充、边框、数字格式、富文本、合并单元格、行列尺寸、隐藏行列及工作表,以及冻结标题。XLS 保留已保存的值、公式、数字格式、合并及可用的行列元数据;不支持字体、边框和冻结窗格。工作簿显示已保存的公式结果而不重新计算;缺少的结果保持为空,公式栏旁的紧凑提示会标记显示结果可能不完整或不准确的工作簿。工作簿视图填满预览面板,并随面板尺寸变化自动调整,无需重新加载工作表或选择状态。工作表标签从左侧边缘开始排列;标签溢出时可用左右按钮滚动,缩放控件独立占位。隐藏选区统计栏及无效的工作表菜单箭头。冻结标题在滚动时保持固定,不显示冻结分隔线和拖拽条。表格预览不调用 Office 转换服务。 +直接在浏览器中打开 `.xlsx`、`.xls`、`.csv` 和 `.tsv`,支持工作表标签、单元格选择、复制和只读公式栏。XLSX 保留字体、纯色填充、边框、数字格式、富文本、合并单元格、行列尺寸、隐藏行列及工作表,以及冻结标题。XLS 保留已保存的值、公式、数字格式、合并及可用的行列元数据;不支持字体、边框和冻结窗格。工作簿显示已保存的公式结果而不重新计算;缺少的结果保持为空,公式栏旁的紧凑提示会标记显示结果可能不完整或不准确的工作簿。工作簿视图填满预览面板,并随面板尺寸变化自动调整,无需重新加载工作表或选择状态。工作表标签从左侧边缘开始排列;标签溢出时可用触控板横向手势或左右按钮滚动,缩放控件独立占位。表格控件在应用的明暗两种主题下均保持浅色背景和深色文字。隐藏选区统计栏及无效的工作表菜单箭头。冻结标题在滚动时保持固定,不显示冻结分隔线和拖拽条。表格预览不调用 Office 转换服务。 XLSX 预览在解析前从内存副本中移除 DrawingML 部件,并忽略工作表的绘图引用。原文件和工作表 XML 保持不变。表格上方的提示列出实际检测到但未展示的图表、图片、形状和条件格式,并建议使用系统应用打开工作簿。未检测到这些内容的文件不显示此提示;公式提示保持独立。 diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/ExcelBody.module.css b/packages/client/ui-sidebar-documentpreview/src/client/excel/ExcelBody.module.css index cb4408cbd8..13c28ffbaa 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/excel/ExcelBody.module.css +++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/ExcelBody.module.css @@ -14,6 +14,8 @@ min-height: 0; min-width: 0; color-scheme: light; + /* FortuneSheet's white controls need light-scheme text even in a dark app. */ + color: CanvasText; } /* Hide editing handles and redundant controls within the read-only preview. */ @@ -43,6 +45,13 @@ max-width: 100%; } +.workbook :global(.fortune-sheettab-container .fortune-sheettab-container-c) { + overflow-x: auto; + overflow-y: hidden; + overscroll-behavior-x: contain; + scrollbar-width: none; +} + .workbook :global(.fortune-sheettab-scroll), .workbook :global(.fortune-sheet-area-right) { flex: none; From c5e0a407ca63192f1fc690c652cde6312ca80141 Mon Sep 17 00:00:00 2001 From: mektpoy Date: Wed, 23 Sep 2026 18:05:08 +0800 Subject: [PATCH 14/36] test(web): cover the file menu when no application is the OS default The reported defect is the branch where the Shell lists associated applications but marks none as the default: the split button promoted "Show file location" to the primary action and hid the applications behind the menu. Record that state in the keyless document-preview scenario. The new golden pins the primary action to the opening control, and the gesture log asserts the main button opens the file while the menu's marked default launches the first registered application. --- apps/web/tests/document-preview.e2e.ts | 40 +++++++++++++++++-- .../applications-no-default.expected.md | 2 + 2 files changed, 38 insertions(+), 4 deletions(-) create mode 100644 snapshots/web/document-preview/applications-no-default.expected.md diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index af48f48e2c..9f5a3d0c1a 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -164,8 +164,13 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () let outsideRoot: string | undefined let nativeRoot: string | undefined let openLog = '' + let launchLog = '' + let appsCatalog = '' const opened = async (): Promise> => (await readFile(openLog, 'utf8')).split('\n').filter(Boolean).map(line => JSON.parse(line) as { path: string; action: 'open' | 'reveal' | 'application' }) + /** The application each `open -a` gesture named; the opened log keeps only the file path. */ + const launched = async (): Promise> => + (await readFile(launchLog, 'utf8')).split('\n').filter(Boolean).map(line => JSON.parse(line) as { app: string; path: string }) beforeAll(async () => { outsideRoot = await mkdtemp(join(tmpdir(), 'dsh-preview-outside-')) @@ -173,20 +178,24 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () // Exercise the built Host through its actual OS command, replacing only the desktop application. nativeRoot = await mkdtemp(join(tmpdir(), 'dsh-preview-native-')) openLog = join(nativeRoot, 'opened.jsonl') + launchLog = join(nativeRoot, 'launched.jsonl') + appsCatalog = join(nativeRoot, 'applications.json') await writeFile(openLog, '') + await writeFile(launchLog, '') 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] === '-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'); +if (process.argv[2] === '-a') fs.appendFileSync(${JSON.stringify(launchLog)}, JSON.stringify({ app: process.argv[3], path: process.argv[4] }) + '\\n'); `, { mode: 0o700 }) if (process.platform === 'darwin') { - const apps = [ + await writeFile(appsCatalog, JSON.stringify([ { 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 }) + ])) + await writeFile(join(nativeRoot, 'osascript'), `#!/usr/bin/env node\nprocess.stdout.write(require('node:fs').readFileSync(${JSON.stringify(appsCatalog)}, 'utf8'));\n`, { mode: 0o700 }) } if (process.platform === 'linux') { const data = join(nativeRoot, 'data') @@ -1075,6 +1084,29 @@ else process.exit(1); await headerOpen.click() await expect.poll(async () => (await opened()).length).toBe(4) expect((await opened())[3]).toEqual({ path: clip, action: 'open' }) + // The reported defect: the Shell lists applications but marks none as the OS default. + await writeFile(appsCatalog, JSON.stringify([ + { id: '/Applications/Test Player.app', name: 'Test Player', default: false, icon: `data:image/png;base64,${TINY_PNG.toString('base64')}` }, + { id: '/Applications/Other Player.app', name: 'Other Player', default: false, icon: null }, + ])) + await prominent.getByRole('button', { name: 'More ways to open' }).click() + await page.getByRole('menuitem', { name: 'Test Player (default)', exact: true }).waitFor() + await page.keyboard.press('Escape') + expect(await emptyOpen.innerText()).toBe('Open') + expect(await headerOpen.getAttribute('aria-label')).toBe('Open in Test Player') + await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'applications-no-default.expected.md'), await prominent.ariaSnapshot(), MODE) + const gesturesBefore = (await opened()).length + await emptyOpen.click() + await expect.poll(async () => (await opened()).length).toBe(gesturesBefore + 1) + // Without an OS-marked default the main action still opens the file instead of revealing it. + expect((await opened()).at(-1)).toEqual({ path: clip, action: 'open' }) + const launchesBefore = (await launched()).length + await prominent.getByRole('button', { name: 'More ways to open' }).click() + await page.getByRole('menuitem', { name: 'Test Player (default)', exact: true }).click() + await expect.poll(async () => (await launched()).length).toBe(launchesBefore + 1) + expect((await launched()).at(-1)?.app).toBe('/Applications/Test Player.app') + await expect.poll(async () => (await opened()).length).toBe(gesturesBefore + 2) + expect((await opened()).at(-1)).toEqual({ path: clip, action: 'application' }) } // Gesture facts stay out of the golden: the stub does not run on Windows. expect(await page.getByRole('alert').count()).toBe(0) @@ -1082,7 +1114,7 @@ else process.exit(1); expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) await compareOrRefreshGolden(EXPECTED, sections.join('\n\n'), MODE) - await assertFixtureInventory(SNAPSHOT_DIR, ['document.expected.md', 'applications.expected.md', 'paging.patch.yml']) + await assertFixtureInventory(SNAPSHOT_DIR, ['document.expected.md', 'applications.expected.md', 'applications-no-default.expected.md', 'paging.patch.yml']) }) }) diff --git a/snapshots/web/document-preview/applications-no-default.expected.md b/snapshots/web/document-preview/applications-no-default.expected.md new file mode 100644 index 0000000000..bfe4d5331c --- /dev/null +++ b/snapshots/web/document-preview/applications-no-default.expected.md @@ -0,0 +1,2 @@ +- button "Open" +- button "More ways to open" From 4aa207c5fec7762b839fc627993733706dee96c3 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 23 Sep 2026 18:06:52 +0800 Subject: [PATCH 15/36] feat(preset): add plugin practices reference to creator skill (#5025) * feat(preset): add creator-mode plugin practices skill Creator mode mounts packages/preset/agent-preset/skills. The new cordis-plugin-practices skill states Harness-specific principles and concrete rules for plugins that survive upgrades and avoid polling or rescanning session logs: waterfall delegation, restrict/guard over assemble, agent.ctx ownership, no new session event types from profile plugins, session projections, and slot-based UI. * refactor(preset): fold plugin practices into cordis-plugin-development A separate skill competed with cordis-plugin-development for the same trigger. The practices now live in references/practices.md, read from procedure step 2 before choosing an extension point. * feat(preset): require Harness components and tokens for plugin pages A creator-built management page served as an iframe with its own stylesheet could not match the Harness design. The practices reference now requires slot-rendered React pages built from ui-primitives, theme tokens from Theme inspection, and light/dark checks in the real slot. * feat(preset): make host UI consistency a principle and a verification step Adds principle 6: the rendering surface decides whether a plugin can match the Harness UI, so it is chosen before any view. Step 4 of cordis-plugin-development now verifies primitives, token-only styles, and light/dark rendering beside a comparable host page. * feat(preset): prefer theme tokens over pre-stable ui-primitives ui-primitives is a pre-stable platform module; creator plugins are plain JS without type checks, and a throwing component blanks the slot entry. The practices reference now treats tokens as the default way to match the host, limits primitives to behavior that is costly to reproduce, and tells the agent how to detect a slot entry crash after an upgrade. * feat(preset): forbid runtime imports of Harness Client packages in plugins Creator plugins must assume ui-primitives and other Client packages break without notice. The practices reference now has plugins copy markup, CSS, and behavior from primitives, or inspect the rendered host control, and keep --dsw-alias-* tokens as the only shared styling dependency. * fix(preset): correct plugin practices against runtime behavior - agent.ctx registrations need a plugin-owned disposer too; plugin unload does not remove them. - Timers start work with followup(); inject() does not wake the agent. - inject() logs agent/inbox/spliced at call time; agent/request cannot change messages. - Guards are synchronous; awaited decisions return ask from tools/pre-execute. - Chat node renderers are keyed by kind; slot inject reinstalls on declaration return. skills.spec now keeps whole-file cordis-plugin-development references below the pruner threshold as the read tool renders them. --- .../skills/cordis-plugin-development/SKILL.md | 5 ++- .../references/practices.md | 38 +++++++++++++++++++ .../references/ui-plugin.md | 2 +- .../preset/agent-preset/tests/skills.spec.ts | 10 +++++ 4 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 packages/preset/agent-preset/skills/cordis-plugin-development/references/practices.md diff --git a/packages/preset/agent-preset/skills/cordis-plugin-development/SKILL.md b/packages/preset/agent-preset/skills/cordis-plugin-development/SKILL.md index ffb57b0f7b..b66474a4ca 100644 --- a/packages/preset/agent-preset/skills/cordis-plugin-development/SKILL.md +++ b/packages/preset/agent-preset/skills/cordis-plugin-development/SKILL.md @@ -10,9 +10,9 @@ Use ordinary workspace files to author a bundle, then `plugin_manager` with `act ## Deliver a working plugin first 1. Resolve the requested result and destination. An unspecified visual destination is the current Harness Web UI; a standalone image or HTML file does not complete such a request. Choose reasonable visual details and implement a small first version; install it before visual refinement. -2. Discover only the APIs needed for that version: `cordis_inspect_list`, then targeted `cordis_inspect_query` calls. For UI, query Client `Slots.listSubTree` and the selected slot's registration options and props. Once the chosen slot and registration API are known, write the plugin. +2. Discover only the APIs needed for that version: `cordis_inspect_list`, then targeted `cordis_inspect_query` calls. For UI, query Client `Slots.listSubTree` and the selected slot's registration options and props. For anything beyond a static decoration, such as tool policy, agent context, session-derived state, or Chat rows, read `references/practices.md` before choosing the extension point. Once the chosen slot and registration API are known, write the plugin. 3. Read the matching template under `templates/` with the file-read tool and write its copies into one workspace directory, or write the installable package, patch, and required Host/Client files there yourself. Check JavaScript syntax and the manifest, then install it. Before that first installation, do not create preview HTML, mock shells, design variants, screenshot scripts, or rasterizer tooling. Use the installed plugin itself as the first preview. -4. Read the installation result. After `application: applied`, exercise the capability or inspect the live Client registration. Use the connected page for visual verification when browser control is available. State any verification limitation explicitly; installation and slot registration alone do not establish what the user can see. +4. Read the installation result. After `application: applied`, exercise the capability or inspect the live Client registration. Use the connected page for visual verification when browser control is available. For a page or panel, also verify design consistency: styles use only theme tokens, the plugin imports no Harness Client package such as `@deepseek-ai/dsh-client-ui-primitives`, the console shows no slot entry crash, and the view reads correctly in light and dark themes beside a comparable host page. State any verification limitation explicitly; installation and slot registration alone do not establish what the user can see. 5. Fix observed defects in the same plugin. When the requested result works, finish with its location and verification status. Do not continue speculative visual variants, optional features, or a new mock preview. Close any task list you created. ## Knowledge sources, in order @@ -36,3 +36,4 @@ The files below live in this skill's base directory, which the `skill` tool repo | UI plugin starting point, four files | `templates/decoration/package.json`, `templates/decoration/cordis.patch.yml`, `templates/decoration/index.js`, `templates/decoration/client.js` | | MCP bundle starting point, two files | `templates/mcp/package.json`, `templates/mcp/cordis.patch.yml` | | Loader patch dialect and the list of installable plugin packages | the `cordis-composition-reference` skill | +| Choosing extension points, contexts, and state mechanisms for upgrade stability and performance | `references/practices.md` | diff --git a/packages/preset/agent-preset/skills/cordis-plugin-development/references/practices.md b/packages/preset/agent-preset/skills/cordis-plugin-development/references/practices.md new file mode 100644 index 0000000000..9d7a393457 --- /dev/null +++ b/packages/preset/agent-preset/skills/cordis-plugin-development/references/practices.md @@ -0,0 +1,38 @@ +# Harness plugin practices + +These rules follow from Harness mechanisms. Confirm every Service method, Event name, and dispatch mode named here with `cordis_inspect_query` (`Service`, `Event`) before relying on it; the installed Harness is the authority. + +## Principles + +1. **The session log is the only source of truth.** Anything the model sees must be reconstructable from committed session events; fork, resume, and replay derive from the log. Plugin memory is a derived cache. +2. **Registrations are effects owned by a context.** Plugin unload, agent disposal, slot collapse, and profile patches remove what was registered on the corresponding context. Choose the owning context. A registration on another context, such as `agent.ctx`, has two owners: keep its disposer in your plugin's own effect too, so either teardown removes it. +3. **The framework drives; the plugin computes.** Session projections, Conversation assembly, and slot rendering subscribe, cache, and publish for you. A plugin that subscribes, rescans, or writes DOM itself bypasses that incremental machinery. +4. **Extension points are shared; use the weakest mechanism that suffices.** From weakest to strongest: `ctx.tools.restrict()` can only remove tools; `ctx.tools.guard()` can only deny; waterfall listeners can rewrite and depend on registration order; `system-prompt/assemble` replaces the whole assembly. The stronger the mechanism, the more of other plugins' contributions you must preserve. +5. **Other plugins and other Harness versions read your data.** Unknown event types, older readers, and older cached state meet what you write; declare compatibility with the envelope fields and versions the Harness provides. +6. **Plugin UI is part of the Harness UI.** Users see one application, so a plugin uses the host's theme tokens, locale, and layout patterns, and matches the look and behavior of host components. Whether that is possible is decided by where the UI renders, so choose the rendering surface before writing any view; later styling inside the wrong surface cannot recover consistency. + +## Stability + +- A waterfall listener (`agent/pre-step`, `agent/request`, `llm/stream`, `tools/pre-execute`, `tools/execute`, `tools/post-execute`) that does not own the decision must return `next()`. When rewriting an `agent/pre-step` decision, spread it (`{ ...decision, messages }`) so fields such as `startsRequestSeries` survive. +- A denial that must hold regardless of order is a `ctx.tools.guard()`; a guard is synchronous, so a decision that must await something, such as asking the user, returns `ask` from `tools/pre-execute`. Hiding tools from one agent is `ctx.tools.restrict()` on that agent's context; it keeps schema presentation, lookup, and execution aligned. Observe final tool outcomes on `tools/result`; use `tools/post-execute` only to transform a result. Do not listen to `system-prompt/assemble` to add or remove tools or text. +- Add prompt text with `ctx.systemPrompt.section()`. Add per-agent context with `agent.inject()`; it is logged as `agent/inbox/spliced` at call time and enters the next admitted step. `agent/request` listeners cannot change request messages. +- Register per-agent behavior on `agent.ctx`, obtained in an `agent/created` listener, so it is removed when that agent is disposed. Wrap it in one `agent.ctx.effect()` and also keep that disposer, keyed by agent, in your plugin's own effect; unloading the plugin does not dispose `agent.ctx` registrations by itself. +- Put optional services in `inject` or `ctx.inject([...], ...)` so the plugin stays inactive in profiles without them instead of throwing. +- Do not append session events with a new `type`. Readers accept an unknown stored event only when its envelope carries `ignorable: true`, and live `Session.append()` cannot set that marker, so the Session would refuse to reopen. Derive state from existing events, or keep plugin-owned data in a storage service found through inspection. +- Put tunable values in the plugin's `Config` so users change them in `cordis.patch.yml`; the user's patch layer survives upgrades. + +## Performance + +- Keep per-session state derived from the log in a `ctx.sessionProjections` unit instead of subscribing to `session/event` and rescanning `session.events`. `apply(state, event)` is pure and synchronous and returns the same reference for events it ignores, so unchanged state costs nothing downstream. Read with `stateOf()`. A `view()` returns the same reference when its value is unchanged, which suppresses publication. +- Keep projection state plain JSON and bump `stateVersion` when fields or fold semantics change. The projection cache then checkpoints it, cold reads replay only the tail, and stale checkpoints are discarded. +- Wait on durable events such as `turn/end`, `assistant/message`, or `tool/result`; render live tokens from `agent/assistant-stream`. Do not poll `agent/status`. `whenIdle()` does not mean one follow-up finished: several inputs can share one running interval. +- A timer that starts work calls `agent.followup()`, which wakes the agent; `agent.inject()` does not wake it, so injected context can wait in the inbox until other input arrives. Clear the timer in the owning effect. + +## UI + +- Render plugin pages as React components in a slot. Do not serve an HTML page from the Host and embed it in an iframe: an iframe document does not receive the host's theme tokens, light/dark switching, or `ctx.locale`. +- Style with the theme tokens that `cordis_inspect_query` `Theme` lists (`--dsw-alias-*`); literal colors are for artwork only. Tokens are the lowest-risk way to match the host: a renamed token degrades appearance but never breaks rendering. Copy spacing, type sizes, and row patterns from an existing host page of the same kind; the Plugin Manager page is the reference for management lists. +- Do not `require('@deepseek-ai/dsh-client-ui-primitives')` or load any other Harness Client package as a module; `dsh.client.inject` entries only order activation and stay allowed. They change without notice, a plain-JS plugin has no type check, and a throwing component blanks your slot entry (console: `slot entry crashed in ''`). Write your own controls and match the host instead: copy markup, CSS, and behavior from the primitive into the plugin (`src/*.tsx` and `*.module.css` in a DSH source checkout, or the installed package's `lib/index.js` and `lib/**/*.css`), or inspect the rendered host control in the connected page. Rename copied classes under your plugin's prefix, keep only `--dsw-alias-*` token references, and keep the behavior that users rely on, such as Modal focus and Escape handling, `role="switch"` with `aria-checked`, and Tooltip placement. Tokens then remain the only shared styling dependency. +- Contribute through slots: `ctx.slots.inject(ownerKey, () => ctx.slots.register(...))`. The callback's registrations are disposed when the owning declaration collapses and reinstalled when it returns. Read session data through the slot props' selector hooks, subscribing to the smallest slice. Do not write DOM outside your component or append to `document.body`. +- Add a Chat row by registering an event definition with `ctx.uiConversation.events.register()` and its view in the `conversation.chat.node` slot under the definition's `kind`, which is the renderer key; the Conversation layer owns paging, Turn and Step placement, and incremental assembly. +- When the Client needs a value derived from a session, declare `wire.view` on the Host projection. The value reaches the Client already computed; the Client does not fold session events itself. diff --git a/packages/preset/agent-preset/skills/cordis-plugin-development/references/ui-plugin.md b/packages/preset/agent-preset/skills/cordis-plugin-development/references/ui-plugin.md index b30350a4b2..d647923d75 100644 --- a/packages/preset/agent-preset/skills/cordis-plugin-development/references/ui-plugin.md +++ b/packages/preset/agent-preset/skills/cordis-plugin-development/references/ui-plugin.md @@ -10,4 +10,4 @@ The browser artifact registers a lazy factory whose id equals the package name. `templates/decoration/client.js` registers a lazy factory into `conversation.composer.dock` through `ctx.slots.inject` and `ctx.slots.register`; follow the selected slot's props and options from `Slots.listSubTree` when you change the slot. -Keep factories free of side effects. Register styles, timers, listeners and other resources inside `apply` with `ctx.effect`/`ctx.on` and return their cleanup functions. Component-local styles can render as React elements so unmounting removes them. Verify disposal for resources you add. Inherit the host theme for containers and controls; artwork may use its own colors. Route visible UI text through the Client locale service. Do not replace the app root or append a second application to `document.body`. Do not read another plugin's DOM, stylesheet, or component source to estimate placement; choose a slot that already allocates space. +Keep factories free of side effects. Register styles, timers, listeners and other resources inside `apply` with `ctx.effect`/`ctx.on` and return their cleanup functions. Component-local styles can render as React elements so unmounting removes them. Verify disposal for resources you add. Inherit the host theme for containers and controls; artwork may use its own colors. A page or panel beyond a decoration follows the UI rules in `references/practices.md`. Route visible UI text through the Client locale service. Do not replace the app root or append a second application to `document.body`. Do not read another plugin's DOM, stylesheet, or component source to estimate placement; choose a slot that already allocates space. diff --git a/packages/preset/agent-preset/tests/skills.spec.ts b/packages/preset/agent-preset/tests/skills.spec.ts index d55e6f2e70..1e93f0b564 100644 --- a/packages/preset/agent-preset/tests/skills.spec.ts +++ b/packages/preset/agent-preset/tests/skills.spec.ts @@ -46,6 +46,16 @@ describe('the shipped creator skills', () => { } }) + it('keep whole-file plugin references below the pruner threshold with read line numbers', () => { + const references = join(skills, 'cordis-plugin-development', 'references') + for (const file of readdirSync(references)) { + const lines = readFileSync(join(references, file), 'utf8').split('\n') + // The read tool renders each line as `${number}: ${text}`. + const numbered = lines.map((text, index) => `${String(index + 1)}: ${text}`).join('\n') + expect(codePointLength(numbered), file).toBeLessThan(PRUNER_THRESHOLD_CHARS) + } + }) + it('ship templates whose manifest, patch, and JavaScript parse', () => { const templates = join(skills, 'cordis-plugin-development', 'templates') for (const name of readdirSync(templates)) { From e0e8327992b0a73ee6aa4ffd12f8f488e99fe5ea Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 23 Sep 2026 18:12:21 +0800 Subject: [PATCH 16/36] fix(preview): redraw PDF pages after settled zoom --- apps/web/tests/document-preview.e2e.ts | 46 ++++++++- apps/web/tests/expected/office-font-notice.md | 2 + .../README.i18n.yaml | 4 +- .../ui-sidebar-documentpreview/README.md | 4 +- .../ui-sidebar-documentpreview/README.zh.md | 4 +- .../src/client/pdf/document.ts | 2 +- .../src/client/pdf/pdf.tsx | 77 ++++++++++----- .../src/client/zoom/ZoomViewport.tsx | 5 +- .../tests/pdf-body.client.spec.tsx | 96 ++++++++++++++++++- .../tests/zoom-viewport.client.spec.tsx | 6 +- .../web/document-preview/document.expected.md | 1 + 11 files changed, 213 insertions(+), 34 deletions(-) diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index 14c2728288..d8b0957d8e 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -77,6 +77,18 @@ async function canvasColor(canvas: Locator): Promise { }) } +/** Wait for device resolution, subject to the page bitmap allocation limit. */ +async function expectPdfResolution(canvas: Locator): Promise { + await expect.poll(() => canvas.evaluate((node) => { + const bitmap = node as HTMLCanvasElement + const width = Number.parseFloat(bitmap.style.getPropertyValue('--pdf-page-width')) + const height = Number.parseFloat(bitmap.style.getPropertyValue('--pdf-page-height')) + const expected = Math.min(bitmap.getBoundingClientRect().width * window.devicePixelRatio, + Math.sqrt(16_777_216 * width / height)) + return Math.abs(bitmap.width - expected) + })).toBeLessThanOrEqual(1) +} + /** Select a workspace file through the Files tab and wait for its preview identity. */ async function openPreviewFile(column: Locator, filesTab: Locator, preview: Locator, name: string): Promise { await filesTab.click() @@ -808,10 +820,12 @@ else process.exit(1); await pdfZoom.click() await page.getByRole('menuitem', { name: '150%', exact: true }).click() await expect.poll(async () => (await canvas.boundingBox())!.width / pdfIntrinsicWidth).toBeCloseTo(1.5, 1) + await expectPdfResolution(canvas) pdfZoom = await revealDocumentZoom(page, preview) await pdfZoom.click() await page.getByRole('menuitem', { name: 'Fit width', exact: true }).click() await expect.poll(async () => (await canvas.boundingBox())!.width).toBeCloseTo(pdfWidth, 0) + await expectPdfResolution(canvas) expect(await preview.locator('[data-pdf-page]').count()).toBe(2) await expect.poll(() => canvasColor(canvas), { timeout: 30_000 }).toBe('red') const firstColor = await canvasColor(canvas) @@ -846,6 +860,7 @@ else process.exit(1); `- Continuous pages: ${await preview.locator('[data-pdf-page]').count()}`, '- Zoom reveal: hidden -> bottom hover -> delayed hidden', '- Zoom modes: fit width -> 100% -> 150% -> fit width', + '- Settled zoom redraws the page at device resolution', `- Horizontal overflow: ${String(await body.evaluate(node => node.scrollWidth > node.clientWidth))}`, `- Canvas fills: ${[firstColor, secondColor, restoredColor].join(' -> ')}`, `- Same tab: ${String(await pdfTab.getAttribute('data-dockkit-tab') === pdfTabId)}`, @@ -1354,7 +1369,8 @@ describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => { ], }) browser = await chromium.launch() - page = await newEnglishPage(browser) + page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, deviceScaleFactor: 2, + locale: 'en-US', timezoneId: 'Asia/Shanghai' }) const tripwire = watchConsole(page) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await connectFreshWorkspace(page, scaffold.workspaceCwd) @@ -1430,6 +1446,7 @@ describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => { await page.getByRole('menuitem', { name: '150%', exact: true }).click() await expect.poll(() => zoomMenu.innerText()).toBe('150%') await expect.poll(async () => (await canvas.boundingBox())!.width / intrinsicWidth).toBeCloseTo(1.5, 1) + await expectPdfResolution(canvas) const zoomScrollport = preview.locator('[data-document-zoom-scrollport]') await zoomScrollport.evaluate((node) => { const bounds = node.getBoundingClientRect() @@ -1438,10 +1455,31 @@ describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => { }) await expect.poll(() => zoomMenu.innerText()).toBe('166%') await expect.poll(async () => (await canvas.boundingBox())!.width / intrinsicWidth).toBeCloseTo(1.66, 1) + await expectPdfResolution(canvas) + await zoomScrollport.evaluate((node) => { node.scrollTop = 0; node.scrollLeft = 0 }) + await copyPdfText(page, preview, '中文文档') + await successShot(page, 'office-zoom-redrawn') await zoomMenu.click() await page.getByRole('menuitem', { name: 'Fit width', exact: true }).click() await expect.poll(() => zoomMenu.innerText()).toBe(fitPercent) + await expectPdfResolution(canvas) await zoomScrollport.evaluate((node) => { node.scrollTop = 0; node.scrollLeft = 0 }) + await zoomScrollport.evaluate(async (node) => { + const bounds = node.getBoundingClientRect() + for (let step = 0; step < 8; step++) { + node.dispatchEvent(new WheelEvent('wheel', { bubbles: true, cancelable: true, ctrlKey: true, + deltaY: -40, clientX: bounds.left + bounds.width / 2, clientY: bounds.top + bounds.height / 2 })) + await new Promise(resolve => requestAnimationFrame(() => { resolve() })) + } + }) + await expect.poll(() => zoomMenu.innerText()).toBe('400%') + expect(await zoomScrollport.evaluate(node => node.scrollLeft > node.clientWidth)).toBe(true) + await expectPdfResolution(canvas) + await zoomScrollport.evaluate((node) => { node.scrollTop = 0; node.scrollLeft = 0 }) + await successShot(page, 'office-pinch-400') + await zoomMenu.click() + await page.getByRole('menuitem', { name: 'Fit width', exact: true }).click() + await expectPdfResolution(canvas) expect(convert).toHaveBeenCalledTimes(1) await preview.getByRole('button', { name: 'Read the file again', exact: true }).click() await canvas.waitFor({ state: 'visible' }) @@ -1494,6 +1532,8 @@ describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => { '- Escape restores focus to the warning: true', '- Closing details preserves the warning and document position: true', '- Fit width, presets, and pinch resize the Office PDF continuously: true', + '- Settled zoom redraws the Office PDF at device resolution: true', + '- Continuous pinch to 400% redraws the page after horizontal panning: true', '- Pinch updates the displayed percentage during the gesture: 166%', `- Document top inset: ${topInset}px`, ].join('\n'), MODE) @@ -1502,6 +1542,10 @@ describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => { await openPreviewFile(column, filesTab, preview, `chinese.${extension}`) await preview.getByRole('img', { name: 'PDF page 1', exact: true }).waitFor({ state: 'visible', timeout: 60_000 }) await expect.poll(async () => (await preview.locator('[data-pdf-text]').allTextContents()).join(''), { timeout: 30_000 }).toContain('中文文档') + const officeZoom = await revealDocumentZoom(page, preview) + await officeZoom.click() + await page.getByRole('menuitem', { name: '150%', exact: true }).click() + await expectPdfResolution(canvas) if (['doc', 'ppt'].includes(extension)) expect(await warning.count()).toBe(0) await successShot(page, `office-${extension}`) } diff --git a/apps/web/tests/expected/office-font-notice.md b/apps/web/tests/expected/office-font-notice.md index 7e2deb69b1..6d9ba31361 100644 --- a/apps/web/tests/expected/office-font-notice.md +++ b/apps/web/tests/expected/office-font-notice.md @@ -7,5 +7,7 @@ - Escape restores focus to the warning: true - Closing details preserves the warning and document position: true - Fit width, presets, and pinch resize the Office PDF continuously: true +- Settled zoom redraws the Office PDF at device resolution: true +- Continuous pinch to 400% redraws the page after horizontal panning: true - Pinch updates the displayed percentage during the gesture: 166% - Document top inset: 0px diff --git a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml index 946ec07ffb..17afc4e007 100644 --- a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml +++ b/packages/client/ui-sidebar-documentpreview/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-sidebar-documentpreview/README.md -README.md: 0571c4537f5ff98d8ec1713c297397da1941780b -README.zh.md: 949e66546b8a4f25a388178dea47f90a8d6d324c +README.md: 5249bb4cbbc0360e8afac96cd86cdac339addfc5 +README.zh.md: 1b15e805a285770befb5c59e602fdd2aed6fbca5 diff --git a/packages/client/ui-sidebar-documentpreview/README.md b/packages/client/ui-sidebar-documentpreview/README.md index 0571c4537f..5249bb4cbb 100644 --- a/packages/client/ui-sidebar-documentpreview/README.md +++ b/packages/client/ui-sidebar-documentpreview/README.md @@ -59,7 +59,7 @@ When Developer tools is enabled, HTML fills the body edge to edge in a Blob ifra PNG, JPEG, GIF, WebP, BMP, ICO, and SVG render through Blob URLs in an `` static-image context, rounded inside a 12px inset. Images default to fit width without enlarging content smaller than the pane; 100% uses the image's intrinsic CSS-pixel width. The shared zoom controls can produce horizontal and vertical scrolling, but do not provide drag-to-pan. Zoom changes retain the same `` and Blob URL, so animated images continue playing. Raster formats can soften above their intrinsic size, while SVG remains on the browser's vector rendering path. SVG markup never enters the application DOM or an iframe, so its scripts cannot execute or reach the parent page. Replacing or unmounting the image revokes its Blob URL. -Shared copy comes from `sidebarDocumentPreview`; each builtin renderer owns its localized labels. PDF and converted Office previews use a graphite background in light mode and a matte-black background in dark mode, with subtle page shadows and original document colors. PDF and image previews share a floating control with **Fit width**, 25%, 50%, 100%, 150%, and 200% choices plus 25% incremental zoom across the 25%–400% fixed range. Fit width is the default and tracks pane resizing; 100% means the PDF's 96-DPI page size or the image's intrinsic CSS-pixel size. On fine pointers, the control starts hidden, slides up when the pointer enters the bottom 72 CSS pixels, and slides down after a 420 ms leave delay; hover, keyboard focus, an open menu, and an active gesture keep it visible, while devices without hover keep it visible continuously. Chromium maps a macOS trackpad pinch to the same live, pointer-anchored path as Ctrl+wheel; the control displays the gesture percentage, and the settled value survives body remounts until the tab closes. HTML previews do not expose zoom controls. +Shared copy comes from `sidebarDocumentPreview`; each builtin renderer owns its localized labels. PDF and converted Office previews use a graphite background in light mode and a matte-black background in dark mode, with subtle page shadows and original document colors. PDF and image previews share a floating control with **Fit width**, 25%, 50%, 100%, 150%, and 200% choices plus 25% incremental zoom across the 25%–400% fixed range. Fit width is the default and tracks pane resizing; 100% means the PDF's 96-DPI page size or the image's intrinsic CSS-pixel size. On fine pointers, the control starts hidden, slides up when the pointer enters the bottom 72 CSS pixels, and slides down after a 420 ms leave delay; hover, keyboard focus, an open menu, and an active gesture keep it visible, while devices without hover keep it visible continuously. Chromium maps a macOS trackpad pinch to the same live, pointer-anchored path as Ctrl+wheel; the control displays the gesture percentage, and the settled value survives body remounts until the tab closes. HTML previews do not expose zoom controls. After zoom settles or fit width changes, nearby PDF pages redraw at the resolved scale multiplied by the device pixel ratio. The previous bitmap and selectable text stay visible until the replacement bitmap is ready; offscreen pages refresh when they approach the viewport. Initial reads, additional pages, and HTML/PDF/image preparation share the ongoing `StateDot` loader, which exposes its label to assistive technology and respects reduced-motion preferences; every wait before content exists centres the loader in the pane, so opening a file shows one indicator in one position until the body appears. Loaded pages stay visible while another page loads. The PDF body loads its package-local `client.pdf.js` chunk only when a PDF preview mounts; PDF.js, its Worker source, and embedded support data stay out of the startup `client.js`. PDF pages fill the pane's width edge to edge as one vertical sequence and render lazily near the viewport; an unrendered page holds its place as a quiet 3:4 placeholder block. PDF.js’s official TextLayerBuilder manages selection boundaries and normalized copying over an aligned text layer. Its companion styles keep blank line breaks unhighlighted; alignment accounts for PDF page units, page rotation, and viewport resizing, and page disposal cancels both layers. Image-only PDFs contain no selectable text. Code previews show a tertiary-colored language label and a copy icon with a tooltip; wrapping remains in the document toolbar. They show source line numbers by default without including them in copied text; plain text uses the same font size and line height as code. Code sits on the pane's own background rather than the chat card's fill; its banner is adjacent to a full-height inner scrollport, so both scrollbars begin below the copy control. @@ -134,7 +134,7 @@ No direct effect; what the user reads here never enters a model request. - **Preview, not editing.** The viewers provide no file editing or shared search interface; a directory address fails with `not-regular-file`. Unknown extensions use the plain-text reader and remain subject to its UTF-8/NUL checks. - **Office conversion limits.** The preview does not launch native Office editors or download an engine. Binary `.doc` and `.ppt` files return no missing-font diagnostics. Conversion fidelity and resource limits belong to the [LibreOffice provider](../../document/office-to-pdf/README.md). - **Sequential text and bounded complete files.** Deep source lines require the preceding pages; PDF, HTML, and images require a complete result within the Host's `maxFileBytes` cap. -- **PDF zoom does not increase raster resolution.** PDF pages render at 96 DPI times the device pixel ratio, subject to the existing 16-million-pixel canvas limit, and fixed zoom enlarges that bitmap. High percentages can therefore soften page text; viewport-near rerendering at the settled zoom remains deferred. +- **PDF raster allocation is bounded.** Each page bitmap is capped at 16,777,216 pixels; very large pages or high zoom on high-density displays can still render below device resolution. - **Byte-view scroll state is not restored.** PDF, HTML, and images can return to the top when their renderer remounts or reloads; fixed PDF and image zoom can overflow horizontally, and HTML iframe scrolling belongs to its opaque browsing context. - **Finite local HTML dependencies.** Only direct classic `.js` and stylesheet `.css` references are packed. Browser-resolved resources retain browser origin and network restrictions; no runtime file-read bridge is exposed to the iframe. - **Scroll writes are unthrottled.** Every scroll event records its offset in the store; the line blocks are memoized so the resulting re-render hands React the same elements back. diff --git a/packages/client/ui-sidebar-documentpreview/README.zh.md b/packages/client/ui-sidebar-documentpreview/README.zh.md index 949e66546b..1b15e805a2 100644 --- a/packages/client/ui-sidebar-documentpreview/README.zh.md +++ b/packages/client/ui-sidebar-documentpreview/README.zh.md @@ -59,7 +59,7 @@ Web 和桌面端均通过开发者工具选择 HTML 预览策略。渲染器从 PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `` 静态图片上下文中渲染,带 12px 内边距和圆角。图片默认适应宽度,但不会放大小于面板的内容;100% 使用图片的固有 CSS 像素宽度。共享缩放控件可产生横向和纵向滚动,但不提供拖拽平移。缩放不会替换 `` 或 Blob URL,因此动画图片会继续播放。位图超过固有尺寸后可能变虚,SVG 则继续使用浏览器的矢量渲染路径。SVG 标记绝不进入应用 DOM 或 iframe,因此其中的脚本无法执行,也无法访问父页面。替换或卸载图片会撤销其 Blob URL。 -共享文案来自 `sidebarDocumentPreview`;各内置渲染器拥有自己的本地化标签。PDF 与转换后的 Office 预览在浅色模式下使用石墨灰底色,在深色模式下使用哑黑底色,页面带有轻微阴影并保留文档原色。PDF 与图片预览共用浮动控件,提供**适应宽度**、25%、50%、100%、150% 和 200% 选项,并在 25%–400% 固定范围内按 25% 逐级缩放。适应宽度是默认模式并跟随面板尺寸;100% 表示 PDF 的 96 DPI 页面尺寸或图片的固有 CSS 像素尺寸。在精细指针设备上,控件初始隐藏;指针进入底部 72 个 CSS 像素时向上滑入,离开 420 ms 后向下退出;控件悬停、键盘焦点、菜单展开和手势进行期间保持显示,无 hover 的设备则始终显示。Chromium 将 macOS 触控板捏合映射到与 Ctrl+滚轮相同的实时指针锚定路径;控件同步显示手势比例,静止后的值在正文重新挂载后继续保留,直至 tab 关闭。HTML 预览不显示缩放控件。 +共享文案来自 `sidebarDocumentPreview`;各内置渲染器拥有自己的本地化标签。PDF 与转换后的 Office 预览在浅色模式下使用石墨灰底色,在深色模式下使用哑黑底色,页面带有轻微阴影并保留文档原色。PDF 与图片预览共用浮动控件,提供**适应宽度**、25%、50%、100%、150% 和 200% 选项,并在 25%–400% 固定范围内按 25% 逐级缩放。适应宽度是默认模式并跟随面板尺寸;100% 表示 PDF 的 96 DPI 页面尺寸或图片的固有 CSS 像素尺寸。在精细指针设备上,控件初始隐藏;指针进入底部 72 个 CSS 像素时向上滑入,离开 420 ms 后向下退出;控件悬停、键盘焦点、菜单展开和手势进行期间保持显示,无 hover 的设备则始终显示。Chromium 将 macOS 触控板捏合映射到与 Ctrl+滚轮相同的实时指针锚定路径;控件同步显示手势比例,静止后的值在正文重新挂载后继续保留,直至 tab 关闭。HTML 预览不显示缩放控件。缩放稳定或适应宽度发生变化后,视口附近的 PDF 页面按最终缩放比例乘以设备像素比重绘。新位图完成前保留原有画面和可选文字层;屏幕外的页面在接近视口时更新。 首次读取、追加页及 HTML/PDF/图片准备共用 ongoing `StateDot` loading,其标签暴露给辅助技术,并遵循减少动态效果偏好;内容出现前的每个等待都把 loading 居中在面板中,打开文件到正文出现始终是同一位置的一个标记。下一页加载期间保留已显示的内容。PDF 正文仅在 PDF 预览挂载时加载包内 `client.pdf.js` chunk;PDF.js、Worker 源码和内嵌支持数据不会进入启动 `client.js`。PDF 页面贴边占满面板宽度,组成一个纵向连续序列并在接近视口时惰性渲染;未渲染的页以安静的 3:4 占位块保持位置。PDF.js 官方 TextLayerBuilder 在与画面重合的文字层上管理选区边界和复制文本规范化。配套样式不高亮空白换行;对齐同时考虑 PDF 页面单位、页面旋转与视口宽度变化,页面释放时取消两层渲染。纯图片 PDF 不包含可选取的文字。代码预览使用 tertiary 色的语言名称和带提示的复制图标;换行仍由文档工具栏控制。默认显示源码行号,但复制文本不包含行号;纯文本与代码使用相同字号和行高。代码直接坐在分栏自身的背景上,而不是会话卡片的填充色;复制条与占满剩余高度的内部滚动区相邻,因此横纵滚动条都从复制控件下方开始。 @@ -134,7 +134,7 @@ Office Remote 通过 Connection 的 multipart 二进制传输返回原生 `Uint8 - **预览而非编辑。** 查看器不提供文件编辑或共享搜索接口;目录地址以 `not-regular-file` 失败。未知扩展名使用纯文本读取,仍受其 UTF-8/NUL 检查限制。 - **Office 转换限制。** 预览不启动原生 Office 编辑器,也不下载引擎。二进制 `.doc` 和 `.ppt` 文件不返回缺失字体诊断。转换保真度与资源限制由 [LibreOffice 提供方](../../document/office-to-pdf/README.zh.md)负责。 - **文本顺序分页,完整文件受限。** 定位到较深处的源码行需要先加载此前各页;PDF、HTML 和图片必须取得 Host `maxFileBytes` 上限内的完整结果。 -- **PDF 缩放不会提高栅格分辨率。** PDF 页面按 96 DPI 乘以设备像素比绘制,并受现有 1600 万像素 Canvas 上限约束;固定缩放会放大这张位图,因此高比例下页面文字可能变虚。按稳定缩放比例重绘视口附近页面仍属于延期工作。 +- **PDF 栅格分配有上限。** 每页位图最多为 16,777,216 像素;超大页面或高像素密度屏幕上的高比例缩放仍可能低于设备分辨率。 - **字节视图不恢复滚动位置。** PDF、HTML 与图片的渲染器重新挂载或重新载入时可能回到顶部;固定 PDF 与图片缩放可产生横向滚动,HTML iframe 的滚动属于其不透明浏览上下文。 - **本地 HTML 依赖集合有限。** 只打包直接引用的经典 `.js` 脚本和 `.css` 样式表。浏览器解析的资源仍受浏览器源与网络规则限制;iframe 不获得运行时文件读取桥接。 - **滚动写入未节流。** 每次滚动事件都把偏移记进 store;行块已 memo 化,于是由此引发的重渲染交还给 React 的是同一批元素。 diff --git a/packages/client/ui-sidebar-documentpreview/src/client/pdf/document.ts b/packages/client/ui-sidebar-documentpreview/src/client/pdf/document.ts index 460bac419d..4415793c21 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/pdf/document.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/pdf/document.ts @@ -35,7 +35,7 @@ export type RenderPdfText = (page: PDFPageProxy, viewport: PageViewport) => { * @param pageNumber - 1-based selected page. * @param canvas - canvas owned by this render only. * @param signal - render lifetime. - * @param pixelRatio - display pixel ratio. + * @param pixelRatio - device pixel ratio multiplied by the settled document zoom. * @param renderText - optional selectable text layer sharing this page and viewport. * @returns the page's CSS dimensions after rendering completes. */ diff --git a/packages/client/ui-sidebar-documentpreview/src/client/pdf/pdf.tsx b/packages/client/ui-sidebar-documentpreview/src/client/pdf/pdf.tsx index fc11b38254..34c43074bb 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/pdf/pdf.tsx +++ b/packages/client/ui-sidebar-documentpreview/src/client/pdf/pdf.tsx @@ -49,6 +49,7 @@ export function PdfBody(props: PdfBodyProps): ReactNode { const [load, setLoad] = useState() const [attempt, setAttempt] = useState(0) const [pageWidth, setPageWidth] = useState() + const [renderZoom, setRenderZoom] = useState(1) const { retainTab, actions, t, ZoomViewport, zoomSurfaceClass } = props const preference = view.zoom ?? FIT_WIDTH const labels: ZoomLabels = { @@ -97,22 +98,23 @@ export function PdfBody(props: PdfBodyProps): ReactNode { {Array.from({ length: load.document.numPages }, (_, index) => ( + onWidth={index === 0 ? setPageWidth : undefined} zoom={renderZoom} zoomSurfaceClass={zoomSurfaceClass} t={t} /> ))} return + signal={tab.signal} scrollportRef={props.scrollportRef} onPreference={setPreference} onRenderZoom={setRenderZoom}> {pages} } -function PdfPage({ document, page, requested: initiallyRequested, onVisible, signal, onWidth, zoomSurfaceClass, t }: { +function PdfPage({ document, page, requested: initiallyRequested, onVisible, signal, onWidth, zoom, zoomSurfaceClass, t }: { readonly document: PdfDocument readonly page: number readonly requested: boolean readonly onVisible: (page: number) => void readonly signal: AbortSignal readonly onWidth: ((width: number) => void) | undefined + readonly zoom: number readonly zoomSurfaceClass: string } & PropsLocale<'sidebarPdf'>): ReactNode { const host = useRef(null) @@ -120,47 +122,78 @@ function PdfPage({ document, page, requested: initiallyRequested, onVisible, sig const text = useRef(null) const [width, setWidth] = useState() const [requested, setRequested] = useState(initiallyRequested) + const [visible, setVisible] = useState(initiallyRequested) + const renderedZoom = useRef() + const pendingRender = useRef(Promise.resolve()) + const textTask = useRef>>() const [state, setState] = useState<'loading' | 'ready'>('loading') const [failure, setFailure] = useState<{ readonly error: unknown }>() const [attempt, setAttempt] = useState(0) useEffect(() => { - const node = host.current as HTMLDivElement + // A zoomed canvas can remain visible after its narrower page wrapper has + // scrolled out horizontally. Observe the displayed page once it is ready. + const node = state === 'ready' ? canvas.current as HTMLCanvasElement : host.current as HTMLDivElement if (typeof IntersectionObserver === 'undefined') { setRequested(true) + setVisible(true) return } let disposed = false const observer = new IntersectionObserver((entries) => { - if (disposed || !entries.some(entry => entry.isIntersecting)) return + if (disposed) return + const intersects = entries.some(entry => entry.isIntersecting) + setVisible(intersects) + if (!intersects) return setRequested(true) onVisible(page) - observer.disconnect() }, { rootMargin: '100% 0px' }) observer.observe(node) return () => { disposed = true observer.disconnect() } - }, [page, onVisible]) + }, [page, onVisible, state]) + useEffect(() => () => { textTask.current?.cancel() }, []) useEffect(() => { if (!requested) return - // The canvas is unconditional; this effect runs after its ref is committed. + setFailure(undefined) + if (renderedZoom.current !== undefined && (!visible || renderedZoom.current === zoom)) return const node = canvas.current as HTMLCanvasElement const lifetime = new AbortController() const renderSignal = AbortSignal.any([lifetime.signal, signal]) - setState('loading') - setFailure(undefined) - const createText = pdfTextRenderer(text.current as HTMLDivElement) - let textTask: ReturnType | undefined - void renderPdfPage(document, page, node, renderSignal, window.devicePixelRatio, (pdfPage, viewport) => { - textTask = createText(pdfPage, viewport) - return textTask - }).then( - (size) => { if (!renderSignal.aborted) { setWidth(size.width); onWidth?.(size.width); setState('ready') } }, - (error: unknown) => { if (!renderSignal.aborted) setFailure({ error }) }, - ) - return () => { lifetime.abort(); textTask?.cancel() } - }, [document, page, requested, signal, attempt, onWidth]) + let newText: typeof textTask.current + // PDF.js shares a page proxy across renders; its cancelled task must finish + // cleanup before another render starts. The displayed bitmap stays intact. + pendingRender.current = pendingRender.current.then(async () => { + renderSignal.throwIfAborted() + const buffer = node.ownerDocument.createElement('canvas') + try { + const size = await renderPdfPage(document, page, buffer, renderSignal, window.devicePixelRatio * zoom, + textTask.current === undefined ? (pdfPage, viewport) => { + newText = pdfTextRenderer(text.current as HTMLDivElement)(pdfPage, viewport) + return newText + } : undefined) + renderSignal.throwIfAborted() + const context = node.getContext('2d') + if (context === null) throw new Error('PDF canvas has no 2D context') + node.width = buffer.width + node.height = buffer.height + node.style.setProperty('--pdf-page-width', `${size.width}px`) + node.style.setProperty('--pdf-page-height', `${size.height}px`) + context.drawImage(buffer, 0, 0) + textTask.current ??= newText + newText = undefined + renderedZoom.current = zoom + setWidth(size.width) + onWidth?.(size.width) + setState('ready') + } finally { + buffer.width = buffer.height = 0 + newText?.cancel() + } + }).catch((error: unknown) => { if (!renderSignal.aborted) setFailure({ error }) }) + return () => { lifetime.abort(); newText?.cancel() } + }, [document, page, requested, visible, zoom, signal, attempt, onWidth]) return
{failure === undefined && state !== 'ready' && (requested ?
@@ -171,7 +204,7 @@ function PdfPage({ document, page, requested: initiallyRequested, onVisible, sig
}