From 7fe9c06fa64125c97026c0b958b19675312358de Mon Sep 17 00:00:00 2001 From: Kaige-Gao Date: Wed, 23 Sep 2026 16:16:30 +0800 Subject: [PATCH 1/6] 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 48392399545a31ae492402d411b8a31ae1de61ae Mon Sep 17 00:00:00 2001 From: Kaige-Gao Date: Wed, 23 Sep 2026 17:12:03 +0800 Subject: [PATCH 2/6] 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 bd2a49a2ca3ef47d8fa2e165a129140c0851402b Mon Sep 17 00:00:00 2001 From: Kaige-Gao Date: Wed, 23 Sep 2026 17:22:55 +0800 Subject: [PATCH 3/6] 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 4/6] 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 19d6df2b2f6addbb6356206c895aef52c784499c Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 23 Sep 2026 18:13:19 +0800 Subject: [PATCH 5/6] 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 6/6] 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