feat(client): preview image attachments in a chat-style lightbox

The attachment card's thumbnail click used to open the full image in a new
browser tab. Now the whole card (tile + metadata column) is one click
target, and clicking it opens an in-page preview modal mirroring the chat
history's ImageLightbox recipe: body portal via the module-table react-dom,
blurred mask on the same dsw theme variables, contain-fit image, circular
close control (IconCloseOutline16 from the seeded ui-primitives), Escape/
mask close, and focus restored to the opener. The ui-attachment package
itself is not seeded into the browser module table and cross-plugin value
imports are gated off, so the recipe is ported onto the plugin's lc-*
classes with fallbacks; load failures still retry on click.
This commit is contained in:
Bowen Liang committed 2026-08-24 02:36:41 +08:00
1 parent f1fa552876
commit e6dcfeea1c
9 files changed
+167 -39

No files matched your search

+85 -28
View File
@@ -10,8 +10,9 @@
*/
import type * as ReactNS from 'react'
import { IconCloseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import { fmtBytes } from '../format'
import { React } from '../react'
import { React, ReactDOM } from '../react'
import type { ImageLoader, ImageRefLike } from '../services'
import { estimateImageTokens } from '../../shared/imageTokens'
import type { ViewKit } from '../viewkit'
@@ -57,12 +58,56 @@ export function imageRefOf(block: unknown): ImageRefLike | null {
}
/**
* One attachment card: a 64px cover tile (click opens the full image in a
* new tab; load failures retry on click) beside a metadata column — display
* name, then one labeled row per known fact: Raw (the pre-normalization
* raster dsh 0.1.1 records when normalization reduced the image), Sent (the
* normalized raster the model receives, with its stored byte size), and the
* estimated provider-billed tokens. Unknown facts leave no row behind.
* Document-level original-image preview — the chat history's ImageLightbox
* recipe (dsh ui-attachment, which the browser module table does not seed)
* ported onto the plugin's lc-* classes: body portal so a transformed or
* filtered ancestor cannot trap the fixed backdrop, blurred mask layer,
* contain-fit image, circular close control, Escape/mask close, and focus
* restored to the opener on unmount.
*/
function AttachmentLightbox(props: {
src: string
alt: string
labels: { dialog: string; close: string }
onClose: () => void
}): ReactNS.ReactElement {
const { src, alt, labels, onClose } = props
const closeRef = React.useRef<HTMLButtonElement | null>(null)
const restoreRef = React.useRef<HTMLElement | null>(null)
React.useEffect(() => {
restoreRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
closeRef.current?.focus()
const onKeyDown = (event: KeyboardEvent): void => {
if (event.key === 'Escape') onClose()
}
window.addEventListener('keydown', onKeyDown)
return () => {
window.removeEventListener('keydown', onKeyDown)
restoreRef.current?.focus()
}
}, [onClose])
return ReactDOM.createPortal(
<div className="lc-att-lightbox" role="dialog" aria-modal="true" aria-label={labels.dialog}>
<div className="lc-att-lightbox-mask" aria-hidden="true" onMouseDown={onClose} />
<img className="lc-att-lightbox-img" src={src} alt={alt} />
<button ref={closeRef} type="button" className="lc-att-lightbox-close" aria-label={labels.close} onClick={onClose}>
<IconCloseOutline16 size={16} />
</button>
</div>,
document.body,
)
}
/**
* One attachment card: the WHOLE card is the click target — a 64px cover
* tile beside a metadata column (display name, then one labeled row per
* known fact: Raw, the pre-normalization raster dsh 0.1.1 records when
* normalization reduced the image; Sent, the normalized raster the model
* receives, with its stored byte size; and the estimated provider-billed
* tokens). Clicking opens the chat-style lightbox preview (load failures
* retry on click instead). Unknown facts leave no row behind.
*/
export function makeImageCard(kit: ViewKit): ImageKit['Card'] {
const { t, fmt } = kit
@@ -73,6 +118,8 @@ export function makeImageCard(kit: ViewKit): ImageKit['Card'] {
// Retry re-arms the one load effect, so every attempt runs under the
// same liveness guard and the same reset.
const [attempt, setAttempt] = React.useState(0)
const [preview, setPreview] = React.useState(false)
const closePreview = React.useCallback(() => { setPreview(false) }, [])
React.useEffect(() => {
if (load === undefined) return
@@ -107,35 +154,45 @@ export function makeImageCard(kit: ViewKit): ImageKit['Card'] {
: null
if (tokens !== null) rows.push({ label: t('attach.token'), value: `≈${fmt(tokens)}`, tip: t('attach.tokensTip') })
const open = (): void => {
if (src === null) return
try { window.open(src, '_blank', 'noopener') } catch { /* popup blocked: ignore */ }
const activate = (): void => {
if (error) { setAttempt(a => a + 1); return }
if (src !== null) setPreview(true)
}
return (
<div className="lc-att-item">
<>
<button
type="button"
className="lc-att-thumb"
className="lc-att-item"
title={error ? t('attach.loadFailed') : t('attach.open')}
onClick={error ? () => { setAttempt(a => a + 1) } : open}
onClick={activate}
>
{src !== null
? <img src={src} alt={name} />
: (
<span className={error ? 'lc-att-err' : 'lc-att-ph'}>
{error ? '⚠' : load === undefined ? '🖼' : t('attach.loading')}
<span className="lc-att-thumb">
{src !== null
? <img src={src} alt={name} />
: (
<span className={error ? 'lc-att-err' : 'lc-att-ph'}>
{error ? '⚠' : load === undefined ? '🖼' : t('attach.loading')}
</span>
)}
</span>
<span className="lc-att-meta">
<span className="lc-att-name" title={name}>{name}</span>
{rows.map(r => (
<span key={r.label} className="lc-att-row" title={r.tip}>
<b className="lc-att-row-label">{r.label}</b>{r.value}
</span>
)}
))}
</span>
</button>
<div className="lc-att-meta">
<span className="lc-att-name" title={name}>{name}</span>
{rows.map(r => (
<span key={r.label} className="lc-att-row" title={r.tip}>
<b className="lc-att-row-label">{r.label}</b>{r.value}
</span>
))}
</div>
</div>
{preview && src !== null && (
<AttachmentLightbox
src={src}
alt={name}
labels={{ dialog: t('attach.preview'), close: t('attach.close') }}
onClose={closePreview}
/>
)}
</>
)
}
}
+4
View File
@@ -119,6 +119,8 @@ export const DICT_ZH: Record<string, string> = {
'attach.other': '其他内容',
'attach.image': '图片',
'attach.open': '查看原图',
'attach.preview': '图片预览',
'attach.close': '关闭',
'attach.loading': '…',
'attach.loadFailed': '加载失败 · 点击重试',
'attach.raw': '原图',
@@ -241,6 +243,8 @@ export const DICT_EN: Record<string, string> = {
'attach.other': 'Other content',
'attach.image': 'Image',
'attach.open': 'Open full image',
'attach.preview': 'Image preview',
'attach.close': 'Close',
'attach.loading': '…',
'attach.loadFailed': 'Load failed · click to retry',
'attach.raw': 'Raw',
+7
View File
@@ -10,3 +10,10 @@ import type * as ReactNS from 'react'
// oxlint-disable-next-line typescript/no-require-imports
export const React: typeof ReactNS = require('react') as typeof ReactNS
export const h = React.createElement
// react-dom rides the same platform seed; only the portal API is used (the
// image lightbox escapes transformed ancestors by portaling to body).
// oxlint-disable-next-line typescript/no-require-imports
export const ReactDOM = require('react-dom') as {
createPortal: (children: ReactNS.ReactNode, container: Element) => ReactNS.ReactPortal
}
+15 -3
View File
@@ -451,15 +451,27 @@
tile are new. The grid is two EQUAL-WIDTH columns
(minmax(0,…) so long names shrink instead of stretching the track); each
image is a 64px cover tile (the chat history's tile size) beside a name +
dims + size + estimated token cost column. ----
dims + size + estimated token cost column; the whole card is the click
target. ----
*/
.lc-att-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; padding: 8px 10px; }
.lc-att-item { display: flex; gap: 8px; align-items: center; min-width: 0; }
.lc-att-thumb { width: 64px; height: 64px; flex: none; padding: 0; display: flex; align-items: center; justify-content: center; border: 1px solid var(--dsw-alias-border-l1); border-radius: 6px; overflow: hidden; background: var(--dsw-alias-bg-layer-1); cursor: pointer; font: inherit; }
.lc-att-item { display: flex; gap: 8px; align-items: center; min-width: 0; width: 100%; box-sizing: border-box; padding: 4px; border: 0; border-radius: 8px; background: transparent; font: inherit; text-align: left; cursor: pointer; }
.lc-att-item:hover { background: var(--dsw-alias-bg-layer-2); }
.lc-att-thumb { width: 64px; height: 64px; flex: none; padding: 0; display: flex; align-items: center; justify-content: center; border: 1px solid var(--dsw-alias-border-l1); border-radius: 6px; overflow: hidden; background: var(--dsw-alias-bg-layer-1); }
.lc-att-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.lc-att-ph, .lc-att-err { font-size: 10px; color: var(--dsw-alias-label-secondary); text-align: center; padding: 2px; }
.lc-att-meta { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.lc-att-name { font-size: 12px; color: var(--dsw-alias-label-primary); line-height: 1.3; overflow-wrap: anywhere; word-break: break-word; }
.lc-att-row { font-size: 11px; color: var(--dsw-alias-label-secondary); font-variant-numeric: tabular-nums; }
.lc-att-row-label { display: inline-block; min-width: 3.2em; font-weight: 400; }
/*
---- Image preview lightbox: the chat history's original-image preview
recipe (fixed backdrop grid + separate blurred mask layer, centered
contain-fit image, circular close control) on the plugin's lc-* namespace,
reusing the same dsw theme variables (with fallbacks). ----
*/
.lc-att-lightbox { position: fixed; inset: 0; z-index: 1000; display: grid; place-items: center; padding: 40px; }
.lc-att-lightbox-mask { position: absolute; inset: 0; background: var(--dsw-alias-bg-mask-1, rgba(0, 0, 0, 0.45)); backdrop-filter: var(--dsw-mask-blur, blur(2px)); }
.lc-att-lightbox-img { position: relative; max-width: min(100%, 1600px); max-height: calc(100vh - 80px); object-fit: contain; border-radius: 12px; background: var(--dsw-specific-input-major, var(--dsw-alias-bg-layer-1)); box-shadow: var(--dsw-shadow-lv3, 0 12px 32px rgba(0, 0, 0, 0.4)); }
.lc-att-lightbox-close { position: fixed; top: 20px; right: 20px; z-index: 1; display: grid; place-items: center; width: 36px; height: 36px; padding: 0; border: 1px solid var(--dsw-alias-border-l2-darkmode-thin, var(--dsw-alias-border-l1)); border-radius: 999px; background: var(--dsw-specific-input-major, var(--dsw-alias-bg-layer-1)); color: var(--dsw-alias-label-primary); cursor: pointer; }
[data-conversation-scroll]:has(.lc-root) > [data-composer-seat]:not(:has([data-approval-key], [data-question-key], [data-plan-review-key])) { display: none; }
+4 -2
View File
@@ -40,9 +40,11 @@ globalThis.window = {
globalThis.document = fakeDoc
const require = (spec) => {
assert.ok(spec === 'react' || spec === '@deepseek-ai/dsh-client-ui-primitives',
assert.ok(spec === 'react' || spec === 'react-dom' || spec === '@deepseek-ai/dsh-client-ui-primitives',
`bundle must only require platform modules (got "${spec}")`)
return spec === 'react' ? fakeReact : FAKE_PRIMITIVES
if (spec === 'react') return fakeReact
if (spec === 'react-dom') return { createPortal: () => null }
return FAKE_PRIMITIVES
}
// ---- materialize the bundle the way the loader does ----
+25 -3
View File
@@ -50,11 +50,12 @@ export function makeFakeDoc() {
export const FAKE_PRIMITIVES = {
IconPlusOutline16: (p) => ({ kind: 'icon', name: 'IconPlusOutline16', props: p }),
IconBranchOutline16: (p) => ({ kind: 'icon', name: 'IconBranchOutline16', props: p }),
IconCloseOutline16: (p) => ({ kind: 'icon', name: 'IconCloseOutline16', props: p }),
MarkdownText: (p) => ({ kind: 'element', args: ['div', { className: 'lc-md-stub' }, p.text] }),
}
export const DICT_FOR_TEST = { 'tab': 'Context', 'loading': '…', 'error': 'ERR:', 'error.retry': 'retry', 'detail.step': 'Turn {t} · Step {s}', 'gran.step': 'Step', 'gran.turn': 'Turn', 'detail.turn': 'Turn {t} · {n} steps', 'detail.lastStep': 'last step', 'overview.used': 'of context used', 'overview.free': 'Free window', 'events.at': 'Turn {t} · Step {s}', 'events.range': 'Turn {t} · Step {a}→{b}', 'events.rangeTo': 'Turn {a} · Step {as} → Turn {b} · Step {bs}', 'stats.recycleSub': '{c} compactions · {p} prunes', 'tip.step': 'Turn {t} · Step {s}', 'tip.turn': 'Turn {t} · {n} steps', 'tip.total': 'total ≈ {n}', 'tip.actual': ' (actual {n})', 'trend.title': 'History', 'trend.empty': 'empty trend', 'cmd.close': 'Close', 'cat.user': 'User', 'browser.live': 'Live (next request)', 'browser.liveNow': 'Live · next request', 'browser.items': '{n} items', 'browser.noContent': 'outside the loaded window', 'browser.loading': 'loading older history', 'browser.preview': 'preview', 'browser.noHeader': 'older plugin build',
'browser.deltaHint': 'vs previous turn', 'overview.compactReserve': 'compact reserve {pct}%', 'tool.desc': 'Description', 'tool.params': 'Parameters', 'tool.paramsEmpty': '(no parameters)', 'tool.jsonToggle': 'View Raw JSON', 'tool.jsonHide': 'Collapse', 'rich.raw': 'Raw', 'rich.md': 'Markdown', 'rich.toMd': 'View as Markdown', 'rich.toRaw': 'View Raw Text', 'block.thinking': 'Reasoning', 'block.answer': 'Response', 'attach.images': 'Images', 'attach.other': 'Other content', 'attach.image': 'Image', 'attach.open': 'Open full image', 'attach.loading': '…', 'attach.loadFailed': 'Load failed · click to retry', 'attach.raw': 'Raw', 'attach.sent': 'Sent', 'attach.token': 'Token', 'attach.tokensTip': 'estimated tokens' }
'browser.deltaHint': 'vs previous turn', 'overview.compactReserve': 'compact reserve {pct}%', 'tool.desc': 'Description', 'tool.params': 'Parameters', 'tool.paramsEmpty': '(no parameters)', 'tool.jsonToggle': 'View Raw JSON', 'tool.jsonHide': 'Collapse', 'rich.raw': 'Raw', 'rich.md': 'Markdown', 'rich.toMd': 'View as Markdown', 'rich.toRaw': 'View Raw Text', 'block.thinking': 'Reasoning', 'block.answer': 'Response', 'attach.images': 'Images', 'attach.other': 'Other content', 'attach.image': 'Image', 'attach.open': 'Open full image', 'attach.preview': 'Image preview', 'attach.close': 'Close', 'attach.loading': '…', 'attach.loadFailed': 'Load failed · click to retry', 'attach.raw': 'Raw', 'attach.sent': 'Sent', 'attach.token': 'Token', 'attach.tokensTip': 'estimated tokens' }
/** Base timeline fixture: 4 requests across 3 turns, 2 live nodes. */
export const snapshot = {
@@ -153,6 +154,8 @@ export async function bootViewBed() {
let hookCursor = 0
const statefulReact = {
createElement: (...args) => ({ kind: 'element', args }),
// Fragment marker: evaluate() flattens it to its evaluated children.
Fragment: 'lc-fragment',
useCallback: (fn) => fn,
useState(init) {
const i = hookCursor++
@@ -195,9 +198,12 @@ export async function bootViewBed() {
},
}
const requireStateful = (spec) => {
assert.ok(spec === 'react' || spec === '@deepseek-ai/dsh-client-ui-primitives',
assert.ok(spec === 'react' || spec === 'react-dom' || spec === '@deepseek-ai/dsh-client-ui-primitives',
`bundle must only require platform modules (got "${spec}")`)
return spec === 'react' ? statefulReact : FAKE_PRIMITIVES
if (spec === 'react') return statefulReact
// Portals flatten to their tree: the lightbox stays walkable in tests.
if (spec === 'react-dom') return { createPortal: (node) => node }
return FAKE_PRIMITIVES
}
const pluginExports = handoff.factory(requireStateful)
@@ -244,6 +250,22 @@ export async function bootViewBed() {
if (node === null || typeof node !== 'object') return node
if (node.kind === 'element') {
const [type, props, ...children] = node.args
// A fragment flattens to its evaluated children (an array), which every
// tree walker here already understands.
if (type === statefulReact.Fragment) {
const kids = []
let f = 0
const walkFragment = (c) => {
if (Array.isArray(c)) { for (const x of c) walkFragment(x); return }
if (c !== null && typeof c === 'object' && c.kind === 'element' && typeof c.args[0] === 'function') {
kids.push(evaluate(c, path, f++))
} else {
kids.push(evaluate(c, path, f))
}
}
for (const c of children) walkFragment(c)
return kids
}
// The fake createElement keeps children in args; real React puts them
// on props.children (single child as-is, several as an array) — merge
// them back for BOTH component kinds, exactly what React would pass.
+3
View File
@@ -19,6 +19,7 @@ import { test } from 'vitest'
import { readFile } from 'node:fs/promises'
import { JSDOM } from 'jsdom'
import React from 'react'
import * as ReactDOM from 'react-dom'
import { createRoot } from 'react-dom/client'
const bundle = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8')
@@ -31,10 +32,12 @@ function boot() {
globalThis.window.__ModuleLoader__ = { load(h) { handoff = h } }
const require = (spec) => {
if (spec === 'react') return React
if (spec === 'react-dom') return ReactDOM
if (spec === '@deepseek-ai/dsh-client-ui-primitives') {
return {
IconPlusOutline16: (p) => React.createElement('svg', p, null),
IconBranchOutline16: (p) => React.createElement('svg', p, null),
IconCloseOutline16: (p) => React.createElement('svg', p, null),
}
}
throw new Error(`unexpected module: ${spec}`)
+21 -3
View File
@@ -64,12 +64,20 @@ test('image attachment cards: card layout, metadata degradation, resolveImage th
assert.match(textOf(byClass(trImg, 'lc-ts-card')[2]), /file:\/\/x/, 'unrecognized blocks keep the raw JSON card')
assert.match(textOf(byClass(trImg, 'lc-br-content')[0]), /WHAT-IS-THIS/, 'prose stays in the text card')
assert.equal(byClass(trImg, 'lc-att-thumb')[0].args.slice(2).filter(c => c !== null && typeof c === 'object' && c.args[0] === 'img').length, 0, 'no thumbnail img without the loader')
// The whole card is the click target (a single button wrapping tile +
// metadata); clicking before anything loads opens nothing.
const cardEl = byClass(trImg, 'lc-att-item')[0]
assert.equal(cardEl.args[0], 'button', 'the whole card is one button')
cardEl.args[1].onClick()
trImg = renderView()
assert.equal(byClass(trImg, 'lc-att-lightbox').length, 0, 'no preview without a loaded image')
// Arm the conversation service: the loader resolves a display URL per ref.
bed.conversationHolder = { resolveImage: async (sid, att) => 'blob:' + att.attachmentId }
trImg = renderView()
// Drive every ImageCard's load effect (slot 3 = its useEffect), then flush.
// Drive every ImageCard's load effect (slot 4 = its useEffect, after the
// src/error/attempt/preview states), then flush.
for (const [key, slots] of hookStates) {
if (key.includes('ImageCard') && slots[3] !== undefined) slots[3].effect()
if (key.includes('ImageCard') && slots[4] !== undefined) slots[4].effect()
}
await new Promise(r => setTimeout(r, 0))
await new Promise(r => setTimeout(r, 0))
@@ -78,6 +86,16 @@ test('image attachment cards: card layout, metadata degradation, resolveImage th
.flatMap(t => t.args.slice(2))
.filter(c => c !== null && typeof c === 'object' && c.args[0] === 'img')
assert.deepEqual(thumbImgs.map(i => i.args[1].src), ['blob:a1', 'blob:a2'], 'thumbnails resolve through resolveImage')
// Clicking the loaded card opens the chat-style lightbox preview (no new
// tab); the mask press closes it again.
byClass(trImg, 'lc-att-item')[0].args[1].onClick()
trImg = renderView()
assert.equal(byClass(trImg, 'lc-att-lightbox').length, 1, 'click opens the lightbox preview')
assert.equal(byClass(trImg, 'lc-att-lightbox-img')[0].args[1].src, 'blob:a1', 'lightbox shows the clicked image')
assert.equal(byClass(trImg, 'lc-att-lightbox-close').length, 1, 'lightbox has a close control')
byClass(trImg, 'lc-att-lightbox-mask')[0].args[1].onMouseDown()
trImg = renderView()
assert.equal(byClass(trImg, 'lc-att-lightbox').length, 0, 'mask press closes the preview')
console.log('✔ image attachment card test passed (card layout, metadata degradation, resolveImage thumbnails)')
console.log('✔ image attachment card test passed (card layout, metadata degradation, resolveImage thumbnails, lightbox preview)')
})
+3
View File
@@ -9,6 +9,7 @@ import { test } from 'vitest'
import { readFile } from 'node:fs/promises'
import { JSDOM } from 'jsdom'
import React from 'react'
import * as ReactDOM from 'react-dom'
import { createRoot } from 'react-dom/client'
test('real-React jsdom mount: rapid step<->turn toggles never hit React #185', async () => {
@@ -56,11 +57,13 @@ test('real-React jsdom mount: rapid step<->turn toggles never hit React #185', a
}
const require = (spec) => {
if (spec === 'react') return React
if (spec === 'react-dom') return ReactDOM
if (spec === '@deepseek-ai/dsh-client-ui-primitives') {
// Glyph-only usage in this bundle; render inert svgs (never asserted).
return {
IconPlusOutline16: (p) => React.createElement('svg', p, null),
IconBranchOutline16: (p) => React.createElement('svg', p, null),
IconCloseOutline16: (p) => React.createElement('svg', p, null),
}
}
throw new Error(`unexpected module: ${spec}`)