Merge pull request #5029 from deepseek-harness/feat/chat-image-preview-4941

feat(chat): 图片链接悬停预览与内联图片放大
This commit is contained in:
imccyu authored and GitHub committed 2026-09-23 19:27:02 +08:00
commit 9b510d89a5
49 files changed
+646 -101

No files matched your search

@@ -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"
@@ -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"
@@ -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](<path/to/image.png>) when an image supports an explanation or comparison. Use [Description](<path/to/image.png>) 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.
+53 -6
View File
@@ -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,59 @@ 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 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))
.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.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)
})
@@ -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
+1 -1
View File
@@ -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 |
</details>
+1 -1
View File
@@ -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 |
</details>
@@ -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. */
@@ -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'
@@ -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'
/**
+2 -2
View File
@@ -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: b3d649c319c58ff807f11cc6b6d0e634eb931711
README.zh.md: 6feb9d459299755968a3d99d5789d0cfae8b6e8e
README.md: cca209016220560b7f39831e11ebb677da515ea9
README.zh.md: 0b97e490f5a535c3b871ec7c6344d4299976b683
+2
View File
@@ -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.
<a id="system-prompt-row"></a>
+2
View File
@@ -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 当前的输入触发源解析。两者悬停或聚焦时均使用正文文件链接的虚线下划线。会话、目录和命令标签仍只作为引用展示。
<a id="system-prompt-row"></a>
@@ -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,20 @@ import { useSearchableHidden } from './searchable-hidden.ts'
import css from './AssistantMarkdown.module.css'
/**
* Resolve an authored POSIX image path against the document's file API.
* 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.
* @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 {
@@ -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({
</button>
</div>
)}
<MarkdownDelegateProvider openExternalLink={openExternalLink} openFile={requestOpenFile}>
<MarkdownDelegateProvider openExternalLink={openExternalLink} openFile={requestOpenFile} fileImages={fileImages}>
<ChatNodeList
entries={entries}
pendingInputs={pendingInputs}
@@ -66,6 +66,11 @@ export const zh = {
'message.turnProcess.took': '用时 {duration}',
'message.turnProcess.failed': '处理失败',
'view.chat': '对话',
'image.open': '查看大图',
'image.loading': '加载图片…',
'image.failed': '图片无法预览',
'image.dialog': '图片预览',
'image.close': '关闭图片预览',
'number.groupSeparator': ',',
'duration.compactSeconds': '{seconds}秒',
'duration.compactMinutes': '{minutes}分{seconds}秒',
@@ -241,6 +246,11 @@ export const en = {
'message.turnProcess.took': 'Took {duration}',
'message.turnProcess.failed': 'Failed',
'view.chat': 'Chat',
'image.open': 'View full image',
'image.loading': 'Loading image…',
'image.failed': 'Image preview unavailable',
'image.dialog': 'Image preview',
'image.close': 'Close image preview',
'number.groupSeparator': ',',
'duration.compactSeconds': '{seconds}s',
'duration.compactMinutes': '{minutes}m{seconds}s',
@@ -43,7 +43,7 @@ describe('localPathMediaUrl', () => {
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()
})
@@ -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: 8a0c51a8c88418862438342fc2a0faf41055dd00
README.zh.md: 3c01c405d390e6fcd06665d9a53face55f5c39dc
README.md: 786495d76098ce134ac378858c177cc498bd4c9f
README.zh.md: 9a1f000845ffab755ce23bd570cbe1a79b133676
+2 -2
View File
@@ -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: 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 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.
+2 -2
View File
@@ -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。
+2 -2
View File
@@ -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](<path/to/image.png>) when an image supports an explanation or comparison. Use [Description](<path/to/image.png>) 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, 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.'
@@ -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](<path/to/image.png>) when an image supports an explanation or comparison. Use [Description](<path/to/image.png>) 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']) {
@@ -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: fbb8ef676e55d1ef2c3c5c8a6c91a12a3c8fe556
README.zh.md: 822ff17c3c4c6b02339cdacd03d9c52c67a738c7
+4 -1
View File
@@ -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` | 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. |
@@ -85,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.
@@ -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. 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
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.
+4 -1
View File
@@ -54,6 +54,7 @@ kind: "package-library"
| `OnboardingSurface` | 首次运行的引导舞台,期间保持应用根节点 inert。 |
| `Tooltip` | 锚定在克隆子元素上的悬停文本;可通过 `portal` 渲染到外层,避免被容器裁剪,或受祖先层叠上下文限制其 z-index。 |
| `HoverCard` | 指针可停留、可选中的悬停预览;可选带复制按钮。 |
| `ImageLightbox` | 共享图片浮层,支持焦点恢复与 Esc 关闭。 |
| `Toast` | 顶部居中的瞬时横幅,保持时长由所有者的 `holdMs` 决定。 |
| `SettingsForm`、`SettingsValueField`、`SettingsSecretField` | 插件设置页的框架与控件:框架以 `labels` 接收文案,只在按钮点击时保存,卸载即丢弃;值字段显示暂存文本以及已覆盖标签和重置;密文字段每次为空,只报告是否已配置。 |
| `SettingsFormModel`、`settingsNumberField`、`settingsTextField` | 这类页面背后基于设置 scope 的暂存编辑模型:草稿先暂存、保存时写入,字段是否被覆盖看用户层是否含有它,未落地的保存保留草稿。 |
@@ -85,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 颜色,以点状下划线区分链接。代码标题栏随代码块滚动。表格和公式仍然启用,使用周围文字的字号,并在可用宽度内横向滚动。两个变体共享解析器与流式缓存。
@@ -95,6 +96,8 @@ kind: "package-library"
`JsonTree` 把折叠字符串限制为 `collapsedStringLines` 行(默认三行)。展开后显示原始文本、保留同级逗号,并限制在窗口与外层滚动容器内;尺寸变化和祖先滚动事件会更新此限制。行复制反馈独立于 JSON 值渲染更新;尚未完成的剪贴板写入不会更新另一行或已卸载的树。
`ImageLightbox` 是共享原图浮层,支持焦点恢复与 Esc 关闭。包内缩略图渲染器由调用方提供加载及失败文案。`HoverCard.inline` 使文件链接保持在文本流内,并使用共享菜单材质、键盘可见焦点,并在锚点上方或下方定位而不遮挡锚点。即使焦点位于其他位置,Esc 也会关闭已打开的缩略图;后续 Esc 按键继续传给 owner。`MarkdownDelegateProvider.fileImages` 提供已解码路径解析器与完整图片文案:消息落定后的图片链接支持悬停预览,独立图片支持点击放大。仅包含图片的链接保留单一导航目标。解析器仅恢复完整、未转义、独立成段且带明确图片扩展名的含裸空格本地图片引用;代码与有歧义的目标保持原文。
### 本地化文案
这些原子组件无法读取应用 locale,因此每段面向用户的文案都必须通过 label prop 提供。`HoverCard`、`TerminalBlock`、`JsonTree`、`CodeBlock`、`MarkdownText`、`JsonBlock`、`ConnectionIndicator`、`Modal`、`DiffBlock`、`ReadBlock`、`SearchBlock` 与 `WebBlock` 接收完整的本地化 label。本包不拥有语言回退;遗漏会导致类型检查失败,各功能会把带类型的 `t` 席位映射到 primitive 的 label 接口。
@@ -79,3 +79,16 @@
@media (prefers-reduced-motion: reduce) {
.preview { transition: none; }
}
.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;
color: var(--dsw-alias-label-primary);
background: var(--dsw-specific-menu);
backdrop-filter: var(--dsw-menu-backdrop-filter);
box-shadow: var(--dsw-elevation-panel);
}
+41 -13
View File
@@ -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.
@@ -32,9 +34,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
@@ -112,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.
@@ -148,11 +154,27 @@ export function HoverCard({
})
return
}
if (inline) {
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 - width - VIEWPORT_MARGIN)),
top: onTop ? r.top - ANCHOR_GAP - Math.min(height, maxHeight) : belowTop,
width, maxHeight,
})
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,19 +185,19 @@ 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
// 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<void> => {
if (copied || copyingRef.current) return
@@ -202,7 +224,7 @@ export function HoverCard({
const card = open && pos !== null && (
<div
ref={cardRef}
className={clsx(css.card, variant === 'preview' && css.preview, copyable && css.copyable, copied && css.feedback)}
className={clsx(css.card, variant === 'preview' && css.preview, inline && css.media, copyable && css.copyable, copied && css.feedback)}
data-closing={closing || undefined}
style={{
...pos, minHeight: copied && copyHeightRef.current !== null ? copyHeightRef.current : undefined,
@@ -237,9 +259,15 @@ export function HoverCard({
return (
<span
ref={rootRef}
className={css.root}
onPointerEnter={() => {
if (disabled) return
className={clsx(css.root, inline && css.inline)}
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}
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()
@@ -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; }
@@ -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,11 +37,12 @@ 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)
window.addEventListener('keydown', onKeyDown, true)
return () => {
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keydown', onKeyDown, true)
restoreRef.current?.focus()
}
}, [onClose])
@@ -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;
}
@@ -0,0 +1,36 @@
/** Passive, contained image preview for Markdown image links. */
import { useState } from 'react'
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.
* @returns A contained preview with loading or failure status.
*/
export function ImagePreview({ src, alt, loadingLabel, failedLabel }: {
src: string
alt: string
loadingLabel: string
failedLabel: string
}) {
return <Preview key={src} src={src} alt={alt} loadingLabel={loadingLabel} failedLabel={failedLabel} />
}
function Preview({ src, alt, loadingLabel, failedLabel }: {
src: string
alt: string
loadingLabel: string
failedLabel: string
}) {
const [state, setState] = useState<'loading' | 'ready' | 'failed'>('loading')
return <span className={css.frame}>
{state !== 'failed' && <img src={src} alt={alt} loading="lazy" decoding="async" referrerPolicy="no-referrer"
className={css.image} data-ready={state === 'ready' || undefined}
onLoad={() => { setState('ready') }} onError={() => { setState('failed') }} />}
{state !== 'ready' && <span className={css.status} role="status">
{state === 'loading' && <IconLoadingOutlineRegular size={16} />}
<span>{state === 'loading' ? loadingLabel : failedLabel}</span>
</span>}
</span>
}
@@ -102,3 +102,5 @@ 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'
@@ -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 (
<MarkdownDelegateContext.Provider value={delegate}>
{children}
@@ -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;
}
@@ -0,0 +1,29 @@
/** Recover standalone local image references with bare spaces without changing code or source offsets. */
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.
* @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 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<>()[\]"']+\.[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) {
for (const child of node.children) visit(child)
}
}
visit(root)
return root
}
@@ -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)
}
@@ -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,28 @@ 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 (
// 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 = (
<button
type="button"
className={clsx(css.fileMention, css.fileLink)}
title={file.path}
title={src === undefined ? file.path : undefined}
onClick={() => { openFile(file.path, file.line === undefined ? undefined : { line: file.line }) }}
>
{glyph && <LinkIconMedium kind={classifyLinkPath(file.path)} className={css.linkIcon} />}
{children}
</button>
)
if (src === undefined || preview === undefined) return anchor
return <HoverCard inline anchor={anchor}
content={<>
<ImagePreview src={src} alt={file.path} loadingLabel={preview.labels.loading} failedLabel={preview.labels.failed} />
<span className={css.previewName}>{file.path.split(/[\\/]/u).pop()}</span>
</>} />
}
/**
@@ -610,28 +622,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 <span key={key} className={css.imageAlt}>{alt}</span>
}
return <MarkdownImage key={`${key}:${imageSrc}`} src={imageSrc} alt={alt} destination={url} />
return <MarkdownImage key={`${key}:${url}`} destination={url} alt={alt}
pathImages={context.pathImages} streaming={context.streaming} inLink={context.inLink === true} />
}
/** 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 <span className={css.imageAlt}>{alt}</span>
return <LoadedMarkdownImage key={src} src={src} alt={alt} destination={destination} preview={inLink ? undefined : fileImages} />
}
function LoadedMarkdownImage({ src, alt, destination, preview }: {
src: string
alt: string
destination: string
preview: ReturnType<typeof useMarkdownDelegate>['fileImages']
}): ReactNode {
const [failed, setFailed] = useState(false)
if (failed) return <span className={css.imageAlt}>{alt || destination}</span>
return (
<img
className={css.image}
src={src}
alt={alt}
onError={() => { setFailed(true) }}
loading="lazy"
decoding="async"
referrerPolicy="no-referrer"
/>
)
const [open, setOpen] = useState(false)
const close = useCallback(() => { setOpen(false) }, [])
if (failed) return <span className={css.imageAlt}>{preview === undefined ? '' : `${preview.labels.failed} · `}{alt || destination}</span>
const img = <img className={css.image} src={src} alt={alt}
onError={() => { setFailed(true) }} loading="lazy" decoding="async" referrerPolicy="no-referrer" />
if (preview === undefined) return img
return <>
<button type="button" className={css.imageButton} title={preview.labels.open}
aria-label={alt ? `${preview.labels.open}: ${alt}` : preview.labels.open}
onClick={() => { setOpen(true) }}>{img}</button>
{open && <ImageLightbox src={src} alt={alt} labels={preview.labels} onClose={close} />}
</>
}
/** The bracketed source text a reference reverts to when its definition is missing. */
@@ -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 = <><button>first</button><button>second</button></>
const view = render(<HoverCard inline anchor={anchor} content={<span>thumbnail</span>} />)
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(<HoverCard inline disabled anchor={anchor} content={<span>thumbnail</span>} />)
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(<div onKeyDown={ownerKey}>
<button>outside</button>
<HoverCard inline anchor={<button>image link</button>} content={<span>thumbnail</span>} />
</div>)
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(<HoverCard inline anchor={<button>image link</button>} content={<span>thumbnail</span>} />)
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)
})
@@ -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()
@@ -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(<ImagePreview {...props} />)
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(<ImagePreview {...props} src="/two.png" />)
expect(screen.getByRole('status').textContent).toBe('Loading image')
fireEvent.load(screen.getByRole('img'))
expect(screen.queryByRole('status')).toBeNull()
expect(screen.queryByRole('button')).toBeNull()
})
@@ -0,0 +1,117 @@
// @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(); 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) {
const openFile = vi.fn()
const view = render(<MarkdownDelegateProvider openFile={openFile} fileImages={fileImages}>
<MarkdownText text={text} streaming={streaming} />
</MarkdownDelegateProvider>)
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' })
// 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' })
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"')
}
})
it('keeps an unavailable image link navigable without showing a hover preview', () => {
const openFile = vi.fn()
render(<MarkdownDelegateProvider openFile={openFile} fileImages={{ ...fileImages, resolve: () => undefined }}>
<MarkdownText text="[Image](relative.png)" />
</MarkdownDelegateProvider>)
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"')
})
@@ -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.
@@ -9,7 +9,6 @@ import { isAbsoluteWorkspacePath, pathPartsOf } from '@deepseek-ai/dsh-util-work
* @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,12 +17,12 @@ 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)) {
if (documentPath === undefined) return undefined
path = pathPartsOf(documentPath).directory + path
}
return new URL(`api/file?path=${encodeURIComponent(path)}`, base).href
return fileMediaUrl(base, path)
}
@@ -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
+2
View File
@@ -26,6 +26,8 @@ A resource address is `dsh-resource://<type>/…`, 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
@@ -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;原生文件卡片路径原样传入。
-----
## 已知限制与暂缓事项
+12
View File
@@ -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
}
@@ -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()
})
})
@@ -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](<path/to/image.png>) when an image supports an explanation or comparison. Use [Description](<path/to/image.png>) 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.
@@ -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](<path/to/image.png>) when an image supports an explanation or comparison. Use [Description](<path/to/image.png>) 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.