From 3515c48a37cadae13e49a518fc9b28b474416bc3 Mon Sep 17 00:00:00 2001 From: mektpoy Date: Sun, 20 Sep 2026 15:37:38 +0800 Subject: [PATCH 01/13] 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/13] 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 80e4da0e3d4071ba7a0ef9c5166fff7bd960c6d6 Mon Sep 17 00:00:00 2001 From: mektpoy Date: Wed, 23 Sep 2026 16:03:17 +0800 Subject: [PATCH 03/13] 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 04/13] 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 05/13] 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 06/13] 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 07/13] 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 08/13] 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 09/13] 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 10/13] 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 4aa207c5fec7762b839fc627993733706dee96c3 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 23 Sep 2026 18:06:52 +0800 Subject: [PATCH 11/13] 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 19d6df2b2f6addbb6356206c895aef52c784499c Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 23 Sep 2026 18:13:19 +0800 Subject: [PATCH 12/13] fix(web): pan read-only spreadsheets on both trackpad axes --- apps/web/tests/document-preview.e2e.ts | 33 +++++++++++++++++++ .../README.i18n.yaml | 4 +-- .../ui-sidebar-documentpreview/README.md | 2 +- .../ui-sidebar-documentpreview/README.zh.md | 2 +- patches/@fortune-sheet__core@1.0.4.patch | 32 ++++++++++++++++-- pnpm-lock.yaml | 8 ++--- 6 files changed, 71 insertions(+), 10 deletions(-) diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index b55c5acfda..151120c05a 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -319,6 +319,38 @@ it.skipIf(MODE === 'record').each(['en-US', 'zh-CN'])('fills the spreadsheet pan } }) +/** Pan with native pixel wheel input in every direction and along the sheet edges. */ +async function expectExcelPanning(page: Page, excel: Locator): Promise { + // Chromium's CDP wheel distances scale with the emulated device pixel ratio. + const scale = await page.evaluate(() => window.devicePixelRatio) + const wheel = async (x: number, y: number) => { await page.mouse.wheel(x * scale, y * scale) } + const offset = async () => await excel.evaluate(node => ({ + x: node.querySelector('.luckysheet-scrollbar-x')!.scrollLeft, + y: node.querySelector('.luckysheet-scrollbar-y')!.scrollTop, + })) + const selection = await excel.locator('.fortune-name-box').innerText() + await excel.evaluate((node) => { + node.querySelector('.luckysheet-scrollbar-x')!.scrollLeft = 240 + node.querySelector('.luckysheet-scrollbar-y')!.scrollTop = 240 + }) + await expect.poll(offset).toEqual({ x: 240, y: 240 }) + await excel.locator('.fortune-sheet-overlay').hover({ position: { x: 260, y: 160 } }) + for (const [dx, dy] of [[37, 19], [-13, 27], [-19, -11], [23, -17], [11, 0], [0, 13], [-9, 0], [0, -7]] as const) { + const before = await offset() + await wheel(dx, dy) + await expect.poll(offset).toEqual({ x: before.x + dx, y: before.y + dy }) + } + expect(await excel.locator('.fortune-name-box').innerText()).toBe(selection) + await wheel(-10000, -10000) + await expect.poll(offset).toEqual({ x: 0, y: 0 }) + await wheel(-20, 20) + await expect.poll(offset).toEqual({ x: 0, y: 20 }) + await wheel(20, -20) + await expect.poll(offset).toEqual({ x: 20, y: 0 }) + await wheel(-20, 0) + await expect.poll(offset).toEqual({ x: 0, y: 0 }) +} + /** Sample the pixel band before the divider center, excluding the adjacent cell grid line. */ async function freezeDividerInk(excel: Locator, axis: 'x' | 'y'): Promise { return await excel.evaluate((node, direction) => { @@ -387,6 +419,7 @@ it.skipIf(MODE === 'record').each([1, 2])('keeps frozen headings without divider await expect.poll(() => freezeDividerInk(excel, axis)).toBe(0) } } + await expectExcelPanning(page, excel) await successShot(page, `excel-freeze-${sheet}-${ratio}-${deviceScaleFactor}`) } await excel.locator('.luckysheet-scrollbar-x').evaluate((node) => { node.scrollLeft = 240 }) diff --git a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml index 87dd80cf92..5f16c0fdaa 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: 8861b11babfa39909ea639bb3caf0dad6f5ccfa3 -README.zh.md: bfd80bdc8f03a181a526c281fe603299ee3a1bc8 +README.md: a0c08803fd13b32cb968f3415dec522a95937da3 +README.zh.md: 7ba9b4397fd34a43a0d399ca0cf4c16df10dc808 diff --git a/packages/client/ui-sidebar-documentpreview/README.md b/packages/client/ui-sidebar-documentpreview/README.md index 8861b11bab..a0c08803fd 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 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. +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. Pixel-based trackpad gestures pan the grid on both axes together, including diagonally, at the gesture’s speed; frozen headings stay fixed. 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 bfd80bdc8f..7ba9b4397f 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/patches/@fortune-sheet__core@1.0.4.patch b/patches/@fortune-sheet__core@1.0.4.patch index b0cbd57e3f..bb06fdbd98 100644 --- a/patches/@fortune-sheet__core@1.0.4.patch +++ b/patches/@fortune-sheet__core@1.0.4.patch @@ -1,5 +1,5 @@ diff --git a/dist/index.esm.js b/dist/index.esm.js -index 5c9820b1a261305d500e69eeaf350821872beaf4..c0e1bf786c0333fa56edc007c3fd700d834fa40f 100644 +index 5c9820b1a261305d500e69eeaf350821872beaf4..819c6d2e3ffa422b1d1bf163bf56df13bd3af17e 100644 --- a/dist/index.esm.js +++ b/dist/index.esm.js @@ -62866,7 +62866,7 @@ function rangeValueToHtml(ctx, sheetId, ranges) { @@ -19,8 +19,22 @@ index 5c9820b1a261305d500e69eeaf350821872beaf4..c0e1bf786c0333fa56edc007c3fd700d var horizontalTop = _ref2.horizontalTop, verticalLeft = _ref2.verticalLeft; var renderCtx = this.canvasElement.getContext("2d"); +@@ -77244,6 +77245,13 @@ function handleGlobalWheel(ctx, e, cache, scrollbarX, scrollbarY) { + var _cache$searchDialog; + if (((_cache$searchDialog = cache.searchDialog) === null || _cache$searchDialog === void 0 ? void 0 : _cache$searchDialog.mouseEnter) && ctx.showSearch && ctx.showReplace) return; + if (ctx.filterContextMenu != null) return; ++ // Read-only trackpad panning follows pixel deltas on both axes without a gesture lock. ++ if (ctx.allowEdit === false && e.deltaMode === 0) { ++ scrollbarX.scrollLeft += e.deltaX; ++ scrollbarY.scrollTop += e.deltaY; ++ e.preventDefault(); ++ return; ++ } + var scrollLeft = scrollbarX.scrollLeft; + var scrollTop = scrollbarY.scrollTop; + var visibledatacolumn_c = ctx.visibledatacolumn; diff --git a/dist/index.js b/dist/index.js -index 04113a18dee9feee28e9434ee3023626c800e7d3..8e68fb2436e7662e3c26ba0308e70b7be1567767 100644 +index 04113a18dee9feee28e9434ee3023626c800e7d3..29e35f51fbf1c3bb16cca54ce2bda81bae4fef64 100644 --- a/dist/index.js +++ b/dist/index.js @@ -62876,7 +62876,7 @@ function rangeValueToHtml(ctx, sheetId, ranges) { @@ -40,3 +54,17 @@ index 04113a18dee9feee28e9434ee3023626c800e7d3..8e68fb2436e7662e3c26ba0308e70b7b var horizontalTop = _ref2.horizontalTop, verticalLeft = _ref2.verticalLeft; var renderCtx = this.canvasElement.getContext("2d"); +@@ -77254,6 +77255,13 @@ function handleGlobalWheel(ctx, e, cache, scrollbarX, scrollbarY) { + var _cache$searchDialog; + if (((_cache$searchDialog = cache.searchDialog) === null || _cache$searchDialog === void 0 ? void 0 : _cache$searchDialog.mouseEnter) && ctx.showSearch && ctx.showReplace) return; + if (ctx.filterContextMenu != null) return; ++ // Read-only trackpad panning follows pixel deltas on both axes without a gesture lock. ++ if (ctx.allowEdit === false && e.deltaMode === 0) { ++ scrollbarX.scrollLeft += e.deltaX; ++ scrollbarY.scrollTop += e.deltaY; ++ e.preventDefault(); ++ return; ++ } + var scrollLeft = scrollbarX.scrollLeft; + var scrollTop = scrollbarY.scrollTop; + var visibledatacolumn_c = ctx.visibledatacolumn; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bdd23c40d2..e93f8c24b1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,7 +12,7 @@ overrides: patchedDependencies: '@earendil-works/pi-ai@0.85.1': b9bcce474fb2ac44633dff0fa722816a5bff5451b4575d5874035ea14ba70a4f '@electron/osx-sign@1.3.3': 280e7b2816caf01423097d24e79107a66afeb563a348630ce9928ae8499a5861 - '@fortune-sheet/core@1.0.4': 7d369e6feca3c674aeb089624e38e0a79ad61e695d6bdae89711876cbd178f24 + '@fortune-sheet/core@1.0.4': 886223a70a0cf76d4fa174608852763fe76bbdc9374db127665024c8c150dd68 '@fortune-sheet/react@1.0.4': 2932e52e79afcd4ced08cfa123b5eded786c85043da32f622f3e80af6fc96c37 '@yao-pkg/pkg@6.21.0': 75891fc22b277c145cafeab83e41baf4a70f5b8fe6dc0801c6a0b922d7ee88c7 exceljs@4.4.0: c1bf8ac8f319dab382f08e3a669b715e691d46c703ac5a4163eb383fbedd1d48 @@ -5042,7 +5042,7 @@ importers: version: link:../../util/workspace-path '@fortune-sheet/core': specifier: 1.0.4 - version: 1.0.4(patch_hash=7d369e6feca3c674aeb089624e38e0a79ad61e695d6bdae89711876cbd178f24) + version: 1.0.4(patch_hash=886223a70a0cf76d4fa174608852763fe76bbdc9374db127665024c8c150dd68) '@fortune-sheet/react': specifier: 1.0.4 version: 1.0.4(patch_hash=2932e52e79afcd4ced08cfa123b5eded786c85043da32f622f3e80af6fc96c37)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -22485,7 +22485,7 @@ snapshots: bessel: 1.0.2 jstat: 1.9.6 - '@fortune-sheet/core@1.0.4(patch_hash=7d369e6feca3c674aeb089624e38e0a79ad61e695d6bdae89711876cbd178f24)': + '@fortune-sheet/core@1.0.4(patch_hash=886223a70a0cf76d4fa174608852763fe76bbdc9374db127665024c8c150dd68)': dependencies: '@fortune-sheet/formula-parser': 0.2.13 dayjs: 1.11.21 @@ -22501,7 +22501,7 @@ snapshots: '@fortune-sheet/react@1.0.4(patch_hash=2932e52e79afcd4ced08cfa123b5eded786c85043da32f622f3e80af6fc96c37)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@fortune-sheet/core': 1.0.4(patch_hash=7d369e6feca3c674aeb089624e38e0a79ad61e695d6bdae89711876cbd178f24) + '@fortune-sheet/core': 1.0.4(patch_hash=886223a70a0cf76d4fa174608852763fe76bbdc9374db127665024c8c150dd68) '@types/regenerator-runtime': 0.13.8 immer: 9.0.21 lodash: 4.18.1 From 2c676339904bef553472152b87d33254b5151c8f Mon Sep 17 00:00:00 2001 From: turtle1999 Date: Wed, 23 Sep 2026 15:36:48 +0800 Subject: [PATCH 13/13] feat(plugins): enforce DSH peer compatibility with exact exemptions --- apps/cli/README.i18n.yaml | 4 +- apps/cli/README.md | 2 + apps/cli/README.zh.md | 2 + apps/cli/src/bin.ts | 16 +- apps/cli/src/plugin.ts | 67 ++- .../plugin-compatibility.expected.e2e.ts | 69 +++ apps/cli/tests/plugin.spec.ts | 91 ++++ apps/web/tests/plugin-install-registry.e2e.ts | 8 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 2 +- docs/event-producer-consumer.zh.md | 2 +- docs/subsystems/boot.i18n.yaml | 4 +- docs/subsystems/boot.md | 15 + docs/subsystems/boot.zh.md | 15 + docs/tool-catalog.i18n.yaml | 4 +- docs/tool-catalog.md | 16 +- docs/tool-catalog.zh.md | 16 +- packages/boot/app-boot/README.i18n.yaml | 4 +- packages/boot/app-boot/README.md | 9 +- packages/boot/app-boot/README.zh.md | 9 +- packages/boot/app-boot/package.json | 5 +- .../app-boot/src/compatibility-preflight.ts | 185 +++++++ packages/boot/app-boot/src/index.ts | 38 +- .../boot/app-boot/src/plugin-compatibility.ts | 102 ++++ .../app-boot/src/profile-compatibility.ts | 141 ++++++ .../tests/compatibility-preflight.spec.ts | 351 +++++++++++++ .../tests/plugin-compatibility.spec.ts | 177 +++++++ .../tests/profile-compatibility.spec.ts | 132 +++++ packages/boot/app-boot/tsconfig.json | 3 + packages/boot/plugin-manager/README.i18n.yaml | 4 +- packages/boot/plugin-manager/README.md | 11 + packages/boot/plugin-manager/README.zh.md | 11 + packages/boot/plugin-manager/src/index.ts | 46 +- .../boot/plugin-manager/src/operations.ts | 215 +++++++- packages/boot/plugin-manager/src/tools.ts | 16 +- .../boot/plugin-manager/tests/manager.spec.ts | 59 ++- .../tests/operations-process.spec.ts | 6 +- .../plugin-manager/tests/operations.spec.ts | 475 ++++++++++++++++-- .../boot/plugin-manager/tests/tools.spec.ts | 15 +- .../extensions/tool-cordis/src/api-catalog.ts | 12 + .../preset/agent-preset-registry/package.json | 2 +- .../preset/agent-preset-registry/src/mount.ts | 6 +- .../agent-preset-registry/tests/mount.spec.ts | 59 ++- pnpm-lock.yaml | 15 +- .../plugin-manager-mcp/session.v4.jsonl | 29 ++ .../tool-schemas.expected.json | 32 +- .../session/plugin-manager/session.v4.jsonl | 27 + .../plugin-manager/tool-schemas.expected.json | 16 +- 51 files changed, 2406 insertions(+), 151 deletions(-) create mode 100644 apps/cli/tests/plugin-compatibility.expected.e2e.ts create mode 100644 apps/cli/tests/plugin.spec.ts create mode 100644 packages/boot/app-boot/src/compatibility-preflight.ts create mode 100644 packages/boot/app-boot/src/plugin-compatibility.ts create mode 100644 packages/boot/app-boot/src/profile-compatibility.ts create mode 100644 packages/boot/app-boot/tests/compatibility-preflight.spec.ts create mode 100644 packages/boot/app-boot/tests/plugin-compatibility.spec.ts create mode 100644 packages/boot/app-boot/tests/profile-compatibility.spec.ts create mode 100644 snapshots/session/plugin-manager-mcp/session.v4.jsonl create mode 100644 snapshots/session/plugin-manager/session.v4.jsonl diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index 7e395f72d5..430fd91f46 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/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/cli/README.md -README.md: 4eb113a6155433cb69f949d9e461ccfbd0142e7c -README.zh.md: 6a0e8a38a70d2ad551b4e91619545b3def09f2ec +README.md: 765440438175083d2dbd765592731df5ec148bcf +README.zh.md: 08db105b160199cb9a64276f273b8b00f0d21c09 diff --git a/apps/cli/README.md b/apps/cli/README.md index 4eb113a615..7654404381 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -36,6 +36,8 @@ dsh --help # the launcher's own help A profile directory holds a `package.json` (out-of-tree plugin dependencies plus the profile manifest `dsh.profile` with its ordered `bundles` list) and a `cordis.patch.yml` (the user's own patch layer). `dsh-hmr`, when enabled in YAML, watches the profile manifest and both profile and home patch files, then recomposes all layers through one serialized reload. Without HMR, changes apply on restart. Edits arriving during watcher registration use the same nonfatal reload reporting as later edits. [Plugin Manager](../../packages/boot/plugin-manager/README.md) shares package operations and the profile write lock with `dsh plugin`; package updates retain disabled bundle selections. CLI package commands inherit authentication variables and terminal descriptors, including interactive build approval; service calls retain their scrubbed environment and captured diagnostics. +Installation and profile startup enforce declared DSH peer ranges against the same runtime version shown by `dsh --version`. Incompatible plugins require an explicitly acknowledged exact-version exemption. The [plugin manager's compatibility reference](../../packages/boot/plugin-manager/README.md#version-compatibility-and-exemptions) documents `version-exemptions`, `allow-version`, `revoke-version`, persistence, and risks. + The tree composes over an empty root: - each bundle's patch in `dsh.profile.bundles` order - then the profile's `cordis.patch.yml`, then the home-level `$DSH_HOME/cordis.patch.yml` diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 6a0e8a38a7..08db105b16 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -36,6 +36,8 @@ dsh --help # the launcher's own help profile 目录包含一个 `package.json`,其中记录树外插件依赖,以及 profile manifest(元数据清单)`dsh.profile`、其中按顺序排列的 `bundles` 列表;还包含一个 `cordis.patch.yml`,其中保存用户自己的 patch 层。在 YAML 中启用的 `dsh-hmr` 监视 profile manifest、profile 与 home 级 patch 文件,再通过统一串行重载重新组合所有层。未启用 HMR 时,更改在重启后生效。监听器注册期间发生的编辑与后续编辑使用相同的非致命重载错误报告。[插件管理器](../../packages/boot/plugin-manager/README.zh.md) 与 `dsh plugin` 共享包操作和 profile 写锁;更新依赖会保留已停用的组合包选择。CLI 包操作继承认证环境和终端描述符,支持交互式构建批准;service 调用保留清理后的环境并捕获诊断。 +安装和 profile 启动会按声明的 DSH peer 范围,检查与 `dsh --version` 显示值相同的运行时版本。不兼容插件需要用户明确确认精确版本豁免。[插件管理器的兼容性参考](../../packages/boot/plugin-manager/README.zh.md#version-compatibility-and-exemptions)说明 `version-exemptions`、`allow-version`、`revoke-version`、持久化规则与风险。 + 配置树以空根为起点,依次叠加以下配置层: - `dsh.profile.bundles` 中各组合包的 patch - profile 自身的 `cordis.patch.yml`,然后是 home 级的 `$DSH_HOME/cordis.patch.yml` diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index e52c7c8202..20f5867e23 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -6,29 +6,17 @@ /* v8 ignore file -- built-bin acceptance exercises this self-executing dispatch. */ -import { readFileSync } from 'node:fs' -import { fileURLToPath } from 'node:url' -import { loadLayeredEnv, StartupError } from '@deepseek-ai/dsh-app-boot' +import { getDshRuntimeVersion, loadLayeredEnv, StartupError } from '@deepseek-ai/dsh-app-boot' import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' import { parseDshArgs } from './args.ts' import { reportStartupFailure } from './startup-diagnostics.ts' -// Both the source tree (apps/cli/src) and the bundled bin (apps/cli/lib) sit -// one directory under apps/cli, so the checked-in manifest resolves with the -// same relative hop from either artifact. -function readVersion(): string { - const manifest = JSON.parse( - readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8'), - ) as { version?: unknown } - return typeof manifest.version === 'string' ? manifest.version : '0.0.0' -} - /** * Run the public dsh command-line interface. * @returns a promise that settles when the selected command mode finishes. */ export async function runCli(): Promise { - const version = readVersion() + const version = getDshRuntimeVersion() const invocation = parseDshArgs(process.argv.slice(2), version) switch (invocation.mode) { diff --git a/apps/cli/src/plugin.ts b/apps/cli/src/plugin.ts index 12a0dbc443..a246af7573 100644 --- a/apps/cli/src/plugin.ts +++ b/apps/cli/src/plugin.ts @@ -1,19 +1,76 @@ -/** dsh plugin forwards pnpm through the shared profile package operations. */ -import { runPluginCommand } from '@deepseek-ai/dsh-plugin-manager/operations' +/** Profile package management and explicit, exact-version compatibility approvals. */ +import { runPluginCommand, setProfileVersionExemption } from '@deepseek-ai/dsh-plugin-manager/operations' import { INSTALL_ANCHOR } from './profile-boot.ts' -import { resolveProfileDir } from '@deepseek-ai/dsh-app-boot' +import { DEFAULT_PROFILE_BUNDLES, initProfile, PROFILE_TEMPLATES, readProfileCompatibility, resolveProfileDir } from '@deepseek-ai/dsh-app-boot' +import { withFileLock } from '@deepseek-ai/dsh-atomic-write' +import { existsSync } from 'node:fs' +import { mkdir } from 'node:fs/promises' import { join } from 'node:path' +/** Parse only DSH-owned commands; all other arguments remain pnpm's responsibility. */ +async function versionCommand(profile: string, args: readonly string[]): Promise { + const [command, ...rest] = args + if (command !== 'allow-version' && command !== 'revoke-version' && command !== 'version-exemptions') return undefined + try { + let packageVersion: string | undefined + let runtimeVersion: string | undefined + let acceptRisk = false + const argumentsIterator = rest.values() + for (const argument of argumentsIterator) { + if (argument === '--accept-risk' && command === 'allow-version' && !acceptRisk) acceptRisk = true + else if (argument === '--dsh-version' && runtimeVersion === undefined) runtimeVersion = argumentsIterator.next().value + else if (argument.startsWith('--dsh-version=') && runtimeVersion === undefined) runtimeVersion = argument.slice('--dsh-version='.length) + else if (!argument.startsWith('-') && packageVersion === undefined) packageVersion = argument + else throw new Error(`unexpected argument ${JSON.stringify(argument)}`) + } + if (command === 'version-exemptions' && rest.length > 0) throw new Error('usage: dsh plugin version-exemptions') + let request: { packageVersion: string; runtimeVersion: string } | undefined + if (command !== 'version-exemptions') { + if (packageVersion === undefined || runtimeVersion === undefined) { + throw new Error(`usage: dsh plugin ${command} --dsh-version ${command === 'allow-version' ? ' --accept-risk' : ''}`) + } + request = { packageVersion, runtimeVersion } + } + if (command === 'allow-version') { + process.stderr.write('dsh: warning: allowing incompatible plugin versions can break the application or corrupt data. Approval applies only to the exact package and DSH versions.\n') + } + const dir = resolveProfileDir(profile) + await mkdir(dir, { recursive: true }) + await withFileLock(join(dir, 'package.json'), async () => { + if (!existsSync(join(dir, 'package.json'))) initProfile(dir, PROFILE_TEMPLATES[profile]?.bundles ?? DEFAULT_PROFILE_BUNDLES) + if (request === undefined) { + const { exemptions, warnings } = readProfileCompatibility(dir) + for (const warning of warnings) process.stderr.write(`dsh: warning: ${warning}\n`) + process.stdout.write(JSON.stringify(exemptions, undefined, 2) + '\n') + } else { + await setProfileVersionExemption(dir, request.packageVersion, request.runtimeVersion, command === 'allow-version', acceptRisk) + process.stdout.write(`dsh: ${command === 'allow-version' ? 'allowed' : 'revoked'} ${request.packageVersion} for DSH ${request.runtimeVersion}\n`) + } + }, { waitMs: 120000 }) + return 0 + } catch (error) { + process.stderr.write(`dsh: ${String(error)}\n`) + return 1 + } +} + /** Run package management for a profile. * @param profile Profile name. - * @param args Pnpm arguments relative to the invoking directory. - * @returns Pnpm exit code. + * @param args DSH exemption command or pnpm arguments relative to the invoking directory. + * @returns Zero on success; nonzero on invalid approval or package-manager failure. */ export async function runPlugin(profile: string, args: readonly string[]): Promise { + const versionResult = await versionCommand(profile, args) + if (versionResult !== undefined) return versionResult + const dir = resolveProfileDir(profile) + if (existsSync(join(dir, 'package.json'))) { + for (const warning of readProfileCompatibility(dir).warnings) process.stderr.write(`dsh: warning: ${warning}\n`) + } const result = await runPluginCommand({ profile, installAnchor: INSTALL_ANCHOR, cwd: process.cwd() }, args, { execution: 'cli', outputBytes: 16384, lockWaitMs: 120000, + lookupTimeoutMs: 120000, onOutput: (text, stream) => { process[stream].write(text) }, }) if (result.exitCode === 127) process.stderr.write('dsh: pnpm was not found; install pnpm and make it available on PATH.\n') diff --git a/apps/cli/tests/plugin-compatibility.expected.e2e.ts b/apps/cli/tests/plugin-compatibility.expected.e2e.ts new file mode 100644 index 0000000000..a002bb2514 --- /dev/null +++ b/apps/cli/tests/plugin-compatibility.expected.e2e.ts @@ -0,0 +1,69 @@ +/** Published CLI acceptance for actual pnpm installation, restart checks, and explicit exemptions. */ +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { execa } from 'execa' +import { expect, it, onTestFinished } from 'vitest' + +const bin = fileURLToPath(new URL('../lib/bin.js', import.meta.url)) +const version = (JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string }).version + +it('refuses incompatible installation and startup until an exact risk exemption is granted', async () => { + const home = mkdtempSync(join(tmpdir(), 'dsh-compatibility-cli-')) + onTestFinished(() => { rmSync(home, { recursive: true, force: true }) }) + const profile = join(home, 'profiles', 'compatibility') + const source = join(home, 'fixture-plugin') + mkdirSync(profile, { recursive: true }) + mkdirSync(source) + const profileFile = join(profile, 'package.json') + const initial = { name: 'compatibility-profile', private: true, dependencies: {}, dsh: { profile: { bundles: [] } } } + writeFileSync(profileFile, JSON.stringify(initial)) + writeFileSync(join(profile, 'pnpm-workspace.yaml'), 'packages:\n - .\nnodeLinker: hoisted\nautoInstallPeers: false\n') + writeFileSync(join(source, 'package.json'), JSON.stringify({ + name: 'compatibility-fixture', version: '1.0.0', type: 'module', + peerDependencies: { '@deepseek-ai/dsh': '999.0.0' }, dsh: { bundle: { patch: './cordis.patch.yml' } }, + })) + writeFileSync(join(source, 'cordis.patch.yml'), '- insert:\n - id: compatibility-fixture\n name: ./index.mjs\n') + writeFileSync(join(source, 'index.mjs'), 'process.stdout.write("PLUGIN_IMPORTED\\n"); export function apply() { process.stdout.write("PLUGIN_STARTED\\n") }\n') + const run = async (args: string[]) => { + const result = await execa(process.execPath, [bin, ...args], { + cwd: home, env: { ...process.env, DSH_HOME: home }, extendEnv: false, reject: false, timeout: 60_000, + }) + expect(result.timedOut).toBe(false) + expect(result.signal).toBeUndefined() + return result + } + const plugin = (args: string[]) => run(['plugin', '--profile', 'compatibility', ...args]) + const refused = await plugin(['add', source, '--offline', '--ignore-scripts']) + expect(refused.exitCode).toBe(1) + expect(refused.stderr).toContain('crashes or data loss') + expect(JSON.parse(readFileSync(profileFile, 'utf8'))).toEqual(initial) + expect(existsSync(join(profile, 'compatibility.json'))).toBe(false) + // The named package is checked before installation, so a refused one never lands on disk. + expect(refused.stderr).toContain('nothing was installed') + expect(existsSync(join(profile, 'node_modules', 'compatibility-fixture'))).toBe(false) + + // A package installed outside DSH still goes through the independent startup check. + cpSync(source, join(profile, 'node_modules', 'compatibility-fixture'), { recursive: true }) + writeFileSync(profileFile, JSON.stringify({ ...initial, + dependencies: { 'compatibility-fixture': `file:${source}` }, dsh: { profile: { bundles: ['compatibility-fixture'] } }, + })) + const deniedStartup = await run(['--profile', 'compatibility']) + expect(deniedStartup.stdout).not.toContain('PLUGIN_IMPORTED') + expect(deniedStartup.stderr).toContain('incompatible') + const missingConsent = await plugin(['allow-version', 'compatibility-fixture@1.0.0', '--dsh-version', version]) + expect(missingConsent.exitCode).toBe(1) + const granted = await plugin(['allow-version', 'compatibility-fixture@1.0.0', '--dsh-version', version, '--accept-risk']) + expect(granted.exitCode).toBe(0) + expect(granted.stderr).toContain('break the application or corrupt data') + // The exemption is profile metadata of its own; the dependency manifest and bundle list stay untouched. + expect(JSON.parse(readFileSync(join(profile, 'compatibility.json'), 'utf8'))).toEqual({ 'compatibility-fixture@1.0.0': [version] }) + expect(JSON.parse(readFileSync(profileFile, 'utf8'))).not.toHaveProperty('dsh.profile.versionExemptions') + expect((await plugin(['add', source, '--offline', '--ignore-scripts'])).exitCode).toBe(0) + const admittedStartup = await run(['--profile', 'compatibility']) + expect(admittedStartup.exitCode).toBe(0) + expect(admittedStartup.stdout).toContain('PLUGIN_STARTED') + expect((await plugin(['revoke-version', 'compatibility-fixture@1.0.0', '--dsh-version', version])).exitCode).toBe(0) + expect((await run(['--profile', 'compatibility'])).stdout).not.toContain('PLUGIN_IMPORTED') +}) diff --git a/apps/cli/tests/plugin.spec.ts b/apps/cli/tests/plugin.spec.ts new file mode 100644 index 0000000000..f3163d5d3d --- /dev/null +++ b/apps/cli/tests/plugin.spec.ts @@ -0,0 +1,91 @@ +/** Explicit CLI exemptions use the profile lock and never become pnpm arguments. */ +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it, onTestFinished, vi } from 'vitest' +import { getDshRuntimeVersion, readProfileVersionExemptions } from '@deepseek-ai/dsh-app-boot' +import { runPluginCommand } from '@deepseek-ai/dsh-plugin-manager/operations' +import { runPlugin } from '../src/plugin.ts' + +vi.mock('../src/profile-boot.ts', () => ({ INSTALL_ANCHOR: '/installation/package.json' })) +vi.mock('@deepseek-ai/dsh-plugin-manager/operations', async importOriginal => ({ + ...await importOriginal(), + runPluginCommand: vi.fn(), +})) + +function fixture() { + const home = mkdtempSync(join(tmpdir(), 'cli-version-exemptions-')) + vi.stubEnv('DSH_HOME', home) + const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + onTestFinished(() => { + vi.restoreAllMocks() + vi.unstubAllEnvs() + vi.mocked(runPluginCommand).mockReset() + rmSync(home, { recursive: true, force: true }) + }) + return { dir: join(home, 'profiles', 'test'), stdout, stderr } +} + +it('requires explicit acknowledgement, grants only the exact pair, lists and revokes it without pnpm', async () => { + const { dir, stdout, stderr } = fixture() + const runtime = getDshRuntimeVersion() + expect(await runPlugin('test', ['allow-version', '@example/plugin@1.2.3', '--dsh-version', runtime, '--accept-risk'])).toBe(0) + expect(stderr.mock.calls.map(call => call[0]).join('')).toMatchInlineSnapshot(` + "dsh: warning: allowing incompatible plugin versions can break the application or corrupt data. Approval applies only to the exact package and DSH versions. + " + `) + expect(stderr.mock.invocationCallOrder[0]).toBeLessThan(stdout.mock.invocationCallOrder[0]!) + expect(readProfileVersionExemptions(dir)).toEqual({ '@example/plugin@1.2.3': [runtime] }) + expect(JSON.parse(readFileSync(join(dir, 'compatibility.json'), 'utf8'))).toEqual({ '@example/plugin@1.2.3': [runtime] }) + expect(await runPlugin('test', ['version-exemptions'])).toBe(0) + expect(stdout).toHaveBeenLastCalledWith(JSON.stringify({ '@example/plugin@1.2.3': [runtime] }, undefined, 2) + '\n') + expect(await runPlugin('test', ['revoke-version', '@example/plugin@1.2.3', `--dsh-version=${runtime}`])).toBe(0) + expect(readProfileVersionExemptions(dir)).toEqual({}) + expect(runPluginCommand).not.toHaveBeenCalled() +}) + +it.each([ + ['allow-version', 'plugin@1.2.3', '--dsh-version', 'CURRENT'], + ['allow-version', 'plugin@1.2.3', '--accept-risk'], + ['allow-version', 'plugin@^1.2.3', '--dsh-version', 'CURRENT', '--accept-risk'], + ['allow-version', 'plugin@1.2.3', '--dsh-version', '999.0.0', '--accept-risk'], + ['allow-version', 'plugin@1.2.3', '--dsh-version', 'CURRENT', '--accept-risk=false'], + ['allow-version', 'plugin@1.2.3', '--dsh-version', 'CURRENT', '--accept-risk', '--accept-risk'], + ['revoke-version', 'plugin@1.2.3'], + ['version-exemptions', 'extra'], +])('rejects malformed or unacknowledged exemption command %j', async (...arguments_) => { + const { dir, stderr } = fixture() + const args = arguments_.map(value => value === 'CURRENT' ? getDshRuntimeVersion() : value) + expect(await runPlugin('test', args)).toBe(1) + expect(stderr).toHaveBeenCalled() + expect(existsSync(join(dir, 'compatibility.json'))).toBe(false) + expect(runPluginCommand).not.toHaveBeenCalled() +}) + +it('uses shipped profile defaults when listing a missing profile', async () => { + fixture() + expect(await runPlugin('web', ['version-exemptions'])).toBe(0) + expect(runPluginCommand).not.toHaveBeenCalled() +}) + +it.each([0, 1])('forwards ordinary pnpm output and exit status %s', async (exitCode) => { + const { stdout, stderr } = fixture() + vi.mocked(runPluginCommand).mockImplementation(async (_context, _args, options) => { + options.onOutput?.('pnpm output', 'stdout') + return { exitCode, output: '', truncated: false, logPath: '/profile/log' } + }) + expect(await runPlugin('test', ['list'])).toBe(exitCode) + expect(stdout).toHaveBeenCalledWith('pnpm output') + if (exitCode === 0) expect(stderr).not.toHaveBeenCalled() + else expect(stderr).toHaveBeenCalledWith('dsh: pnpm failed; diagnostics: /profile/log\n') +}) + +it('forwards all other commands unchanged and retains package diagnostics', async () => { + const { stderr } = fixture() + vi.mocked(runPluginCommand).mockResolvedValue({ exitCode: 127, output: '', truncated: false, logPath: '/profile/log' }) + const args = ['add', 'github:example/plugin'] + expect(await runPlugin('test', args)).toBe(127) + expect(runPluginCommand).toHaveBeenCalledWith(expect.objectContaining({ profile: 'test' }), args, expect.objectContaining({ execution: 'cli' })) + expect(stderr.mock.calls.map(call => call[0]).join('')).toContain('pnpm was not found') +}) diff --git a/apps/web/tests/plugin-install-registry.e2e.ts b/apps/web/tests/plugin-install-registry.e2e.ts index 16a9d7d0a9..ca1ef80e6d 100644 --- a/apps/web/tests/plugin-install-registry.e2e.ts +++ b/apps/web/tests/plugin-install-registry.e2e.ts @@ -107,8 +107,12 @@ it.each(['https://registry.npmjs.org/', MIRROR])('selects the fastest responding await dialog.getByRole('button', { name: '立即启用', exact: true }).waitFor({ timeout: 20_000 }) await dialog.getByText('版本 2.0.0', { exact: true }).waitFor() const lookups = (await readFile(join(profile, '.registry-lookups'), 'utf8')).trim().split('\n') - expect(lookups).toHaveLength(1) - expect(JSON.parse(lookups[0]!)).toContain('--registry=' + MIRROR) + .map(line => JSON.parse(line) as string[]) + // The inspection asked the mirror first, and each install attempt checked the named package's + // DSH peers at the registry that attempt would use: the mirror, then pnpm's own. + expect(lookups.map(call => call.filter(argument => argument.startsWith('--registry=')))).toEqual([ + ['--registry=' + MIRROR], ['--registry=' + MIRROR], [], + ]) await dialog.getByRole('button', { name: '查看安装详情', exact: true }).click() await dialog.getByText('Installed from the registry pnpm names', { exact: true }).waitFor() await dialog.getByText('第 1 次 · 中国大陆镜像源', { exact: true }).waitFor() diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index b2f5692dec..4c969b9423 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: eb66160994cb6d7792fb8225aa07839f988e53dc -config-catalog.zh.md: 2fbadf572626b0c1938f88ed2189a25f0a6c7268 +config-catalog.md: eb63c85c48d91bc9a79ebd909f2e0acd3a406d26 +config-catalog.zh.md: cdbfb07a2ecda96c8df282b8c49ec59ec34d8401 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index eb66160994..eb63c85c48 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2194,7 +2194,7 @@ export interface Config { } ``` -Source: [`packages/boot/plugin-manager/src/index.ts:38`](../packages/boot/plugin-manager/src/index.ts) +Source: [`packages/boot/plugin-manager/src/index.ts:40`](../packages/boot/plugin-manager/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 2fbadf5726..cdbfb07a2e 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2196,7 +2196,7 @@ export interface Config { } ``` -来源: [`packages/boot/plugin-manager/src/index.ts:38`](../packages/boot/plugin-manager/src/index.ts) +来源: [`packages/boot/plugin-manager/src/index.ts:40`](../packages/boot/plugin-manager/src/index.ts) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index e3971707b4..a86f94ab5f 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: 54a5e36bb6d2c39606c2e0263cad89293e6bb613 -event-producer-consumer.zh.md: 248aacbafc115d2c7aea8480406d2a1842ed293d +event-producer-consumer.md: fb0816fe5f66b3981eac1f0fcb2b4d418a12ecfe +event-producer-consumer.zh.md: b2f7a1b2075bd8328a45ed7eedef2c5a3eecd1ce diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 54a5e36bb6..fb0816fe5f 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -26,7 +26,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:616`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:595`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:602`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:45`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | +| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:52`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 248aacbafc..b2f7a1b207 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -28,7 +28,7 @@ | `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:616`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:595`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:602`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:43`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | +| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:52`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | diff --git a/docs/subsystems/boot.i18n.yaml b/docs/subsystems/boot.i18n.yaml index 598649b415..54a45c2704 100644 --- a/docs/subsystems/boot.i18n.yaml +++ b/docs/subsystems/boot.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/boot.md -boot.md: 5faf482b0532058e480adf13bf7fee8bb882498d -boot.zh.md: 1f50732fd36a996c6bc15b406f7e9a4ac83bcb26 +boot.md: 07391705977bc7b3cba9377e3142feea7d62f69f +boot.zh.md: 64ff36d41295f529b81f2856f2fbc77f09efa808 diff --git a/docs/subsystems/boot.md b/docs/subsystems/boot.md index 5faf482b05..0739170597 100644 --- a/docs/subsystems/boot.md +++ b/docs/subsystems/boot.md @@ -89,6 +89,21 @@ Source: [`packages/boot/hmr/src/index.ts`](../../packages/boot/hmr/src/index.ts) Manage profile files and apply their declared reload lifecycle. ```ts cordis-catalog +/** Read exact plugin-version exemptions saved in this profile. + * @returns Accepted package-name@version keys with the runtime versions they may run on, and any + * record or file problem the reader rejected, which the caller reports instead of failing. + */ +@Remote listVersionExemptions(): { exemptions: Record; warnings: string[] } + +/** Grant or revoke one exact plugin/runtime exemption and reevaluate live plugins. + * @param packageVersion Exact manifest package name followed by @ and its version; never an installation spec or alias. + * @param runtimeVersion Exact current DSH version for grants; revocation may name a previous runtime. + * @param enabled Whether to grant rather than revoke the exemption. + * @param acceptRisk Required true for grants after the user accepts possible crashes and data loss. + * @returns Saved and runtime outcomes. Startup-only profiles require restart. + */ +@Remote setVersionExemption(packageVersion: string, runtimeVersion: string, enabled: boolean, acceptRisk?: boolean): Promise + /** Read current plugins, including why a row cannot be changed through the profile patch. * @returns Current runtime entries with persistent patch targets. */ diff --git a/docs/subsystems/boot.zh.md b/docs/subsystems/boot.zh.md index 1f50732fd3..64ff36d412 100644 --- a/docs/subsystems/boot.zh.md +++ b/docs/subsystems/boot.zh.md @@ -89,6 +89,21 @@ Source: [`packages/boot/hmr/src/index.ts`](../../packages/boot/hmr/src/index.ts) Manage profile files and apply their declared reload lifecycle. ```ts cordis-catalog +/** Read exact plugin-version exemptions saved in this profile. + * @returns Accepted package-name@version keys with the runtime versions they may run on, and any + * record or file problem the reader rejected, which the caller reports instead of failing. + */ +@Remote listVersionExemptions(): { exemptions: Record; warnings: string[] } + +/** Grant or revoke one exact plugin/runtime exemption and reevaluate live plugins. + * @param packageVersion Exact manifest package name followed by @ and its version; never an installation spec or alias. + * @param runtimeVersion Exact current DSH version for grants; revocation may name a previous runtime. + * @param enabled Whether to grant rather than revoke the exemption. + * @param acceptRisk Required true for grants after the user accepts possible crashes and data loss. + * @returns Saved and runtime outcomes. Startup-only profiles require restart. + */ +@Remote setVersionExemption(packageVersion: string, runtimeVersion: string, enabled: boolean, acceptRisk?: boolean): Promise + /** Read current plugins, including why a row cannot be changed through the profile patch. * @returns Current runtime entries with persistent patch targets. */ diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index d020d136a3..2b04d1eacd 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/tool-catalog.md -tool-catalog.md: 8e1f4f7c8c8b36de366aa8a1b795106f4ae32283 -tool-catalog.zh.md: 0995196921738ad4b5a5deda0cf48c83b1d4e664 +tool-catalog.md: 3e4407192ee75b61537d9fd09f3d68381d1e803e +tool-catalog.zh.md: cf78ee4d4f0d8a8bdddbf4ad3987a18bd1f300d1 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 8e1f4f7c8c..3e4407192e 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -52,7 +52,7 @@ This table connects model-visible tool names to the plugin package and service s ### `plugin_manager` -List plugins or bundles in the current profile, enable or disable them, install a bundle, or remove an installed bundle. Every action requires danger-full-access permission or approval for this call. Approval does not change the session permission mode. Changes affect every session in this profile. List first to obtain exact identifiers. Package installation can execute allowed build scripts. Live profiles apply changes immediately; startup profiles require restart. +List plugins or bundles in the current profile, enable or disable them, install a bundle, or remove an installed bundle. Every action requires danger-full-access permission or approval for this call. Approval does not change the session permission mode. Changes affect every session in this profile. List first to obtain exact identifiers. Package installation can execute allowed build scripts. Live profiles apply changes immediately; startup profiles require restart. Incompatible DSH peer dependencies block installation and activation. Version exemptions risk crashes and data loss: warn the user and obtain explicit permission for the exact plugin and runtime versions before granting one. ```json { @@ -67,7 +67,9 @@ List plugins or bundles in the current profile, enable or disable them, install "set_plugin", "set_bundle", "install_bundle", - "remove_bundle" + "remove_bundle", + "list_version_exemptions", + "set_version_exemption" ] }, "target": { @@ -76,7 +78,15 @@ List plugins or bundles in the current profile, enable or disable them, install }, "enabled": { "type": "boolean", - "description": "Required for set operations; defaults to true for installation." + "description": "Required for set operations; defaults to true for installation. For set_version_exemption, true grants and false revokes." + }, + "runtimeVersion": { + "type": "string", + "description": "For set_version_exemption: exact DSH version from list_version_exemptions. Target must be the manifest package-name@version, not an alias or version range." + }, + "acceptRisk": { + "type": "boolean", + "description": "For granting an exemption: true only after warning the user about possible crashes and data loss and receiving explicit permission for this exact plugin/runtime pair. General installation permission is not enough." }, "approvedBuilds": { "type": "array", diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index 0995196921..cf78ee4d4f 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -56,7 +56,7 @@ ### `plugin_manager` -列出当前 profile 中的插件或组合包,启用或禁用它们,安装组合包或移除已安装的组合包。每项操作都要求 danger-full-access 权限或本次调用的批准。批准不改变会话权限模式。变更影响该 profile 的所有会话。先列出条目以获取准确标识。包安装可能运行已获批准的构建脚本。支持热更新的 profile 立即应用变更;仅启动时加载的 profile 需要重启。 +列出当前 profile 中的插件或组合包,启用或禁用它们,安装组合包或移除已安装的组合包。每项操作都要求 danger-full-access 权限或本次调用的批准。批准不改变会话权限模式。变更影响该 profile 的所有会话。先列出条目以获取准确标识。包安装可能运行已获批准的构建脚本。支持热更新的 profile 立即应用变更;仅启动时加载的 profile 需要重启。不兼容的 DSH peer 依赖会阻止安装和激活。版本豁免可能导致崩溃和数据丢失:授权前必须警告用户,并获得用户对精确插件版本与运行时版本组合的明确许可。 ```json { @@ -71,7 +71,9 @@ "set_plugin", "set_bundle", "install_bundle", - "remove_bundle" + "remove_bundle", + "list_version_exemptions", + "set_version_exemption" ] }, "target": { @@ -80,7 +82,15 @@ }, "enabled": { "type": "boolean", - "description": "Required for set operations; defaults to true for installation." + "description": "Required for set operations; defaults to true for installation. For set_version_exemption, true grants and false revokes." + }, + "runtimeVersion": { + "type": "string", + "description": "For set_version_exemption: exact DSH version from list_version_exemptions. Target must be the manifest package-name@version, not an alias or version range." + }, + "acceptRisk": { + "type": "boolean", + "description": "For granting an exemption: true only after warning the user about possible crashes and data loss and receiving explicit permission for this exact plugin/runtime pair. General installation permission is not enough." }, "approvedBuilds": { "type": "array", diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index ff61bc8512..8a77721112 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/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/boot/app-boot/README.md -README.md: 70a8e235ac5846061ea47e79e7dc575881110a50 -README.zh.md: 9849b033e01827a4c50a22b72ea20deebb0fdd85 +README.md: 729a4b1552cd28e89698580860f8c9e31043a76c +README.zh.md: c401978fdf05651479c8c6b74b01d48287fa2b63 diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index 70a8e235ac..729a4b1552 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -49,6 +49,12 @@ Import profile and bundle declaration types from [`@deepseek-ai/dsh-package-mani A profile is how one dsh installation ships different app surfaces: `web`, `headless`, `acp`, `sdk`, and `sdk-minimal` start distinct compositions from the same launcher. A profile lives at `$DSH_HOME/profiles/` and combines installable bundles with its own `cordis.patch.yml`. A bundle's `dsh.bundle.patch` names one patch file or an ordered list of files; `bundlePatchFiles` validates the declaration and `bundlePatchPaths` resolves it to absolute paths; the layer concatenates their patch lists in that order. The YAML composition enables or disables HMR. The shipped `web` template uses live reload, while the other shipped templates apply patches only at startup. `sdk-minimal` names only its standalone bundle; the other templates retain base-plus-mode stacks. `dsh --profile --from-default-profile