feat(client): render message attachments in order and preview them in the sidebar

A user message's image and file blocks now render as one ordered attachment
block in the Context browser: images keep their session-authorized thumbnail,
other files show their type glyph, extension, and stored size, and the row
chip counts every attachment. One click IS the preview — the card hands the
attachment's named host path to dsh's own preview column (the File Activity
card's `previewAddressOf` → `openResourceVia` path), which renders markdown,
code, PDF, office, and image viewers under the file's real name. A refused
open reports on that card alone; the image lightbox and the per-type modal it
replaced are gone with `images.tsx`.

The path comes from the plugin's own `/api/dsh-context/attachment` route (the
same authenticated `/api` fence, Connection exact-route registry, and
cold-read gate as the detail and fleet routes). It answers only an id the
session's own log cites — a depth-bounded, total walk over the event payload
(user content, tool-result blocks, spliced inbox deliveries) — and answers
the store's named alias: `attachments.fileHostPath` for verbatim files, and a
published-or-reused named hard link for images, whose objects are digest-named
and suffixless while the preview column dispatches viewers by suffix. The
link shares the immutable object bytes and lives in a disposable cache
(`<home>/cache/dsh-context/previews/`); a store that is not host-file-backed,
or a media type the harness never admits, resolves the typed null the card
reports as an unavailable preview.

`Section` moves to its own module so the attachment block heads itself
without importing the tool card, and the compat matrix probes `FileTypeIcon`
alongside the icons it already pins.
This commit is contained in:
bowenliang123 committed 2026-10-13 21:19:47 +08:00
1 parent a5438855d7
commit 6487602f1e
27 files changed
+1740 -455

No files matched your search

+88
View File
@@ -0,0 +1,88 @@
/**
* The on-click attachment plumbing behind the browser's cards. Nothing is fetched while a message row
* is merely rendered: one click resolves that attachment's named host path through the plugin's own
* `/api/dsh-context/attachment` route (host/attachment.ts), and the harness's preview column then
* opens it — the File Activity card's own two rungs (`previewAddressOf` → `openResourceVia`) with the
* attachment's real filename carrying the viewer's file kind.
*/
import { previewAddressOf } from './fileActivity'
import type { AttachmentRef } from '../shared/attachment'
import { asRecord, openResourceVia } from './services'
import type { AttachmentLocator, AttachmentOpener, ClientCtx } from './services'
// The route of host/attachment.ts, re-declared here: the client bundle inlines every import and the
// host module must never reach it (the detail route's own arrangement in timelineSource.ts).
const ATTACHMENT_ROUTE = '/api/dsh-context/attachment'
/** Cache ceiling: cards are clicked one at a time, so a small FIFO holds a session's working set. */
const CACHE_MAX = 32
const paths = new Map<string, string>()
/** Drop every cached location (tests; a page never needs it — an id's object is immutable). */
export function clearAttachmentPaths(): void {
paths.clear()
}
/** One route payload's path, re-proved: a non-string or empty path is no path. */
export function pathOfValue(value: unknown): string | null {
const data = asRecord(value)
return data !== null && typeof data.path === 'string' && data.path !== '' ? data.path : null
}
/** The session-authorized attachment locator, or undefined without a session (cards then render
* metadata only). A failed resolution resolves null and is never cached, so a retry re-reads. */
export function makeAttachmentLocator(sessionId: string | undefined): AttachmentLocator | undefined {
if (typeof sessionId !== 'string' || sessionId === '') return undefined
return async (attachment: AttachmentRef) => {
const key = attachment.attachmentId
const cached = paths.get(key)
if (cached !== undefined) return cached
try {
const response = await fetch(ATTACHMENT_ROUTE, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ sessionId, attachmentId: key }),
})
if (!response.ok) return null
const body = asRecord(await response.json())
if (body === null || body.ok !== true) return null
const path = pathOfValue(body.value)
if (path === null) return null
if (paths.size >= CACHE_MAX) {
const oldest = paths.keys().next().value
/* v8 ignore next 1 -- the cap fires only on a non-empty cache, so the key always exists. */
if (oldest !== undefined) paths.delete(oldest)
}
paths.set(key, path)
return path
} catch {
return null
}
}
}
/**
* Open one attachment in the harness's own preview column, or report that it could not be opened.
* Resolution order is the File Activity card's: the cited attachment's named host path behind the
* plugin's route, then the harness's `dsh-resource://file/…` address.
* @returns whether a preview took the address.
*/
export function makeAttachmentOpener(
ctx: ClientCtx,
sessionId: string | undefined,
workspace: string | undefined,
): AttachmentOpener | undefined {
const locate = makeAttachmentLocator(sessionId)
const openPreview = openResourceVia(ctx)
if (locate === undefined || openPreview === undefined) return undefined
return async (attachment: AttachmentRef) => {
const path = await locate(attachment)
if (path === null) return false
// The address layer's form only refuses directories; WHICH viewer renders the file is the
// preview column's own decision, keyed off the path's suffix.
const address = previewAddressOf(path, 'text', false, sessionId, workspace)
return address !== undefined && openPreview(address)
}
}
+216
View File
@@ -0,0 +1,216 @@
/**
* The Context browser's attachment block. Every `image`/`file` block a message carries renders here
* in the message's OWN order — one card each, images with their session-authorized thumbnail, other
* files with their type glyph, type badge, and size. One click IS the preview: the card hands the
* attachment's named host path to dsh's own preview column (attachmentSource.ts), which renders
* markdown, code, PDFs, office documents, and images under the file's real name. A refused open
* leaves a note on that card alone.
*/
import { useCallback, useEffect, useMemo, useState, type ReactElement } from 'react'
import { attachmentExtension } from '../../shared/attachment'
import type { AttachmentRef } from '../../shared/attachment'
import { estimateImageTokens } from '../../shared/imageTokens'
import { fmtBytes } from '../format'
import type { AttachmentOpener, ImageLoader, ImageRefLike } from '../services'
import type { ViewKit } from '../viewkit'
import { FileTypeGlyph } from '../primitives'
import { Section } from './section'
/** One message's ordered attachment block, plus that message's own IO seats. */
export interface AttachmentKit {
Section: (props: {
attachments: readonly AttachmentRef[]
load?: ImageLoader
open?: AttachmentOpener
}) => ReactElement
load?: ImageLoader
open?: AttachmentOpener
}
/** The image reference the harness loader consumes; only proven facts are handed over. */
function imageRefOf(ref: AttachmentRef): ImageRefLike {
return {
attachmentId: ref.attachmentId,
...(ref.name !== undefined ? { name: ref.name } : {}),
...(ref.bytes !== undefined ? { bytes: ref.bytes } : {}),
...(ref.width !== undefined ? { width: ref.width } : {}),
...(ref.height !== undefined ? { height: ref.height } : {}),
...(ref.originalDimensions !== undefined ? { originalDimensions: ref.originalDimensions } : {}),
}
}
/** The name a card shows: the attachment's own, else its kind's label. */
function nameOf(ref: AttachmentRef, t: ViewKit['t']): string {
return ref.name ?? t(ref.kind === 'image' ? 'attach.image' : 'attach.file')
}
/** The type line of an attachment (the harness file card's own rule): the upper-cased extension,
* else the attachment's kind. */
function typeOf(ref: AttachmentRef, t: ViewKit['t']): string {
const ext = attachmentExtension(ref.name ?? '')
if (ext !== '') return ext.toUpperCase().slice(0, 8)
return ref.kind === 'image' ? t('attach.image') : t('attach.file')
}
/** The image thumbnail's lifecycle: bytes through the harness's session-authorized loader, read
* once per mount. */
function useImageSrc(attachment: AttachmentRef, load: ImageLoader | undefined): {
src: string | null
failed: boolean
} {
const [state, setState] = useState<{ src: string | null; failed: boolean }>({ src: null, failed: false })
useEffect(() => {
if (load === undefined) {
setState({ src: null, failed: false })
return undefined
}
let live = true
setState({ src: null, failed: false })
void load(imageRefOf(attachment)).then(
(url) => { if (live) setState({ src: url, failed: false }) },
() => { if (live) setState({ src: null, failed: true }) },
)
return () => { live = false }
}, [attachment, load])
return state
}
/** An attachment's facts as label/value rows: an image's dimensions and token estimate, a file's
* size — the same figures the chat and the harness's own file card show. */
function factsOf(ref: AttachmentRef, t: ViewKit['t'], fmt: ViewKit['fmt']): Array<{ label: string; value: string; tip?: string }> {
const dimsOf = (w: number | undefined, h: number | undefined): string | null =>
w !== undefined && h !== undefined ? `${w}×${h}` : null
if (ref.kind !== 'image') {
return ref.bytes === undefined ? [] : [{ label: t('attach.size'), value: fmtBytes(ref.bytes) }]
}
const rows: Array<{ label: string; value: string; tip?: string }> = []
// No byte size is stored for the raw raster — dims only.
const rawDims = ref.originalDimensions !== undefined
? dimsOf(ref.originalDimensions.width, ref.originalDimensions.height)
: null
if (rawDims !== null) rows.push({ label: t('attach.raw'), value: rawDims })
const sent = dimsOf(ref.width, ref.height)
if (sent !== null) {
rows.push({
label: t('attach.sent'),
value: ref.bytes !== undefined ? `${sent} · ${fmtBytes(ref.bytes)}` : sent,
})
}
const tokens = ref.width !== undefined && ref.height !== undefined
? estimateImageTokens(ref.width, ref.height)
: null
if (tokens !== null) rows.push({ label: t('attach.token'), value: `≈${fmt(tokens)}`, tip: t('attach.tokensTip') })
return rows
}
function AttachmentCard(props: {
attachment: AttachmentRef
load?: ImageLoader
/** This card's own open state: resolving the preview path, or refused. */
state: 'idle' | 'opening' | 'failed'
onOpen: () => void
t: ViewKit['t']
fmt: ViewKit['fmt']
}): ReactElement {
const { attachment, load, state, t, fmt } = props
const name = nameOf(attachment, t)
const image = attachment.kind === 'image'
const thumb = useImageSrc(attachment, image ? load : undefined)
const hint = t('attach.hint')
return (
<button
type="button"
className={'lc-att-item hover:border-(--dsw-alias-label-dimmed) hover:bg-[var(--dsw-alias-interactive-bg-hover,var(--dsw-alias-bg-layer-2))]'
+ (state === 'failed' ? ' lc-att-item-failed' : '')}
title={name + ' · ' + hint}
data-state={state}
onClick={props.onOpen}
>
{image
? (
<span className="lc-att-thumb">
{thumb.src !== null
? <img src={thumb.src} alt={name} />
: (
<span className={thumb.failed ? 'lc-att-err' : 'lc-att-ph'}>
{thumb.failed ? '⚠' : load === undefined ? '🖼' : t('attach.loading')}
</span>
)}
</span>
)
: (
<span className="lc-att-thumb lc-att-thumb-file">
<FileTypeGlyph path={name} size={28} />
<span className="lc-att-ext">{typeOf(attachment, t)}</span>
</span>
)}
<span className="lc-att-meta">
<span className="lc-att-name" title={name}>{name}</span>
<span className="lc-att-row"><b className="lc-att-row-label">{t('attach.type')}</b>{typeOf(attachment, t)}</span>
{factsOf(attachment, t, fmt).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>
))}
{state === 'failed' ? <span className="lc-att-fail">{t('attach.unavailable')}</span> : null}
{state === 'failed' ? null : (
<span className={'lc-att-hint' + (state === 'opening' ? ' lc-att-hint-on' : '')}>
{state === 'opening' ? t('attach.loading') : hint}
</span>
)}
</span>
</button>
)
}
/** The ordered attachment block. `load`/`open` are optional: without them the cards stay metadata
* only (a harness with no image face, a deployment with no preview column). */
export function makeAttachmentSection(kit: ViewKit): AttachmentKit['Section'] {
const { t, fmt } = kit
return function AttachmentSection(props: {
attachments: readonly AttachmentRef[]
load?: ImageLoader
open?: AttachmentOpener
}): ReactElement {
const { attachments, load, open } = props
const [pending, setPending] = useState<string | null>(null)
const [failed, setFailed] = useState<string | null>(null)
// A content swap (another message's block) drops every stale state rather than acting on a stranger.
const key = useMemo(() => attachments.map(a => a.attachmentId).join('\u0000'), [attachments])
useEffect(() => {
setPending(null)
setFailed(null)
}, [key])
const activate = useCallback((ref: AttachmentRef) => {
if (open === undefined) {
setFailed(ref.attachmentId)
return
}
setPending(ref.attachmentId)
setFailed(null)
void open(ref).then((taken) => {
setPending(cur => (cur === ref.attachmentId ? null : cur))
setFailed(taken ? null : ref.attachmentId)
})
}, [open])
if (attachments.length === 0) return <></>
return (
<Section label={t('attach.title')} count={attachments.length}>
<div className="lc-att-grid">
{attachments.map((a, i) => (
<AttachmentCard
key={a.attachmentId + ':' + String(i)}
attachment={a}
load={load}
t={t}
fmt={fmt}
state={pending === a.attachmentId ? 'opening' : failed === a.attachmentId ? 'failed' : 'idle'}
onOpen={() => { activate(a) }}
/>
))}
</div>
</Section>
)
}
}
+10 -10
View File
@@ -14,10 +14,11 @@ import { makeDetailNote } from './detailNote'
import { makeNodeText } from './nodes'
import { turnStepsOf } from './trendChart'
import { fetchMissNote, useFetchOnMiss } from './fetchOnMiss'
import { imageRefOf, makeImageCard } from './images'
import { attachmentsIn } from '../../shared/attachment'
import { makeAttachmentSection } from './attachments'
import { makeRichText } from './richText'
import type { StackedBarProps } from './stackedBar'
import type { ImageLoader } from '../services'
import type { AttachmentOpener, ImageLoader } from '../services'
import { TextSection, schemaTextOf, RowToolbar, ToolSchema, toolErrOf } from './browserToolCard'
import { NodeContent, byCatOf, nodeNameOf, msgKindsOf, reasoningTextOf, ROW_KINDS } from './browserNodes'
import type { RowKind } from './browserNodes'
@@ -47,6 +48,8 @@ export interface ContextBrowserProps {
onHoverKey?: (key: string | null) => void
onOpenCat?: (cat: string | null) => void
loadImage?: ImageLoader
/** On-click opener of one attachment's preview (attachmentSource.ts); absent = metadata only. */
openAttachment?: AttachmentOpener
detailState?: DetailState
onDetailRetry?: () => void
}
@@ -96,7 +99,7 @@ export function makeContextBrowser(
const DetailNote = makeDetailNote(kit)
const nodeText = makeNodeText(kit)
const rich = makeRichText(kit)
const ImageCard = makeImageCard(kit)
const AttachmentSection = makeAttachmentSection(kit)
const lineLabel = (n: number): string => t(n === 1 ? 'block.line' : 'block.lines', { n })
return function ContextBrowser(props: ContextBrowserProps): ReactElement {
@@ -526,11 +529,9 @@ export function makeContextBrowser(
preview = blockSummaryOf(conv) ?? preview
}
} else if (n.cat === 'user') {
const imgCount = conv !== undefined && Array.isArray(conv.content)
? conv.content.filter(b => imageRefOf(b) !== null).length
: 0
if (imgCount > 0 && openElem !== `n${n.seq}`) {
tag = t('attach.image') + (imgCount > 1 ? ' ×' + String(imgCount) : '')
const attCount = attachmentsIn(conv?.content).length
if (attCount > 0 && openElem !== `n${n.seq}`) {
tag = t('attach.title') + (attCount > 1 ? ' ×' + String(attCount) : '')
}
} else {
tag = t('form.' + (n.form || 'context'))
@@ -592,14 +593,13 @@ export function makeContextBrowser(
node={n}
conv={conv}
rich={rich}
img={{ Card: ImageCard, load: props.loadImage }}
att={{ Section: AttachmentSection, load: props.loadImage, open: props.openAttachment }}
labels={{
thinking: t('block.thinking'),
answer: t('block.answer'),
content: t('block.content'),
result: t('block.result'),
summary: t('block.summary'),
images: t('attach.images'),
other: t('attach.other'),
lines: lineLabel,
callState: (err: boolean, exit: number | null) => (
+6 -6
View File
@@ -4,7 +4,7 @@ import type { ReactElement, ReactNode } from 'react'
import type { Category, SurfaceNode } from '../../shared/types'
import type { Assembled } from '../assemble'
import type { ConversationNodeLike } from '../services'
import type { ImageKit } from './images'
import type { AttachmentKit } from './attachments'
import type { RichKit } from './richText'
import { TextSection, BlocksBody, toolErrOf, ToolCallCard } from './browserToolCard'
import type { DetailLabels } from './browserToolCard'
@@ -14,10 +14,10 @@ export function NodeContent(props: {
conv: ConversationNodeLike | undefined
hint: ReactNode
rich: RichKit
img: ImageKit
att: AttachmentKit
labels: DetailLabels
}): ReactElement {
const { node, conv, rich, img, labels } = props
const { node, conv, rich, att, labels } = props
if (conv === undefined) {
if (node.text === undefined || node.text === '') {
return <div className="lc-br-note">{props.hint}</div>
@@ -30,7 +30,7 @@ export function NodeContent(props: {
)
}
if (conv.kind === 'assistant' && Array.isArray(conv.blocks)) {
return <BlocksBody blocks={conv.blocks} textLabel={labels.answer} rich={rich} img={img} labels={labels} />
return <BlocksBody blocks={conv.blocks} textLabel={labels.answer} rich={rich} att={att} labels={labels} />
}
if (conv.kind === 'tool-result') {
const { err, exit } = toolErrOf(node, conv)
@@ -45,7 +45,7 @@ export function NodeContent(props: {
/>
: null}
{Array.isArray(conv.content)
? <BlocksBody blocks={conv.content} textLabel={labels.result} rich={rich} img={img} labels={labels} />
? <BlocksBody blocks={conv.content} textLabel={labels.result} rich={rich} att={att} labels={labels} />
: null}
</>
)
@@ -56,7 +56,7 @@ export function NodeContent(props: {
: <></>
}
if (Array.isArray(conv.content)) {
return <BlocksBody blocks={conv.content} textLabel={labels.content} rich={rich} img={img} labels={labels} />
return <BlocksBody blocks={conv.content} textLabel={labels.content} rich={rich} att={att} labels={labels} />
}
return <div className="lc-br-note">{props.hint}</div>
}
+26 -50
View File
@@ -4,10 +4,11 @@ import { useMemo, useState, type ChangeEvent, type ReactElement, type ReactNode
import type { SurfaceNode } from '../../shared/types'
import type { ConversationNodeLike } from '../services'
import { parseCallArgs } from '../callSummary'
import { imageRefOf } from './images'
import type { ImageKit } from './images'
import { attachmentOf } from '../../shared/attachment'
import type { AttachmentRef } from '../../shared/attachment'
import type { AttachmentKit } from './attachments'
import type { RichKit } from './richText'
import type { ImageRefLike } from '../services'
import { Section } from './section'
import { typeOf, paramsOf } from './browserSchema'
import type { ParamSchema } from './browserSchema'
@@ -32,29 +33,6 @@ function ParamRow(props: {
)
}
export function Section(props: {
label: string
labelClass?: string
/** Fold the head's trailing group onto a second line under width pressure (rich-text heads; the call-name head must stay one-line). */
foldHead?: boolean
count?: number
actions?: ReactNode
meta?: ReactNode
children: ReactNode
}): ReactElement {
const right = props.actions !== undefined || props.meta !== undefined
return (
<div className="lc-ts-card">
<div className={'lc-ts-card-head' + (props.foldHead === true ? ' lc-ts-card-head-wrap' : '')}>
<b className={props.labelClass} title={props.label}>{props.label}</b>
{right ? <span className="lc-ts-card-right">{props.meta}{props.actions}</span> : null}
{props.count !== undefined ? <span className="lc-ts-card-count">{props.count}</span> : null}
</div>
{props.children}
</div>
)
}
function lineCountOf(text: string): number {
return text.split(/\r\n|\r|\n/).length
}
@@ -190,40 +168,39 @@ export interface DetailLabels {
content: string
result: string
summary: string
images: string
other: string
lines: (n: number) => string
callState: (err: boolean, exit: number | null) => ReactNode
}
/** Both block vocabularies normalize here — raw durable blocks (`type`) and snapshot assistant blocks (`kind`).
* Consecutive images group into one grid; nested tool-result blocks flatten into the same flow. */
/** Both block vocabularies normalize here — raw durable blocks (`type`) and snapshot assistant
* blocks (`kind`). Every attachment block collects into ONE ordered attachment group, rendered where
* the first of them sits (the chat's own arrangement: attachments above the text); nested
* tool-result blocks flatten into the same flow. */
export function BlocksBody(props: {
blocks: readonly unknown[]
textLabel: string
rich: RichKit
img: ImageKit
att: AttachmentKit
labels: DetailLabels
}): ReactElement {
const { rich, img, labels } = props
const { rich, att, labels } = props
const out: ReactNode[] = []
let images: ImageRefLike[] = []
const flushImages = (): void => {
if (images.length === 0) return
const group = images
images = []
out.push(
<Section key={'img' + String(out.length)} label={labels.images} count={group.length}>
<div className="lc-att-grid">
{group.map((a, i) => <img.Card key={`${a.attachmentId}:${i}`} attachment={a} load={img.load} />)}
</div>
</Section>,
)
}
for (const b of props.blocks) {
const image = imageRefOf(b)
if (image !== null) { images.push(image); continue }
flushImages()
const refs = props.blocks.map(attachmentOf)
const attachments = refs.filter((ref): ref is AttachmentRef => ref !== null)
const firstAt = refs.findIndex(ref => ref !== null)
for (const [i, b] of props.blocks.entries()) {
if (i === firstAt) {
out.push(
<att.Section
key={'att' + String(out.length)}
attachments={attachments}
load={att.load}
open={att.open}
/>,
)
}
if (refs[i] !== null) continue
const blk = b !== null && typeof b === 'object'
? b as { type?: unknown; kind?: unknown; text?: unknown; name?: unknown; argsRaw?: unknown; arguments?: unknown; content?: unknown }
: null
@@ -255,14 +232,13 @@ export function BlocksBody(props: {
blocks={blk.content as unknown[]}
textLabel={labels.result}
rich={rich}
img={img}
att={att}
labels={labels}
/>)
continue
}
out.push(<Section key={out.length} label={labels.other}><pre className="lc-ts-desc-body lc-br-dim">{JSON.stringify(b, null, 2)}</pre></Section>)
}
flushImages()
return <>{out}</>
}
+7 -1
View File
@@ -2,12 +2,13 @@
* current composition and the shared browser. */
import { createElement as h, useCallback, useLayoutEffect, useMemo, useRef, useState, type ReactElement } from 'react'
import { makeAttachmentOpener } from '../attachmentSource'
import { measureDock } from '../dockMeasure'
import { headlineOf } from '../headline'
import { modalStoreOf, takePendingConsume } from '../modalStore'
import type { ClientCtx, SessionStandardProps, SessionsFace } from '../services'
import type { ContextSettings } from '../settings'
import { contextBreakdownOf, contextPressureOf, conversationNodesOf, headersOf, imageLoaderOf, projectionOf } from '../services'
import { contextBreakdownOf, contextPressureOf, conversationNodesOf, headersOf, imageLoaderOf, projectionOf, workspaceOf } from '../services'
import { makeContentFetcher, makeHeaderFetcher, useHistoryFace } from '../historyPage'
import { useTimelineSource } from '../timelineSource'
import type { ViewKit } from '../viewkit'
@@ -54,6 +55,10 @@ export function makeContextModal(
() => imageLoaderOf(ctx, sessionId !== '' ? sessionId : undefined),
[ctx, sessionId],
)
const openAttachment = useMemo(
() => makeAttachmentOpener(ctx, sessionId !== '' ? sessionId : undefined, workspaceOf(ctx, sessionId !== '' ? sessionId : undefined)),
[ctx, sessionId],
)
// The first render can race the declared inject (a watch rebuild remounts this overlay before the fiber re-fires),
// so both fetchers below must rebuild when the face lands or is revoked.
@@ -131,6 +136,7 @@ export function makeContextModal(
fetchContent={fetchContent}
fetchHeader={fetchHeader}
loadImage={loadImage}
openAttachment={openAttachment}
hoverKey={hoverCat}
onHoverKey={setHoverCat}
detailState={source.detailState}
+6
View File
@@ -10,6 +10,7 @@ import { makeContentFetcher, makeHeaderFetcher, useHistoryFace } from '../histor
import { useContextSession } from '../sessionData'
import { makeDetailNote } from './detailNote'
import { canOpenPathsOf, openPathVia, openResourceVia, workspaceOf } from '../services'
import { makeAttachmentOpener } from '../attachmentSource'
import { activityOf, activityOfOps, locateStepOf, previewAddressOf } from '../fileActivity'
import type { FileEntry, FileOp } from '../fileActivity'
import type { ContextSettings } from '../settings'
@@ -339,6 +340,10 @@ export function makeContextView(
},
[previewOpener, sessionKey, workspace],
)
const openAttachment = useMemo(
() => makeAttachmentOpener(ctx, typeof sessionId === 'string' ? sessionId : undefined, workspace),
[ctx, sessionId, workspace],
)
const locateFileOp = useCallback((op: FileOp): void => {
// A nested Code-Mode op has no surface row of its own — it reveals on its parent run_code result.
const seq = op.parent ?? op.seq
@@ -540,6 +545,7 @@ export function makeContextView(
nodeFocus={nodeFocus}
onNodeFocusHandled={clearNodeFocus}
loadImage={loadImage}
openAttachment={openAttachment}
detailState={source.detailState}
onDetailRetry={source.retryDetail}
catFocus={catFocus}
-125
View File
@@ -1,125 +0,0 @@
/** dsh stores only a normalized reference per image (no inline bytes); the display URL comes from the harness
* `uiConversation.imageUrl` loader handed down as `load`. */
import { useCallback, useEffect, useState, type ReactElement } from 'react'
import { ImageLightbox } from '@deepseek-ai/dsh-client-ui-primitives'
import { fmtBytes } from '../format'
import type { ImageLoader, ImageRefLike } from '../services'
import { estimateImageTokens } from '../../shared/imageTokens'
import type { ViewKit } from '../viewkit'
export interface ImageKit {
Card: (props: { attachment: ImageRefLike; load?: ImageLoader }) => ReactElement
load?: ImageLoader
}
/** Accepts both raw message blocks (`type: 'image'`) and the snapshot's assistant blocks (`kind: 'image'`);
* every optional fact is re-proved. */
export function imageRefOf(block: unknown): ImageRefLike | null {
if (block === null || typeof block !== 'object') return null
const b = block as { type?: unknown; kind?: unknown; attachment?: unknown }
if (b.type !== 'image' && b.kind !== 'image') return null
const a = b.attachment
if (a === null || typeof a !== 'object') return null
const r = a as Record<string, unknown>
if (typeof r.attachmentId !== 'string' || r.attachmentId === '') return null
const num = (v: unknown): number | undefined =>
typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined
const orig = r.originalDimensions !== null && typeof r.originalDimensions === 'object'
? r.originalDimensions as Record<string, unknown>
: undefined
const origDims = orig !== undefined && num(orig.width) !== undefined && num(orig.height) !== undefined
? { width: num(orig.width) as number, height: num(orig.height) as number }
: undefined
return {
attachmentId: r.attachmentId,
...(typeof r.name === 'string' && r.name !== '' ? { name: r.name } : {}),
...(num(r.bytes) !== undefined ? { bytes: num(r.bytes) } : {}),
...(num(r.width) !== undefined ? { width: num(r.width) } : {}),
...(num(r.height) !== undefined ? { height: num(r.height) } : {}),
...(origDims !== undefined ? { originalDimensions: origDims } : {}),
}
}
export function makeImageCard(kit: ViewKit): ImageKit['Card'] {
const { t, fmt } = kit
return function ImageCard(props: { attachment: ImageRefLike; load?: ImageLoader }): ReactElement {
const { attachment, load } = props
const [src, setSrc] = useState<string | null>(null)
const [error, setError] = useState(false)
const [attempt, setAttempt] = useState(0)
const [preview, setPreview] = useState(false)
const closePreview = useCallback(() => { setPreview(false) }, [])
useEffect(() => {
if (load === undefined) return
let live = true
setError(false)
setSrc(null)
void load(attachment).then((url) => { if (live) setSrc(url) }).catch(() => { if (live) setError(true) })
return () => { live = false }
}, [attachment, load, attempt])
const name = attachment.name ?? t('attach.image')
const dimsOf = (w: number | undefined, h: number | undefined): string | null =>
w !== undefined && h !== undefined ? `${w}×${h}` : null
const rows: Array<{ label: string; value: string; tip?: string }> = []
// No byte size is stored for the raw raster — dims only.
const raw = attachment.originalDimensions !== undefined
? dimsOf(attachment.originalDimensions.width, attachment.originalDimensions.height)
: null
if (raw !== null) rows.push({ label: t('attach.raw'), value: raw })
const sent = dimsOf(attachment.width, attachment.height)
if (sent !== null) {
rows.push({
label: t('attach.sent'),
value: attachment.bytes !== undefined ? `${sent} · ${fmtBytes(attachment.bytes)}` : sent,
})
}
const tokens = attachment.width !== undefined && attachment.height !== undefined
? estimateImageTokens(attachment.width, attachment.height)
: null
if (tokens !== null) rows.push({ label: t('attach.token'), value: `≈${fmt(tokens)}`, tip: t('attach.tokensTip') })
const activate = (): void => {
if (error) { setAttempt(a => a + 1); return }
if (src !== null) setPreview(true)
}
return (
<>
<button
type="button"
className="lc-att-item hover:border-(--dsw-alias-label-dimmed) hover:bg-[var(--dsw-alias-interactive-bg-hover,var(--dsw-alias-bg-layer-2))]"
title={error ? t('attach.loadFailed') : t('attach.open')}
onClick={activate}
>
<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>
{preview && src !== null && (
<ImageLightbox
src={src}
alt={name}
labels={{ dialog: t('attach.preview'), close: t('attach.close') }}
onClose={closePreview}
/>
)}
</>
)
}
}
+26
View File
@@ -0,0 +1,26 @@
/** The Context browser's titled card head, shared by the tool/detail cards and the attachment block. */
import type { ReactElement, ReactNode } from 'react'
export function Section(props: {
label: string
labelClass?: string
/** Fold the head's trailing group onto a second line under width pressure (rich-text heads; the call-name head must stay one-line). */
foldHead?: boolean
count?: number
actions?: ReactNode
meta?: ReactNode
children: ReactNode
}): ReactElement {
const right = props.actions !== undefined || props.meta !== undefined
return (
<div className="lc-ts-card">
<div className={'lc-ts-card-head' + (props.foldHead === true ? ' lc-ts-card-head-wrap' : '')}>
<b className={props.labelClass} title={props.label}>{props.label}</b>
{right ? <span className="lc-ts-card-right">{props.meta}{props.actions}</span> : null}
{props.count !== undefined ? <span className="lc-ts-card-count">{props.count}</span> : null}
</div>
{props.children}
</div>
)
}
+1 -1
View File
@@ -17,7 +17,7 @@ import { familyHue, pressureColorOf } from '../agentTree'
import { fmtTime } from '../format'
import { familyOthersOf, memberIdByName, type FleetTeam, type FleetTeamTask } from '../fleetTeam'
import type { FleetDetail } from '../../shared/types'
import { Section } from './browserToolCard'
import { Section } from './section'
import { BRAND_HUE, memberStyle, runMetaOf } from './memberBoard'
import type { RichKit } from './richText'
import type { ViewKit } from '../viewkit'
+6 -5
View File
@@ -435,14 +435,15 @@ export const DICT_EN = {
'call.fail': 'Failed',
'call.exit': 'exit {n}',
'node.failed': 'Tool execution failed',
'attach.images': 'Images',
'attach.title': 'Attachments',
'attach.other': 'Other content',
'attach.image': 'Image',
'attach.open': 'Open full image',
'attach.preview': 'Image preview',
'attach.close': 'Close',
'attach.file': 'File',
'attach.hint': 'Click to preview',
'attach.loading': '…',
'attach.loadFailed': 'Load failed · click to retry',
'attach.unavailable': 'Preview unavailable',
'attach.type': 'Type',
'attach.size': 'Size',
'attach.raw': 'Raw',
'attach.sent': 'Sent',
'attach.token': 'Token',
+6 -5
View File
@@ -434,14 +434,15 @@ export const DICT_ZH = {
'call.fail': '失败',
'call.exit': 'exit {n}',
'node.failed': '工具执行失败',
'attach.images': '图片附件',
'attach.title': '附件',
'attach.other': '其他内容',
'attach.image': '图片',
'attach.open': '查看原图',
'attach.preview': '图片预览',
'attach.close': '关闭',
'attach.file': '文件',
'attach.hint': '点击预览',
'attach.loading': '…',
'attach.loadFailed': '加载失败 · 点击重试',
'attach.unavailable': '预览不可用',
'attach.type': '类型',
'attach.size': '大小',
'attach.raw': '原图',
'attach.sent': '发送',
'attach.token': 'Token',
+30 -6
View File
@@ -1,5 +1,5 @@
/** Named imports from the ui-primitives platform module, resolved defensively: an absent or renamed
* export would render as React error #130 inside the Context tab, so each icon resolves by name
* export would render as React error #130 inside the Context tab, so each component resolves by name
* through a bounded read and degrades to a render-nothing fallback. */
import type { ReactElement } from 'react'
@@ -10,13 +10,19 @@ export type IconComponent = (props: IconProps) => ReactElement | null
const ns = primitives as unknown as Record<string, unknown>
/** One export's raw value: undefined when the name is absent or the namespace read throws. */
function exportOf(name: string, source: Record<string, unknown>): unknown {
try {
return source[name]
} catch {
return undefined
}
}
/** Resolve one icon by export name; a missing, non-component, or hostile-namespace read renders nothing. */
export function resolveIcon(name: string, source: Record<string, unknown> = ns): IconComponent {
try {
const found = source[name]
if (typeof found === 'function') return found as IconComponent
} catch { /* the fallback below */ }
return () => null
const found = exportOf(name, source)
return typeof found === 'function' ? found as IconComponent : () => null
}
export const IconBranch = resolveIcon('IconBranchOutlineRegular')
@@ -28,3 +34,21 @@ export const IconSettings = resolveIcon('IconSettingsOutlineMedium')
export const IconChevronDown = resolveIcon('IconChevronDownOutlineMedium')
export const IconChevronUp = resolveIcon('IconChevronUpOutlineMedium')
export const IconSearch = resolveIcon('IconSearchOutlineRegular')
/** The harness's file-type glyph: its props are its own (`path`/`kind`), not an icon's. */
export interface FileGlyphProps {
path?: string
kind?: string
size?: number
className?: string
}
export type FileGlyphComponent = (props: FileGlyphProps) => ReactElement | null
/** Resolve the file-type glyph the defensive way the icons resolve. */
export function resolveFileGlyph(source: Record<string, unknown> = ns): FileGlyphComponent {
const found = exportOf('FileTypeIcon', source)
return typeof found === 'function' ? found as FileGlyphComponent : () => null
}
export const FileTypeGlyph = resolveFileGlyph()
+8
View File
@@ -8,6 +8,7 @@
import type { Context } from '@deepseek-ai/cordis'
import type { ComponentType } from 'react'
import type { AttachmentRef } from '../shared/attachment'
import { estimateSystemTokens } from '../shared/estimate'
import type { ActivityDay, ContextActivity, ContextBreakdown, ContextHeaders, ContextPressure, ContextTimeline, CostModelUsage, HeaderEpochContent, SystemPromptNode, TimingTotals, TokenUsage, ToolTimingTotals } from '../shared/types'
@@ -138,6 +139,13 @@ export function imageLoaderOf(
return undefined
}
/** The named host path of one session-cited attachment (the plugin's own location route);
* null resolves the browser's unavailable note. */
export type AttachmentLocator = (attachment: AttachmentRef) => Promise<string | null>
/** On-click opener of one attachment's preview; false = nothing took it. */
export type AttachmentOpener = (attachment: AttachmentRef) => Promise<boolean>
/** The `useChat` seat. The selector must return a reference-stable slice: a fresh object re-renders the view on every event. */
export type UseChatLike = <T>(selector: (snapshot: unknown) => T) => T
+22 -3
View File
@@ -1,9 +1,28 @@
/* The attachment block: one ordered card per image/file block; one click hands the attachment to dsh's preview column. */
.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; width: 100%; box-sizing: border-box; padding: 6px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; background: transparent; font: inherit; text-align: left; cursor: pointer; transition: border-color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out), background-color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
.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-item { position: relative; display: flex; gap: 8px; align-items: center; min-width: 0; width: 100%; box-sizing: border-box; padding: 6px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; background: transparent; font: inherit; text-align: left; cursor: pointer; transition: border-color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out), background-color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
.lc-att-item:focus-visible { outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: 1px; }
.lc-att-item-failed { border-color: var(--dsw-alias-state-error-primary, var(--dsw-alias-border-l2)); }
.lc-att-thumb { position: relative; 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-err { color: var(--dsw-alias-state-error-primary, var(--dsw-alias-label-secondary)); }
.lc-att-thumb-file { color: var(--dsw-alias-label-primary); overflow: visible; }
/* The type badge rides the glyph's corner, so a wide glyph plus a long extension never widen the card. */
.lc-att-ext { position: absolute; right: 2px; bottom: 2px; max-width: 46px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 0 3px; border-radius: 4px; background: var(--dsw-alias-bg-layer-1); border: 1px solid var(--dsw-alias-border-l1); font-size: 9px; font-weight: 600; color: var(--dsw-alias-label-secondary); }
.lc-att-meta { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; }
.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; }
/* A refused open reports on its own card — the click never lands anywhere else. */
.lc-att-fail { font-size: 11px; color: var(--dsw-alias-state-error-primary, var(--dsw-alias-label-secondary)); }
/* The preview invite is a normal meta row: it fades in on hover/focus, so it never paints over the
name and the card never changes height when it appears. */
.lc-att-hint { font-size: 11px; color: var(--dsw-alias-label-secondary); opacity: 0; transition: opacity var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
.lc-att-item:hover .lc-att-hint, .lc-att-item:focus-visible .lc-att-hint, .lc-att-hint-on { opacity: 1; }
/* Under width pressure the two-column grid folds to one column (a thumbnail plus its facts needs ~240px). */
@container lc-card (max-width: 480px) {
.lc-att-grid { grid-template-columns: minmax(0, 1fr); }
}
+228
View File
@@ -0,0 +1,228 @@
/**
* The attachment-preview route (`/api/dsh-context/attachment`) — the previewable host path of one
* attachment a session's log cites.
*
* dsh's own preview column renders a file from a `dsh-resource://file/…` address and dispatches its
* viewer by the path's SUFFIX, so this route answers with a path that carries the attachment's name:
*
* - A verbatim FILE already has one: the attachment store publishes every stored file as a named
* alias (`<home>/attachments/v1/files/<xx>/<digest>/<name>`), returned by `attachments.fileHostPath`.
* - A normalized IMAGE does not — its object is digest-named and suffixless — so the route publishes
* (or reuses) a named hard link under the plugin's own preview cache and answers with that. The
* link shares the object's immutable, digest-verified bytes; nothing is copied on the common path.
*
* No bytes travel: the browser hands the path to the harness, exactly as the File Activity card does.
*
* Same transport and posture as the detail and fleet routes (detail.ts): Connection's exact
* Fetch-route registry behind the authenticated `/api` fence, the durable log read through
* `ctx.sessionQuery.observeSession` (live-preferred), and the read admitted by the shared cold-read
* gate. Authorization mirrors the harness's own attachment gateway: a path resolves only while the
* SESSION'S OWN log cites the attachment, so an id an untrusted replay invented resolves to nothing.
*/
import { copyFile, link, mkdir } from 'node:fs/promises'
import { basename, dirname, join } from 'node:path'
import type { Context } from '@deepseek-ai/cordis'
import { attachmentOf, isImageName, leafNameOf } from '../shared/attachment'
import type { AttachmentRef } from '../shared/attachment'
import { type ColdReadGate, makeColdReadGate } from './coldRead'
import { type ConnectionHostFace, registerPostRoute } from './connection'
/** The plugin's attachment route, under the authenticated `/api` fence. */
export const ATTACHMENT_ROUTE = '/api/dsh-context/attachment'
/** Normalized image media types → the suffix a viewer dispatches on. */
const IMAGE_MEDIA_EXTENSION: Readonly<Record<string, string>> = {
'image/png': 'png',
'image/jpeg': 'jpg',
'image/webp': 'webp',
'image/gif': 'gif',
}
/** The host attachment store, as far as the route consumes it. */
interface AttachmentStoreFace {
fileHostPath?(ref: unknown): string | undefined
imageHostPath?(ref: unknown): string | undefined
}
interface SessionQueryFace {
observeSession?(id: string, options: unknown): Promise<unknown>
}
function reply(value: unknown): Response {
return Response.json(value, { headers: { 'cache-control': 'no-store' } })
}
function failure(code: string, message: string): Response {
return reply({ ok: false, error: { code, message } })
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: null
}
/** Depth cap for the payload walk: real citation sites sit at `data.content[…]`,
* `data.message.content[…]`, and `data.inserted[…].content[…]` (a spliced inbox delivery). */
const WALK_MAX_DEPTH = 8
/** The first attachment block citing `id` anywhere below `value`, or null. Untrusted input: only
* arrays and objects are walked, the depth is capped, and a hostile object drops that subtree. */
function attachmentIn(value: unknown, id: string, depth: number): AttachmentRef | null {
if (depth > WALK_MAX_DEPTH) return null
if (Array.isArray(value)) {
for (const item of value) {
const hit = attachmentIn(item, id, depth + 1)
if (hit !== null) return hit
}
return null
}
if (value === null || typeof value !== 'object') return null
const ref = attachmentOf(value)
if (ref !== null && ref.attachmentId === id) return ref
for (const child of Object.values(value as Record<string, unknown>)) {
const hit = attachmentIn(child, id, depth + 1)
if (hit !== null) return hit
}
return null
}
/** The session's own citation of one attachment: the first logged block naming it, wherever the
* event payload carries it — a user message's content, a tool result's blocks, a spliced inbox
* delivery. An untrusted log: a hostile payload drops out instead of throwing. */
export function referencedAttachmentOf(events: readonly unknown[], attachmentId: string): AttachmentRef | null {
for (const entry of events) {
try {
const hit = attachmentIn(entry, attachmentId, 0)
if (hit !== null) return hit
} catch { /* a hostile payload names nothing */ }
}
return null
}
/** The name one image previews under. The preview column dispatches its viewer by suffix, so a
* display name counts only when its suffix is one a viewer claims — the verified media type names
* the rest (a re-encoded source keeps its original name), and an image whose media type the harness
* never admits names nothing. */
export function imagePreviewNameOf(ref: AttachmentRef): string | null {
const leaf = leafNameOf(ref.name ?? '')
if (leaf !== '' && isImageName(leaf)) return leaf
const extension = ref.mediaType !== undefined ? IMAGE_MEDIA_EXTENSION[ref.mediaType] : undefined
return extension === undefined ? null : `attachment.${extension}`
}
/** The plugin's preview cache under the same home the attachment store lives in: the store's object
* path is `<home>/attachments/v1/objects/<xx>/<digest>`. Null when the path is not that shape (an
* unfamiliar backend), so the route reports no preview instead of guessing a directory. */
function previewAliasDirOf(objectPath: string): string | null {
const root = dirname(dirname(dirname(objectPath)))
if (basename(root) !== 'v1' || basename(dirname(root)) !== 'attachments') return null
return join(dirname(dirname(root)), 'cache', 'dsh-context', 'previews')
}
/** The verbatim file's own named alias, or null when the store cannot locate it (an absent store, a
* non-host-file backend, or a reference the store refuses). */
function filePathOf(store: AttachmentStoreFace | undefined, ref: AttachmentRef): string | null {
if (typeof store?.fileHostPath !== 'function') return null
try {
const path = store.fileHostPath({ attachmentId: ref.attachmentId, name: ref.name ?? '', bytes: ref.bytes ?? 0 })
return typeof path === 'string' && path !== '' ? path : null
} catch {
// A malformed reference (bad digest, unsanitized name) is no path, never a throw.
return null
}
}
/** Publish (or reuse) the named hard link an image previews through; an existing link is the same
* immutable object, so it is the answer. A cross-device or otherwise link-less host falls back to a
* copy, and any failure resolves null rather than throwing out of the route. */
async function imageAliasOf(store: AttachmentStoreFace | undefined, ref: AttachmentRef): Promise<string | null> {
if (typeof store?.imageHostPath !== 'function') return null
let objectPath: string | undefined
try {
objectPath = store.imageHostPath({
attachmentId: ref.attachmentId,
...(ref.mediaType !== undefined ? { mediaType: ref.mediaType } : {}),
...(ref.bytes !== undefined ? { bytes: ref.bytes } : {}),
...(ref.width !== undefined ? { width: ref.width } : {}),
...(ref.height !== undefined ? { height: ref.height } : {}),
...(ref.name !== undefined ? { name: ref.name } : {}),
})
} catch {
return null
}
if (typeof objectPath !== 'string' || objectPath === '') return null
const name = imagePreviewNameOf(ref)
if (name === null) return null
const directory = previewAliasDirOf(objectPath)
if (directory === null) return null
const alias = join(directory, basename(dirname(objectPath)), basename(objectPath), name)
try {
await mkdir(dirname(alias), { recursive: true })
try {
await link(objectPath, alias)
} catch (err) {
if (!(err instanceof Error && 'code' in err && err.code === 'EEXIST')) await copyFile(objectPath, alias)
}
return alias
} catch {
return null
}
}
/** The previewable path of one cited attachment: a file's own named alias, or an image's. */
async function previewPathOf(store: AttachmentStoreFace | undefined, ref: AttachmentRef): Promise<string | null> {
return ref.kind === 'file' ? filePathOf(store, ref) : await imageAliasOf(store, ref)
}
/** Serve the attachment route whenever the connection service is composed (the fleet route's own
* posture); sessionQuery and the attachment store are re-read per request, so a late-arriving
* service still arms. */
export function watchAttachmentChannel(ctx: Context, coldReads: ColdReadGate = makeColdReadGate()): void {
ctx.inject(['connection'], (c) => {
const handler = async (request: Request): Promise<Response> => {
let sessionId: unknown
let attachmentId: unknown
try {
const body = asRecord(await request.json())
sessionId = body?.sessionId
attachmentId = body?.attachmentId
} catch {
return failure('dsh-context/bad-request', 'body is not JSON')
}
if (typeof sessionId !== 'string' || sessionId === '') {
return failure('dsh-context/bad-request', 'missing sessionId')
}
if (typeof attachmentId !== 'string' || attachmentId === '') {
return failure('dsh-context/bad-request', 'missing attachmentId')
}
try {
const query = ctx.get('sessionQuery') as SessionQueryFace | undefined
const observe = typeof query?.observeSession === 'function'
? query.observeSession.bind(query)
: undefined
if (observe === undefined) return reply({ ok: true, value: null })
const store = ctx.get('attachments') as AttachmentStoreFace | undefined
const value = await coldReads.admit(async () => {
const observation = await observe(sessionId, { projectionMode: 'none' })
try {
const events = (observation as { events?: unknown } | null)?.events
if (!Array.isArray(events)) return null
const ref = referencedAttachmentOf(events, attachmentId)
if (ref === null) return null
const path = await previewPathOf(store, ref)
return path === null ? null : { path }
} finally {
const dispose = (observation as { [Symbol.dispose]?: unknown } | null)?.[Symbol.dispose]
if (typeof dispose === 'function') dispose.call(observation)
}
})
return reply({ ok: true, value: value ?? null })
} catch (err) {
return failure('gateway/internal', err instanceof Error ? err.message : String(err))
}
}
registerPostRoute(c, c.get('connection') as ConnectionHostFace | undefined, ATTACHMENT_ROUTE, handler, 'dsh-context: attachment route')
})
}
+3
View File
@@ -13,6 +13,7 @@
import type { Context } from '@deepseek-ai/cordis'
import { createContextActivityDefinition } from './activity'
import { watchAttachmentChannel } from './attachment'
import { createToolAttribution } from './attribution'
import { watchBalanceChannel } from './balance'
import { watchActivityBackfill } from './backfill'
@@ -45,6 +46,8 @@ export function apply(ctx: Context, config: Config): void {
watchBalanceChannel(ctx)
watchSkillCatalog(ctx)
watchFleetChannel(ctx, coldReads)
// Attachment previews (one click = one authorized log read) ride the same gate as every other read.
watchAttachmentChannel(ctx, coldReads)
// A harness BELOW the baseline never gets the real folds (its log shapes and seam faces are
// outside the compat matrix); fallback units serve zeroed data plus the gate record. An
// undetectable version fails open into the normal composition below.
+108
View File
@@ -0,0 +1,108 @@
/**
* The durable attachment vocabulary both halves share. A user message's content carries
* `{ type: 'image' | 'file', attachment: … }` blocks (the conversation snapshot spells the same
* blocks `kind:`), and the host's preview route must re-prove WHICH attachment a session cites
* before resolving its stored object — so one parser normalizes both block shapes and both
* spellings here.
*/
export type AttachmentKind = 'image' | 'file'
/** One attachment block's proven facts; every optional field is re-proved, never borrowed. */
export interface AttachmentRef {
kind: AttachmentKind
attachmentId: string
name?: string
bytes?: number
/** Images only: the media type verified when the raster was admitted. */
mediaType?: string
width?: number
height?: number
originalDimensions?: { width: number; height: number }
}
/** Suffixes the preview column's image viewers claim — the harness's own image-body extensions. */
const EXTENSION_IMAGE: ReadonlySet<string> = new Set([
'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'ico', 'svg',
])
/** The leaf of a display name: both separator styles stripped, so a hostile log path never names a
* file outside the directory the caller resolves it in. */
export function leafNameOf(name: string): string {
return name.slice(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1)
}
/** The lower-case extension of a display name (no dot), following the harness's own `fileExtension`:
* a leading dot starts the suffix, so `.env` is `env`; a missing dot yields an empty string. */
export function attachmentExtension(name: string): string {
const base = leafNameOf(name)
const dot = base.lastIndexOf('.')
return dot < 0 ? '' : base.slice(dot + 1).toLowerCase()
}
/** Whether a display name's suffix is one the preview column's image viewers claim — the host names
* an image's preview alias by it, and an image whose name carries another suffix is named by its
* verified media type instead. */
export function isImageName(name: string): boolean {
return EXTENSION_IMAGE.has(attachmentExtension(name))
}
function numOf(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined
}
function dimensionsOf(value: unknown): { width: number; height: number } | undefined {
if (value === null || typeof value !== 'object') return undefined
const dims = value as Record<string, unknown>
const width = numOf(dims.width)
const height = numOf(dims.height)
return width !== undefined && height !== undefined ? { width, height } : undefined
}
/** One content block → its attachment reference, or null when it is none. Accepts the durable
* `type:` spelling and the snapshot's `kind:`, and re-proves every optional fact. */
export function attachmentOf(block: unknown): AttachmentRef | null {
if (block === null || typeof block !== 'object') return null
const b = block as { type?: unknown; kind?: unknown; attachment?: unknown }
const spelling = typeof b.type === 'string' ? b.type : b.kind
if (spelling !== 'image' && spelling !== 'file') return null
if (b.attachment === null || typeof b.attachment !== 'object') return null
const a = b.attachment as Record<string, unknown>
if (typeof a.attachmentId !== 'string' || a.attachmentId === '') return null
const name = typeof a.name === 'string' && a.name !== '' ? a.name : undefined
const bytes = numOf(a.bytes)
if (spelling === 'file') {
return {
kind: 'file',
attachmentId: a.attachmentId,
...(name !== undefined ? { name } : {}),
...(bytes !== undefined ? { bytes } : {}),
}
}
const origDims = dimensionsOf(a.originalDimensions)
const width = numOf(a.width)
const height = numOf(a.height)
const mediaType = typeof a.mediaType === 'string' && a.mediaType !== '' ? a.mediaType : undefined
return {
kind: 'image',
attachmentId: a.attachmentId,
...(name !== undefined ? { name } : {}),
...(mediaType !== undefined ? { mediaType } : {}),
...(bytes !== undefined ? { bytes } : {}),
...(width !== undefined ? { width } : {}),
...(height !== undefined ? { height } : {}),
...(origDims !== undefined ? { originalDimensions: origDims } : {}),
}
}
/** Every attachment of one message content, in the message's own order; a non-array content or a
* malformed block drops out instead of throwing. */
export function attachmentsIn(content: unknown): AttachmentRef[] {
if (!Array.isArray(content)) return []
const out: AttachmentRef[] = []
for (const block of content) {
const ref = attachmentOf(block)
if (ref !== null) out.push(ref)
}
return out
}
+167
View File
@@ -0,0 +1,167 @@
// The attachment plumbing (src/client/attachmentSource.ts): the location route read behind one
// click, its cache, and the preview-column opener the File Activity card also rides.
import assert from 'node:assert/strict'
import { afterEach, describe, test, vi } from 'vitest'
import {
clearAttachmentPaths,
makeAttachmentLocator,
makeAttachmentOpener,
pathOfValue,
} from '../../src/client/attachmentSource'
import type { AttachmentRef } from '../../src/shared/attachment'
import { asClientCtx, TestClientCtx } from './helpers/harness'
const FILE: AttachmentRef = { kind: 'file', attachmentId: 'sha256:f1', name: 'report.md', bytes: 47320 }
const ALIAS = '/home/me/.dsh/attachments/v1/files/a3/a3f0/report.md'
interface Call {
url: string
body: unknown
}
/** Stub the global fetch with per-call answers; returns the recorded calls. */
function stubFetch(answer: (call: Call) => unknown): Call[] {
const calls: Call[] = []
vi.stubGlobal('fetch', async (url: string, init: { body?: string }) => {
const call: Call = { url, body: init.body === undefined ? undefined : JSON.parse(init.body) }
calls.push(call)
return answer(call)
})
return calls
}
function json(value: unknown, ok = true): { ok: boolean; json: () => Promise<unknown> } {
return { ok, json: () => Promise.resolve(value) }
}
afterEach(() => {
clearAttachmentPaths()
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
describe('pathOfValue', () => {
test('proves the envelope down to a non-empty string path', () => {
assert.equal(pathOfValue({ path: ALIAS }), ALIAS)
assert.equal(pathOfValue({ path: '' }), null)
assert.equal(pathOfValue({ path: 42 }), null)
assert.equal(pathOfValue({}), null)
assert.equal(pathOfValue(null), null)
assert.equal(pathOfValue('path'), null)
})
})
describe('makeAttachmentLocator', () => {
test('no session means no locator', () => {
assert.equal(makeAttachmentLocator(undefined), undefined)
assert.equal(makeAttachmentLocator(''), undefined)
})
test('one read resolves the path and the next click reuses it', async () => {
const calls = stubFetch(() => json({ ok: true, value: { path: ALIAS } }))
const locate = makeAttachmentLocator('s1')!
assert.equal(await locate(FILE), ALIAS)
assert.equal(await locate(FILE), ALIAS)
assert.equal(calls.length, 1, 'the second read is served from the cache')
assert.equal(calls[0].url, '/api/dsh-context/attachment')
assert.deepEqual(calls[0].body, { sessionId: 's1', attachmentId: 'sha256:f1' })
})
test('every refusal resolves null, and none of them is cached', async () => {
const answers: ((call: Call) => unknown)[] = [
() => json({ ok: true, value: null }),
() => json({ ok: false, error: { code: 'x' } }),
() => json({ ok: true, value: { path: '' } }),
() => ({ ok: false, json: () => Promise.resolve({}) }),
() => {
throw new Error('offline')
},
() => json('not-an-envelope'),
]
for (const answer of answers) {
clearAttachmentPaths()
const calls = stubFetch(answer)
const locate = makeAttachmentLocator('s1')!
assert.equal(await locate(FILE), null)
assert.equal(await locate(FILE), null)
assert.equal(calls.length, 2, 'a failed resolution is retried, never cached')
vi.unstubAllGlobals()
}
})
test('a malformed JSON body resolves null', async () => {
vi.stubGlobal('fetch', async () => ({ ok: true, json: () => Promise.reject(new Error('bad json')) }))
const locate = makeAttachmentLocator('s1')!
assert.equal(await locate(FILE), null)
})
test('the cache is bounded: the oldest path is dropped first', async () => {
const calls = stubFetch(call => json({ ok: true, value: { path: `${ALIAS}#${String((call.body as { attachmentId: string }).attachmentId)}` } }))
const locate = makeAttachmentLocator('s1')!
for (let i = 0; i < 33; i++) {
await locate({ kind: 'file', attachmentId: `sha256:f${String(i)}`, name: 'x.md' })
}
assert.equal(calls.length, 33)
// The first id fell out of the 32-entry cache, so it reads again; the newest stays cached.
await locate({ kind: 'file', attachmentId: 'sha256:f0', name: 'x.md' })
assert.equal(calls.length, 34)
await locate({ kind: 'file', attachmentId: 'sha256:f32', name: 'x.md' })
assert.equal(calls.length, 34)
})
})
describe('makeAttachmentOpener', () => {
test('without a session or a preview column there is no opener', async () => {
stubFetch(() => json({ ok: true, value: { path: ALIAS } }))
const bare = new TestClientCtx()
assert.equal(makeAttachmentOpener(asClientCtx(bare), undefined, undefined), undefined)
assert.equal(makeAttachmentOpener(asClientCtx(bare), '', undefined), undefined)
assert.equal(makeAttachmentOpener(asClientCtx(bare), 's1', undefined), undefined)
})
test('a resolved path opens the harness preview column at the session file address', async () => {
stubFetch(() => json({ ok: true, value: { path: ALIAS } }))
const opened: string[] = []
const ctx = new TestClientCtx({ services: { sidebarRight: { openResource: (address: string) => { opened.push(address) } } } })
const open = makeAttachmentOpener(asClientCtx(ctx), 's1', '/work/repo')!
assert.equal(await open(FILE), true)
assert.equal(opened.length, 1)
assert.ok(opened[0].startsWith('dsh-resource://file/session/s1/'), opened[0])
assert.ok(opened[0].endsWith('report.md'), opened[0])
assert.ok(opened[0].includes('/home/me/.dsh/attachments/v1/files/a3/a3f0/'), 'an absolute path outside the workspace stays absolute')
})
test('an image-formed file keeps its stored name in the address', async () => {
stubFetch(() => json({ ok: true, value: { path: '/store/a3/shot.PNG' } }))
const opened: string[] = []
const ctx = new TestClientCtx({ services: { sidebarRight: { openResource: (address: string) => { opened.push(address) } } } })
const open = makeAttachmentOpener(asClientCtx(ctx), 's1', undefined)!
assert.equal(await open({ kind: 'file', attachmentId: 'sha256:f9', name: 'shot.PNG' }), true)
assert.ok(opened[0].endsWith('shot.PNG'))
})
test('an unresolved path and a refused address both report false', async () => {
stubFetch(() => json({ ok: true, value: null }))
const refused: string[] = []
const ctx = new TestClientCtx({ services: { sidebarRight: { openResource: (address: string) => { refused.push(address) } } } })
const open = makeAttachmentOpener(asClientCtx(ctx), 's1', undefined)!
assert.equal(await open(FILE), false)
assert.deepEqual(refused, [], 'nothing to open, the column is never called')
clearAttachmentPaths()
vi.unstubAllGlobals()
stubFetch(() => json({ ok: true, value: { path: ALIAS } }))
const throwing = new TestClientCtx({
services: {
sidebarRight: {
openResource: () => {
throw new Error('no viewer claims it')
},
},
},
})
const openThrowing = makeAttachmentOpener(asClientCtx(throwing), 's1', undefined)!
assert.equal(await openThrowing(FILE), false)
})
})
+207
View File
@@ -0,0 +1,207 @@
// The attachment block (src/client/components/attachments.tsx): the ordered cards and the one-click
// hand-off to the harness preview column, with a refusal reported on the card itself.
import { createElement as h } from 'react'
import assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import { makeAttachmentSection } from '../../../src/client/components/attachments'
import type { AttachmentOpener, ImageLoader } from '../../../src/client/services'
import type { AttachmentRef } from '../../../src/shared/attachment'
import { click, flush, makeKit, mount, query, queryAll, text } from '../helpers/kit'
const kit = makeKit()
const Attachments = makeAttachmentSection(kit)
const CONTENT: AttachmentRef[] = [
{ kind: 'file', attachmentId: 'sha256:f1', name: 'dsh-context-标题.svg', bytes: 2627 },
{ kind: 'file', attachmentId: 'sha256:f2', name: '调研报告.docx', bytes: 778285 },
{ kind: 'file', attachmentId: 'sha256:f3', name: 'REPORT.md', bytes: 47320 },
{ kind: 'image', attachmentId: 'sha256:i1', name: 'result-image-1.jpg', mediaType: 'image/jpeg', bytes: 146227, width: 1568, height: 1350, originalDimensions: { width: 3462, height: 1211 } },
]
const loadImage: ImageLoader = async (att) => {
if (att.attachmentId === 'sha256:i1') return 'blob:image-1'
throw new Error('denied')
}
async function mountSection(over: {
attachments?: readonly AttachmentRef[]
load?: ImageLoader
open?: AttachmentOpener
} = {}): Promise<Awaited<ReturnType<typeof mount>>> {
return await mount(h(Attachments, {
attachments: over.attachments ?? CONTENT,
...(over.load !== undefined ? { load: over.load } : {}),
...(over.open !== undefined ? { open: over.open } : {}),
}))
}
describe('the attachment block', () => {
test('renders every attachment in message order, one card each', async () => {
const m = await mountSection({ load: loadImage })
await flush()
const cards = queryAll(m.container, '.lc-att-item')
assert.equal(cards.length, 4)
assert.deepEqual(
cards.map(c => text(query(c, '.lc-att-name'))),
['dsh-context-标题.svg', '调研报告.docx', 'REPORT.md', 'result-image-1.jpg'],
)
assert.deepEqual(text(query(m.container, '.lc-ts-card-head')), 'Attachments4')
// Files show their extension badge; the image shows its resolved thumbnail.
assert.deepEqual(queryAll(m.container, '.lc-att-ext').map(e => text(e)), ['SVG', 'DOCX', 'MD'])
assert.equal(queryAll<HTMLImageElement>(m.container, '.lc-att-thumb img').length, 1)
assert.ok(query<HTMLImageElement>(m.container, '.lc-att-thumb img').src.includes('blob:image-1'))
// The image card carries the chat's own facts: raw dims, sent size, token estimate.
const imageCard = cards[3]
assert.ok(text(imageCard).includes('3462×1211'), 'raw dimensions')
assert.ok(text(imageCard).includes('1568×1350 · 146.2 kB'), 'sent dimensions and size')
assert.ok(text(imageCard).includes('Token') && text(imageCard).includes('≈325'))
// File cards carry type and size instead.
assert.ok(text(cards[0]).includes('Type') && text(cards[0]).includes('Size'))
assert.ok(text(cards[0]).includes('2.6 kB'))
// Every card invites the same one click.
assert.ok(queryAll(m.container, '.lc-att-hint').every(hint => text(hint).includes('Click to preview')))
await m.unmount()
})
test('a rejected or absent image loader degrades the thumbnail, never the block', async () => {
const m = await mountSection({ load: loadImage, attachments: [{ kind: 'image', attachmentId: 'sha256:x', name: 'x.png' }] })
await flush()
assert.ok(text(query(m.container, '.lc-att-err')).includes('⚠'))
await m.unmount()
const bare = await mountSection({ attachments: [{ kind: 'image', attachmentId: 'sha256:x' }] })
await flush()
assert.equal(text(query(bare.container, '.lc-att-ph')), '🖼')
assert.ok(text(query(bare.container, '.lc-att-name')).includes('Image'))
await bare.unmount()
})
test('an unnamed file falls back to its kind for type, and shows no size row', async () => {
const m = await mountSection({ attachments: [{ kind: 'file', attachmentId: 'sha256:u' }] })
const card = query(m.container, '.lc-att-item')
assert.ok(text(query(card, '.lc-att-name')).includes('File'))
assert.equal(text(query(card, '.lc-att-ext')), 'File', 'the kind stands in for a missing extension')
assert.ok(!text(card).includes('Size'))
await m.unmount()
})
test('an image without sent bytes still shows its dimensions', async () => {
const m = await mountSection({ attachments: [{ kind: 'image', attachmentId: 'sha256:i2', name: 'x.png', width: 10, height: 20 }] })
assert.ok(text(query(m.container, '.lc-att-item')).includes('10×20'))
await m.unmount()
})
test('an empty block renders nothing at all', async () => {
const m = await mountSection({ attachments: [] })
assert.equal(m.container.textContent, '')
await m.unmount()
})
})
describe('the card click', () => {
test('hands every kind to the preview column at once, with no intermediate surface', async () => {
const opened: string[] = []
const open: AttachmentOpener = async (att) => {
opened.push(att.attachmentId)
return true
}
const m = await mountSection({ load: loadImage, open })
await flush()
for (const card of queryAll(m.container, '.lc-att-item')) await click(card)
await flush()
assert.deepEqual(opened, ['sha256:f1', 'sha256:f2', 'sha256:f3', 'sha256:i1'])
assert.equal(queryAll(m.container, '.lc-att-fail').length, 0)
assert.equal(queryAll(m.container, '[role=dialog]').length, 0)
await m.unmount()
})
test('an open in flight shows on its own card, and a refusal leaves the note there', async () => {
let settle: ((taken: boolean) => void) | undefined
const open: AttachmentOpener = () => new Promise((resolve) => { settle = resolve })
const m = await mountSection({ open })
await click(queryAll(m.container, '.lc-att-item')[2])
const opening = queryAll(m.container, '.lc-att-item')[2]
assert.equal(opening.dataset.state, 'opening')
assert.ok(text(query(opening, '.lc-att-hint')).includes('…'))
settle?.(false)
await flush()
assert.equal(queryAll(m.container, '.lc-att-item')[2].dataset.state, 'failed')
assert.ok(text(query(queryAll(m.container, '.lc-att-item')[2], '.lc-att-fail')).includes('Preview unavailable'))
// A later success clears the note.
await click(queryAll(m.container, '.lc-att-item')[2])
settle?.(true)
await flush()
assert.equal(queryAll(m.container, '.lc-att-item')[2].dataset.state, 'idle')
assert.equal(queryAll(m.container, '.lc-att-fail').length, 0)
await m.unmount()
})
test('without an opener every click reports on the card it hit', async () => {
const m = await mountSection({})
const cards = queryAll(m.container, '.lc-att-item')
await click(cards[0])
assert.equal(text(query(cards[0], '.lc-att-fail')), 'Preview unavailable')
// The state follows the newest refusal: one report at a time.
await click(cards[3])
assert.equal(queryAll(m.container, '.lc-att-fail').length, 1)
assert.equal(queryAll(m.container, '.lc-att-item')[3].dataset.state, 'failed')
await m.unmount()
})
test('a content swap drops stale card states, even for an open still in flight', async () => {
let settle: ((taken: boolean) => void) | undefined
const m = await mountSection({ open: () => new Promise((resolve) => { settle = resolve }) })
await click(queryAll(m.container, '.lc-att-item')[0])
settle?.(false)
await flush()
assert.equal(queryAll(m.container, '.lc-att-fail').length, 1)
await m.update(h(Attachments, { attachments: [{ kind: 'file', attachmentId: 'sha256:new', name: 'new.md', bytes: 1 }] }))
assert.equal(queryAll(m.container, '.lc-att-fail').length, 0)
// An open that lands AFTER the swap must not resurrect the state it belonged to.
await click(queryAll(m.container, '.lc-att-item')[0])
await m.update(h(Attachments, { attachments: [{ kind: 'file', attachmentId: 'sha256:other', name: 'other.md', bytes: 2 }] }))
settle?.(false)
await flush()
assert.equal(queryAll(m.container, '.lc-att-fail').length, 0)
await m.unmount()
})
test('a load that settles after unmount never writes state', async () => {
let settle: ((url: string) => void) | undefined
let reject: ((err: Error) => void) | undefined
const lateLoad: ImageLoader = () => new Promise((_resolve, rejectPromise) => {
settle = _resolve
reject = rejectPromise
})
const m = await mountSection({ load: lateLoad, attachments: [{ kind: 'image', attachmentId: 'sha256:i9', name: 'late.png' }] })
await m.unmount()
settle?.('blob:late')
await flush()
// The rejection arm too: both settle paths must stay silent after unmount.
const failing = await mountSection({ load: lateLoad, attachments: [{ kind: 'image', attachmentId: 'sha256:i8', name: 'late-2.png' }] })
await failing.unmount()
reject?.(new Error('denied'))
await flush()
})
test('two opens in flight keep their own cards marked, in landing order', async () => {
const settles = new Map<string, (taken: boolean) => void>()
const open: AttachmentOpener = (att) => new Promise((resolve) => { settles.set(att.attachmentId, resolve) })
const m = await mountSection({ open })
const cards = queryAll(m.container, '.lc-att-item')
await click(cards[0])
await click(cards[1])
assert.equal(queryAll(m.container, '.lc-att-item')[1].dataset.state, 'opening')
// The FIRST open lands while the second is still pending: its clear must not touch the newer one.
settles.get('sha256:f1')?.(true)
await flush()
assert.equal(queryAll(m.container, '.lc-att-item')[0].dataset.state, 'idle')
assert.equal(queryAll(m.container, '.lc-att-item')[1].dataset.state, 'opening')
settles.get('sha256:f2')?.(true)
await flush()
assert.equal(queryAll(m.container, '.lc-att-item')[1].dataset.state, 'idle')
await m.unmount()
})
})
@@ -98,18 +98,18 @@ describe('ContextBrowser message categories', () => {
const rows = elemRows(m)
assert.equal(rows.length, 8, 'user + compaction nodes, newest first')
assert.equal(text(query(rows[0], '.lc-br-preview')), 'empty summary')
// Image chips ride the collapsed row (seq 1 ×2, seq 2 ×1).
// Attachment chips ride the collapsed row (seq 1 ×2 images, seq 2 ×1).
const rowOf = (preview: string) => rows.find(r => text(r).includes(preview)) as HTMLElement
assert.ok(text(rowOf('with images')).includes('Image ×2'))
assert.ok(text(rowOf('one pic')).includes('Image'))
assert.ok(text(rowOf('with images')).includes('Attachments ×2'))
assert.ok(text(rowOf('one pic')).includes('Attachments'))
assert.ok(!text(rowOf('one pic')).includes('×'))
// Open seq 1: the chip makes way for the grid; loader resolves two cards.
await click(rowOf('with images'))
await flush()
let content = query(m.container, '.lc-br-content')
assert.ok(!text(elemRows(m).find(r => text(r).includes('with images')) as HTMLElement).includes('Image ×2'),
assert.ok(!text(elemRows(m).find(r => text(r).includes('with images')) as HTMLElement).includes('Attachments ×2'),
'expanded row drops the chip')
assert.ok(text(content).includes('Images'))
assert.ok(text(content).includes('Attachments'))
const imgs = queryAll<HTMLImageElement>(content, '.lc-att-thumb img')
assert.equal(imgs.length, 2, 'both loads resolved')
assert.ok(imgs[0].src.includes('blob:a1'))
@@ -331,7 +331,7 @@ describe('ContextBrowser message categories', () => {
assert.ok(heads.some(s => s.includes('→ noargs')))
assert.ok(heads.some(s => s.includes('Result')), 'nested tool-result text section')
assert.ok(heads.filter(s => s.includes('Other content')).length === 6, 'unknown blocks render raw JSON')
assert.ok(heads.some(s => s.includes('Images')))
assert.ok(heads.some(s => s.includes('Attachments')))
const argVals = queryAll(content, '.lc-ts-arg-row').map(el => text(el))
assert.ok(argVals.some(s => s.includes('command') && s.includes('ls')))
assert.ok(argVals.some(s => s.includes('timeout') && s.includes('30')))
-235
View File
@@ -1,235 +0,0 @@
// ImagesCard (src/client/components/images.tsx): the preview it renders is the platform ImageLightbox, whose dialog
// behavior is covered upstream, so only the plugin-owned open/close wiring is pinned here.
import { createElement as h } from 'react'
import assert from 'node:assert/strict'
import { describe, test, vi } from 'vitest'
import { imageRefOf, makeImageCard } from '../../../src/client/components/images'
import type { ImageRefLike } from '../../../src/client/services'
import { estimateImageTokens } from '../../../src/shared/imageTokens'
import { click, flush, keydown, makeKit, mount, query, queryAll } from '../helpers/kit'
const kit = makeKit()
const ImageCard = makeImageCard(kit)
const dialog = (): Element | null => document.body.querySelector('[role=dialog]')
describe('imageRefOf', () => {
test('rejects non-objects and non-image blocks', () => {
assert.equal(imageRefOf(null), null)
assert.equal(imageRefOf('image'), null)
assert.equal(imageRefOf(42), null)
assert.equal(imageRefOf({}), null)
assert.equal(imageRefOf({ type: 'text', attachment: { attachmentId: 'a' } }), null)
})
test('accepts both type:image and kind:image shapes', () => {
const attachment = { attachmentId: 'a1' }
assert.deepEqual(imageRefOf({ type: 'image', attachment }), { attachmentId: 'a1' })
assert.deepEqual(imageRefOf({ kind: 'image', attachment }), { attachmentId: 'a1' })
})
test('rejects a missing or malformed attachment', () => {
assert.equal(imageRefOf({ type: 'image' }), null)
assert.equal(imageRefOf({ type: 'image', attachment: null }), null)
assert.equal(imageRefOf({ type: 'image', attachment: 'x' }), null)
assert.equal(imageRefOf({ type: 'image', attachment: {} }), null)
assert.equal(imageRefOf({ type: 'image', attachment: { attachmentId: '' } }), null)
assert.equal(imageRefOf({ type: 'image', attachment: { attachmentId: 42 } }), null)
})
test('keeps only well-formed optional facts', () => {
assert.deepEqual(
imageRefOf({
type: 'image',
attachment: {
attachmentId: 'a1',
name: '',
bytes: '500',
width: NaN,
height: 0,
originalDimensions: 'big',
},
}),
{ attachmentId: 'a1' },
)
assert.deepEqual(
imageRefOf({ type: 'image', attachment: { attachmentId: 'a1', bytes: -5, originalDimensions: null } }),
{ attachmentId: 'a1' },
)
assert.deepEqual(
imageRefOf({ type: 'image', attachment: { attachmentId: 'a1', originalDimensions: { width: 100 } } }),
{ attachmentId: 'a1' },
)
assert.deepEqual(
imageRefOf({ type: 'image', attachment: { attachmentId: 'a1', originalDimensions: { width: 0, height: 10 } } }),
{ attachmentId: 'a1' },
)
})
test('passes a fully populated attachment through', () => {
assert.deepEqual(
imageRefOf({
kind: 'image',
attachment: {
attachmentId: 'a1',
name: 'photo.png',
bytes: 500,
width: 100,
height: 100,
originalDimensions: { width: 4000, height: 3000 },
},
}),
{
attachmentId: 'a1',
name: 'photo.png',
bytes: 500,
width: 100,
height: 100,
originalDimensions: { width: 4000, height: 3000 },
},
)
})
})
const FULL: ImageRefLike = {
attachmentId: 'a1',
name: 'photo.png',
bytes: 500,
width: 100,
height: 100,
originalDimensions: { width: 4000, height: 3000 },
}
describe('ImageCard metadata', () => {
test('without a loader: placeholder tile, metadata rows, click is a no-op', async () => {
const m = await mount(h(ImageCard, { attachment: FULL }))
const card = query(m.container, '.lc-att-item')
assert.equal(card.getAttribute('title'), 'Open full image')
assert.equal(query(m.container, '.lc-att-ph').textContent, '🖼')
assert.equal(query(m.container, '.lc-att-name').textContent, 'photo.png')
const rows = queryAll(m.container, '.lc-att-row')
assert.equal(rows.length, 3)
assert.equal(rows[0].textContent, 'Raw4000×3000')
assert.equal(rows[1].textContent, 'Sent100×100 · 500 B')
assert.equal(rows[2].textContent, `Token≈${estimateImageTokens(100, 100)}`)
assert.ok(rows[2].getAttribute('title')!.includes('384'))
assert.equal(rows[0].getAttribute('title'), null)
await click(card)
assert.equal(dialog(), null)
await m.unmount()
})
test('without a name the generic label applies; without dimensions no rows render', async () => {
const m = await mount(h(ImageCard, { attachment: { attachmentId: 'a2' } }))
const name = query(m.container, '.lc-att-name')
assert.equal(name.textContent, 'Image')
assert.equal(name.getAttribute('title'), 'Image')
assert.equal(queryAll(m.container, '.lc-att-row').length, 0)
await m.unmount()
})
test('sent row without a byte size shows dimensions only', async () => {
const m = await mount(h(ImageCard, { attachment: { attachmentId: 'a3', name: 'n.png', width: 200, height: 50 } }))
const rows = queryAll(m.container, '.lc-att-row')
assert.equal(rows.length, 2)
assert.equal(rows[0].textContent, 'Sent200×50')
assert.equal(rows[1].textContent, `Token≈${estimateImageTokens(200, 50)}`)
await m.unmount()
})
})
describe('ImageCard loading', () => {
test('a resolving loader swaps the placeholder for the image', async () => {
const load = vi.fn((_att: ImageRefLike) => Promise.resolve('blob:x'))
const m = await mount(h(ImageCard, { attachment: FULL, load }))
await flush()
const img = query<HTMLImageElement>(m.container, '.lc-att-thumb img')
assert.equal(img.getAttribute('src'), 'blob:x')
assert.equal(img.getAttribute('alt'), 'photo.png')
assert.equal(load.mock.calls.length, 1)
assert.deepEqual(load.mock.calls[0][0], FULL)
await m.unmount()
})
test('a pending loader shows the loading glyph', async () => {
const load = () => new Promise<string>(() => {})
const m = await mount(h(ImageCard, { attachment: FULL, load }))
assert.equal(query(m.container, '.lc-att-ph').textContent, '…')
await m.unmount()
})
test('a rejecting loader shows the error tile; clicking retries the load', async () => {
const load = vi.fn()
.mockRejectedValueOnce(new Error('boom'))
.mockResolvedValue('blob:y')
const m = await mount(h(ImageCard, { attachment: FULL, load }))
await flush()
assert.equal(query(m.container, '.lc-att-err').textContent, '⚠')
assert.equal(query(m.container, '.lc-att-item').getAttribute('title'), 'Load failed · click to retry')
await click(query(m.container, '.lc-att-item'))
await flush()
assert.equal(load.mock.calls.length, 2)
const img = query<HTMLImageElement>(m.container, '.lc-att-thumb img')
assert.equal(img.getAttribute('src'), 'blob:y')
assert.equal(query(m.container, '.lc-att-item').getAttribute('title'), 'Open full image')
await m.unmount()
})
test('a late answer after unmount is dropped (resolve and reject alike)', async () => {
let resolveLoad: ((url: string) => void) | null = null
const m = await mount(h(ImageCard, { attachment: FULL, load: () => new Promise<string>((r) => { resolveLoad = r }) }))
await m.unmount()
resolveLoad!('blob:z')
await flush()
let rejectLoad: ((err: unknown) => void) | null = null
const m2 = await mount(h(ImageCard, {
attachment: FULL,
load: () => new Promise<string>((_, rej) => { rejectLoad = rej }),
}))
await m2.unmount()
rejectLoad!(new Error('late'))
await flush()
})
})
describe('ImageLightbox wiring', () => {
async function mountOpenable() {
const load = () => Promise.resolve('blob:x')
const m = await mount(h(ImageCard, { attachment: FULL, load }))
await flush()
return m
}
test('click opens the body-portaled dialog; Escape closes it', async () => {
const m = await mountOpenable()
await click(query(m.container, '.lc-att-item'))
const box = dialog()!
assert.equal(box.getAttribute('aria-label'), 'Image preview')
assert.equal(query<HTMLImageElement>(box, 'img').getAttribute('src'), 'blob:x')
assert.equal(query(box, 'button').getAttribute('aria-label'), 'Close')
await keydown('Escape')
assert.equal(dialog(), null)
await m.unmount()
})
test('the close control closes the lightbox', async () => {
const m = await mountOpenable()
await click(query(m.container, '.lc-att-item'))
await click(query(dialog()!, 'button'))
assert.equal(dialog(), null)
await m.unmount()
})
test('switching the attachment while open closes the preview', async () => {
const load = () => Promise.resolve('blob:x')
const m = await mount(h(ImageCard, { attachment: FULL, load }))
await flush()
await click(query(m.container, '.lc-att-item'))
assert.ok(dialog() !== null)
await m.update(h(ImageCard, { attachment: { ...FULL, attachmentId: 'a2' }, load: () => new Promise<string>(() => {}) }))
assert.equal(dialog(), null)
await m.unmount()
})
})
+14 -1
View File
@@ -4,7 +4,7 @@
import assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import { resolveIcon, IconBranch, IconPlus, IconCheck, IconCopy, IconClose, IconSettings, IconChevronDown } from '../../src/client/primitives'
import { FileTypeGlyph, resolveFileGlyph, resolveIcon, IconBranch, IconPlus, IconCheck, IconCopy, IconClose, IconSettings, IconChevronDown } from '../../src/client/primitives'
describe('resolveIcon', () => {
const icon = function SomeIcon(): null { return null }
@@ -35,5 +35,18 @@ describe('resolveIcon', () => {
for (const icon of [IconBranch, IconPlus, IconCheck, IconCopy, IconClose, IconSettings, IconChevronDown]) {
assert.equal(typeof icon, 'function', 'every seam resolves to a component')
}
assert.equal(typeof FileTypeGlyph, 'function', 'the file-type glyph resolves too')
})
})
describe('resolveFileGlyph', () => {
const glyph = function SomeGlyph(): null { return null }
test('a present export resolves; a missing, non-component, or hostile read renders nothing', () => {
assert.equal(resolveFileGlyph({ FileTypeIcon: glyph }), glyph)
assert.equal(resolveFileGlyph({ FileTypeIcon: 42 })({}), null)
assert.equal(resolveFileGlyph({})({}), null)
const hostile = new Proxy({}, { get() { throw new Error('hostile export read') } })
assert.equal(resolveFileGlyph(hostile)({}), null)
})
})
+1
View File
@@ -307,6 +307,7 @@ export const ICON_SEAMS = [
'IconChevronDownOutlineMedium',
'IconChevronUpOutlineMedium',
'IconSearchOutlineRegular',
'FileTypeIcon',
] as const
/** The event families the host fold actually switches on, read out of `src/host/fold.ts` so the
+388
View File
@@ -0,0 +1,388 @@
// The attachment-location route (src/host/attachment.ts): session-authorized named-path resolution
// for verbatim file attachments, plus the route's gating/transport behavior.
import assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import type { Context } from '@deepseek-ai/cordis'
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach } from 'vitest'
import { ATTACHMENT_ROUTE, referencedAttachmentOf, imagePreviewNameOf, watchAttachmentChannel } from '../../src/host/attachment'
import type { ColdReadGate } from '../../src/host/coldRead'
type RouteFetch = (request: Request) => Promise<Response>
interface CtxSpec {
connection?: unknown
sessionQuery?: unknown
attachments?: unknown
}
/** The fleet route spec's host ctx double: cordis inject semantics over a service map. */
function ctxOf(spec: CtxSpec): { ctx: Context; captured: { path?: string; fetch?: RouteFetch }; disposers: (() => void)[] } {
const captured: { path?: string; fetch?: RouteFetch } = {}
const disposers: (() => void)[] = []
const services = new Map<string, unknown>()
if ('connection' in spec) services.set('connection', spec.connection)
if ('sessionQuery' in spec) services.set('sessionQuery', spec.sessionQuery)
if ('attachments' in spec) services.set('attachments', spec.attachments)
const ctx = {
get: (name: string) => services.get(name),
effect(fn: () => unknown, _label?: string) {
const d = fn()
if (typeof d === 'function') disposers.push(d as () => void)
return () => {}
},
inject(deps: string[], cb: (c: unknown) => unknown) {
if (!deps.every(d => services.has(d))) return
const d = cb(ctx)
if (typeof d === 'function') disposers.push(d as () => void)
},
}
if (spec.connection !== undefined && spec.connection !== null) {
const conn = spec.connection as { fetch?: { register?: unknown } }
if (typeof conn.fetch?.register === 'function') {
conn.fetch.register = (route: { path: string; fetch: RouteFetch }) => {
captured.path = route.path
captured.fetch = route.fetch
return () => {}
}
}
}
return { ctx: ctx as unknown as Context, captured, disposers }
}
const OPEN_GATE: ColdReadGate = { admit: read => read() }
const SKIP_GATE: ColdReadGate = { admit: () => Promise.resolve(undefined) }
function armed(spec: CtxSpec = {}, gate: ColdReadGate = OPEN_GATE): { captured: { path?: string; fetch?: RouteFetch } } {
const { ctx, captured } = ctxOf({ connection: { fetch: { register: () => () => {} } }, ...spec })
watchAttachmentChannel(ctx, gate)
return { captured }
}
async function call(captured: { fetch?: RouteFetch }, body: unknown): Promise<Record<string, unknown>> {
const request = new Request(`http://dsh.test${ATTACHMENT_ROUTE}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
})
const response = await captured.fetch!(request)
return await response.json() as Record<string, unknown>
}
const BARE_IMAGE_EVENT = {
type: 'user/message',
seq: 10,
time: 2,
data: { content: [{ type: 'image', attachment: { attachmentId: 'sha256:i2' } }] },
}
/** The screenshot session's own content: three verbatim files, then an image, then the text. */
const USER_EVENT = {
type: 'user/message',
seq: 9,
time: 1,
data: {
content: [
{ type: 'file', attachment: { attachmentId: 'sha256:f1', name: 'report.md', bytes: 47320 } },
{ type: 'file', attachment: { attachmentId: 'sha256:f2', name: 'report.docx', bytes: 778285 } },
{ type: 'image', attachment: { attachmentId: 'sha256:i1', name: 'result-image.jpg', mediaType: 'image/jpeg', bytes: 146227, width: 1568, height: 1350 } },
{ type: 'text', text: 'how many files?' },
],
},
}
function basename(path: string): string {
return path.slice(path.lastIndexOf('/') + 1)
}
function observing(events: unknown): { observeSession(id: string, options: unknown): Promise<unknown>; disposed(): boolean } {
let disposed = false
return {
observeSession: () => Promise.resolve({
events,
[Symbol.dispose]() { disposed = true },
}),
disposed: () => disposed,
}
}
const ALIAS = '/home/me/.dsh/attachments/v1/files/a3/a3f0/report.md'
describe('referencedAttachmentOf', () => {
test('finds the block citing the id at every real citation site', () => {
assert.deepEqual(
referencedAttachmentOf([USER_EVENT], 'sha256:f1'),
{ kind: 'file', attachmentId: 'sha256:f1', name: 'report.md', bytes: 47320 },
)
// A tool result's own content, and a nested block inside it.
assert.deepEqual(
referencedAttachmentOf([
{ type: 'tool/result', seq: 2, time: 2, data: { message: { content: [{ type: 'image', attachment: { attachmentId: 'sha256:i9', width: 1, height: 1 } }] } } },
], 'sha256:i9'),
{ kind: 'image', attachmentId: 'sha256:i9', width: 1, height: 1 },
)
assert.deepEqual(
referencedAttachmentOf([
{
type: 'tool/result',
seq: 3,
time: 3,
data: { message: { content: [{ type: 'tool-result', content: [{ type: 'text', text: 'shot' }, { type: 'image', attachment: { attachmentId: 'sha256:i10', width: 2, height: 2 } }] }] } },
},
], 'sha256:i10'),
{ kind: 'image', attachmentId: 'sha256:i10', width: 2, height: 2 },
)
// A spliced inbox delivery, before its user/message materializes.
assert.deepEqual(
referencedAttachmentOf([
{ type: 'agent/inbox/spliced', seq: 4, time: 4, data: { target: 'next-turn', inserted: [{ content: [{ type: 'file', attachment: { attachmentId: 'sha256:f9', name: 'delivered.md', bytes: 7 } }] }] } },
], 'sha256:f9'),
{ kind: 'file', attachmentId: 'sha256:f9', name: 'delivered.md', bytes: 7 },
)
})
test('the walk is bounded and total over a hostile log', () => {
// Deeper than the cap: the block is out of reach, so nothing resolves.
let deep: Record<string, unknown> = { type: 'file', attachment: { attachmentId: 'sha256:deep', name: 'x.md', bytes: 1 } }
for (let i = 0; i < 10; i++) deep = { nest: deep }
assert.equal(referencedAttachmentOf([{ type: 'user/message', data: deep }], 'sha256:deep'), null)
// An object that throws on the walk drops that event, and the next one still serves.
const hostile = new Proxy({}, { ownKeys() { throw new Error('hostile payload') } })
assert.equal(referencedAttachmentOf([{ type: 'x', data: hostile }], 'sha256:f1'), null)
assert.deepEqual(
referencedAttachmentOf([{ type: 'x', data: hostile }, USER_EVENT], 'sha256:f1'),
{ kind: 'file', attachmentId: 'sha256:f1', name: 'report.md', bytes: 47320 },
)
})
test('an id the log never cites resolves null, over hostile entries', () => {
assert.equal(referencedAttachmentOf([USER_EVENT], 'sha256:other'), null)
assert.equal(referencedAttachmentOf([], 'sha256:f1'), null)
assert.equal(
referencedAttachmentOf([
null,
42,
{ type: 'x' },
{ type: 'x', data: null },
{ type: 'x', data: { content: 'not-an-array' } },
{ type: 'x', data: { content: [null, 'plain', { type: 'file' }] } },
{ type: 'x', data: { message: null } },
], 'sha256:f1'),
null,
)
})
})
describe('watchAttachmentChannel gating', () => {
test('no route without the connection service or its fetch registry', () => {
assert.equal(ctxOf({}).captured.path, undefined)
assert.equal(ctxOf({ connection: {} }).captured.path, undefined)
assert.equal(ctxOf({ connection: { fetch: {} } }).captured.path, undefined)
assert.equal(armed().captured.path, ATTACHMENT_ROUTE)
})
test('a rejecting registry keeps the route absent without throwing', () => {
const ctx = {
get: (name: string) => name === 'connection'
? {
fetch: {
register: () => {
throw new Error('registration rejected')
},
},
}
: undefined,
inject(_deps: string[], cb: (c: unknown) => unknown) { cb(ctx) },
}
assert.doesNotThrow(() => { watchAttachmentChannel(ctx as unknown as Context, OPEN_GATE) })
})
test('bad bodies, a missing session, and a missing attachment are typed failures', async () => {
const { captured } = armed()
const badJson = await captured.fetch!(new Request(`http://dsh.test${ATTACHMENT_ROUTE}`, { method: 'POST', body: '{' }))
assert.equal((await badJson.json() as { ok: boolean }).ok, false)
for (const body of [undefined, null, {}, { sessionId: 42 }, { sessionId: '' }, { sessionId: 's1' }, { sessionId: 's1', attachmentId: 7 }, { sessionId: 's1', attachmentId: '' }]) {
const r = await call(captured, body)
assert.equal(r.ok, false, JSON.stringify(body))
}
})
test('no sessionQuery, a verbless query, or a skipped read answers a typed null', async () => {
assert.deepEqual(await call(armed().captured, { sessionId: 's1', attachmentId: 'a' }), { ok: true, value: null })
assert.deepEqual(await call(armed({ sessionQuery: {} }).captured, { sessionId: 's1', attachmentId: 'a' }), { ok: true, value: null })
assert.deepEqual(await call(armed({ sessionQuery: observing(null) }).captured, { sessionId: 's1', attachmentId: 'a' }), { ok: true, value: null })
const skipped = armed({ sessionQuery: observing([USER_EVENT]) }, SKIP_GATE)
assert.deepEqual(await call(skipped.captured, { sessionId: 's1', attachmentId: 'sha256:f1' }), { ok: true, value: null })
})
test('an id the session never cited resolves null (the authorization rule)', async () => {
const query = observing([USER_EVENT])
const { captured } = armed({ sessionQuery: query })
assert.deepEqual(await call(captured, { sessionId: 's1', attachmentId: 'sha256:invented' }), { ok: true, value: null })
assert.equal(query.disposed(), true)
})
test('a throwing observation surfaces as an internal failure', async () => {
const { captured } = armed({ sessionQuery: { observeSession: () => Promise.reject(new Error('log unreadable')) } })
const r = await call(captured, { sessionId: 's1', attachmentId: 'a' })
assert.equal(r.ok, false)
assert.equal((r.error as { message: string }).message, 'log unreadable')
const nonError = armed({ sessionQuery: { observeSession: () => Promise.reject('nope') } })
assert.equal(((await call(nonError.captured, { sessionId: 's1', attachmentId: 'a' })).error as { message: string }).message, 'nope')
})
})
describe('watchAttachmentChannel resolution', () => {
test('a cited file resolves its named host path and the observation disposes', async () => {
const query = observing([USER_EVENT])
const seen: unknown[] = []
const { captured } = armed({
sessionQuery: query,
attachments: {
fileHostPath: (ref: unknown) => {
seen.push(ref)
return ALIAS
},
},
})
assert.deepEqual(await call(captured, { sessionId: 's1', attachmentId: 'sha256:f1' }), { ok: true, value: { path: ALIAS } })
assert.deepEqual(seen, [{ attachmentId: 'sha256:f1', name: 'report.md', bytes: 47320 }])
assert.equal(query.disposed(), true)
})
test('a store without an image face answers a typed null for an image', async () => {
const { captured } = armed({
sessionQuery: observing([USER_EVENT]),
attachments: { fileHostPath: () => ALIAS },
})
assert.deepEqual(await call(captured, { sessionId: 's1', attachmentId: 'sha256:i1' }), { ok: true, value: null })
})
test('every unlocatable attachment shape resolves a typed null', async () => {
const cases: { attachments: unknown; id: string }[] = [
// No store composed, a store without the verb, and a store answering nothing path-shaped.
{ attachments: undefined, id: 'sha256:f1' },
{ attachments: {}, id: 'sha256:f1' },
{ attachments: { fileHostPath: () => undefined }, id: 'sha256:f1' },
{ attachments: { fileHostPath: () => '' }, id: 'sha256:f1' },
{ attachments: { fileHostPath: () => 42 }, id: 'sha256:f1' },
// A hostile reference (bad digest, unsanitized name) throws inside the store.
{
attachments: {
fileHostPath: () => {
throw new Error('invalid attachment ref')
},
},
id: 'sha256:f1',
},
]
for (const c of cases) {
const { captured } = armed({ sessionQuery: observing([USER_EVENT]), attachments: c.attachments })
assert.deepEqual(await call(captured, { sessionId: 's1', attachmentId: c.id }), { ok: true, value: null }, JSON.stringify(c))
}
})
test('an observation without a dispose method still serves', async () => {
const { captured } = armed({
sessionQuery: { observeSession: () => Promise.resolve({ events: [USER_EVENT] }) },
attachments: { fileHostPath: () => ALIAS },
})
assert.deepEqual(await call(captured, { sessionId: 's1', attachmentId: 'sha256:f1' }), { ok: true, value: { path: ALIAS } })
})
test('a nameless file reference still resolves through the store', async () => {
const { captured } = armed({
sessionQuery: observing([{ type: 'user/message', seq: 1, data: { content: [{ type: 'file', attachment: { attachmentId: 'sha256:f3' } }] } }]),
attachments: { fileHostPath: (ref: unknown) => `${ALIAS}:${JSON.stringify(ref)}` },
})
const r = await call(captured, { sessionId: 's1', attachmentId: 'sha256:f3' })
assert.deepEqual(r.value, { path: `${ALIAS}:${JSON.stringify({ attachmentId: 'sha256:f3', name: '', bytes: 0 })}` })
})
})
describe('image preview names', () => {
test('an image keeps a viewable suffix, leaf only', () => {
assert.equal(imagePreviewNameOf({ kind: 'image', attachmentId: 'a', name: 'photo.PNG', mediaType: 'image/jpeg' }), 'photo.PNG')
assert.equal(imagePreviewNameOf({ kind: 'image', attachmentId: 'a', name: '../../evil.svg' }), 'evil.svg')
assert.equal(imagePreviewNameOf({ kind: 'image', attachmentId: 'a', name: 'C:\\tmp\\x.webp' }), 'x.webp')
})
test('anything the column cannot view is named by the verified media type', () => {
assert.equal(imagePreviewNameOf({ kind: 'image', attachmentId: 'a', name: 'clipboard', mediaType: 'image/jpeg' }), 'attachment.jpg')
assert.equal(imagePreviewNameOf({ kind: 'image', attachmentId: 'a', name: 'shot.png', mediaType: 'image/webp' }), 'shot.png', 'a suffix a viewer claims keeps the displayed name; the viewer renders the stored bytes')
assert.equal(imagePreviewNameOf({ kind: 'image', attachmentId: 'a', name: 'scan.tiff', mediaType: 'image/png' }), 'attachment.png')
assert.equal(imagePreviewNameOf({ kind: 'image', attachmentId: 'a', mediaType: 'image/gif' }), 'attachment.gif')
assert.equal(imagePreviewNameOf({ kind: 'image', attachmentId: 'a', mediaType: 'image/tiff' }), null)
assert.equal(imagePreviewNameOf({ kind: 'image', attachmentId: 'a' }), null)
})
})
describe('watchAttachmentChannel image aliases', () => {
const homes: string[] = []
afterEach(() => {
for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true })
})
/** A real store layout: `<home>/attachments/v1/objects/<xx>/<digest>` holding the image bytes. */
function storeOf(bytes: string): { home: string; objectPath: string; store: { imageHostPath: () => string } } {
const home = mkdtempSync(join(tmpdir(), 'dsh-context-att-'))
homes.push(home)
const digest = 'ab'.repeat(32)
const objectPath = join(home, 'attachments', 'v1', 'objects', 'ab', digest)
mkdirSync(join(home, 'attachments', 'v1', 'objects', 'ab'), { recursive: true })
writeFileSync(objectPath, bytes)
return { home, objectPath, store: { imageHostPath: () => objectPath } }
}
test('a cited image answers a named hard link under the plugin preview cache', async () => {
const { home, objectPath, store } = storeOf('image-bytes')
const { captured } = armed({ sessionQuery: observing([USER_EVENT]), attachments: store })
const r = await call(captured, { sessionId: 's1', attachmentId: 'sha256:i1' })
const path = (r.value as { path: string }).path
assert.equal(path, join(home, 'cache', 'dsh-context', 'previews', 'ab', basename(objectPath), 'result-image.jpg'))
assert.equal(readFileSync(path, 'utf8'), 'image-bytes', 'the link shares the stored bytes')
// A second read reuses the link instead of failing on EEXIST.
const again = await call(captured, { sessionId: 's1', attachmentId: 'sha256:i1' })
assert.deepEqual(again.value, { path })
})
test('an unfamiliar object layout and an unlocatable object both answer a typed null', async () => {
const flat = armed({
sessionQuery: observing([USER_EVENT]),
attachments: { imageHostPath: () => join(tmpdir(), 'objects', 'ab', 'digest') },
})
assert.deepEqual(await call(flat.captured, { sessionId: 's1', attachmentId: 'sha256:i1' }), { ok: true, value: null })
const { home } = storeOf('image-bytes')
const missing = armed({
sessionQuery: observing([USER_EVENT]),
attachments: { imageHostPath: () => join(home, 'attachments', 'v1', 'objects', 'ab', 'gone') },
})
assert.deepEqual(await call(missing.captured, { sessionId: 's1', attachmentId: 'sha256:i1' }), { ok: true, value: null })
})
test('a hostile or empty image reference resolves a typed null', async () => {
const cases: { store: unknown; id: string }[] = [
{ store: {}, id: 'sha256:i1' },
{
store: {
imageHostPath: () => {
throw new Error('invalid attachment ref')
},
},
id: 'sha256:i1',
},
{ store: { imageHostPath: () => '' }, id: 'sha256:i1' },
{ store: { imageHostPath: () => 42 }, id: 'sha256:i1' },
// The cited image carries no media type and no usable suffix.
{ store: { imageHostPath: () => '/x/attachments/v1/objects/ab/digest' }, id: 'sha256:i2' },
]
for (const c of cases) {
const { captured } = armed({ sessionQuery: observing([USER_EVENT, BARE_IMAGE_EVENT]), attachments: c.store })
assert.deepEqual(await call(captured, { sessionId: 's1', attachmentId: c.id }), { ok: true, value: null }, JSON.stringify(c.id))
}
})
})
+2 -1
View File
@@ -186,7 +186,7 @@ describe('dsh-context host plugin', () => {
test('the split generation with the detail route live: slim wire head + the endpoint serves the collections', async () => {
const ctx = new Context()
// The plugin mounts two routes (detail + warm-up trigger); keep them by path.
// The plugin's own routes (detail, fleet, attachment, warm-up trigger, balance, skills); keep them by path.
const routes = new Map<string, { fetch?: (request: Request) => Promise<Response> }>()
ctx.provide('connection', {
fetch: {
@@ -215,6 +215,7 @@ describe('dsh-context host plugin', () => {
assert.equal(timeline.nodes.length, 0, 'the collections stay off the wire value')
assert.equal(timeline.requests.length, 0)
assert.ok(routes.has('/api/dsh-context/attachment'), 'the attachment-location route registered beside the detail route')
const route = routes.get('/api/dsh-context/detail')
assert.ok(route !== undefined && route.fetch !== undefined, 'the detail route registered')
const response = await route.fetch(new Request('http://dsh.test/api/dsh-context/detail', {
+158
View File
@@ -0,0 +1,158 @@
// The shared attachment vocabulary (src/shared/attachment.ts): both block spellings normalize into
// one proven reference, and the preview tables classify what a card can show.
import assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import {
attachmentExtension,
attachmentOf,
attachmentsIn,
isImageName,
leafNameOf,
} from '../../src/shared/attachment'
describe('attachmentExtension', () => {
test('reads the last suffix of a name or path, lower-cased', () => {
assert.equal(attachmentExtension('report.MD'), 'md')
assert.equal(attachmentExtension('dir/sub/file.tar.gz'), 'gz')
assert.equal(attachmentExtension('C:\\docs\\sheet.XLSX'), 'xlsx')
})
test('a missing, trailing, or leading-dot suffix is empty', () => {
assert.equal(attachmentExtension('README'), '')
assert.equal(attachmentExtension('archive.'), '')
assert.equal(attachmentExtension('.env'), 'env')
assert.equal(attachmentExtension('.gitignore'), 'gitignore')
assert.equal(attachmentExtension(''), '')
})
})
describe('isImageName', () => {
test('matches exactly the suffixes the preview column\'s image viewers claim', () => {
assert.equal(isImageName('x.PNG'), true)
assert.equal(isImageName('/dir/shot.webp'), true)
assert.equal(isImageName('vector.svg'), true)
assert.equal(isImageName('scan.tiff'), false, 'the column lists tiff as unviewable')
assert.equal(isImageName('photo.avif'), false, 'the column lists avif as unviewable')
assert.equal(isImageName('notes.md'), false)
assert.equal(isImageName('report.docx'), false)
assert.equal(isImageName(''), false)
})
})
describe('leafNameOf', () => {
test('keeps the leaf of either separator style, so a path never escapes its directory', () => {
assert.equal(leafNameOf('/a/b/report.md'), 'report.md')
assert.equal(leafNameOf('C:\\tmp\\x.png'), 'x.png')
assert.equal(leafNameOf('report.md'), 'report.md')
assert.equal(leafNameOf(''), '')
})
})
describe('attachmentOf', () => {
test('rejects every non-attachment block', () => {
assert.equal(attachmentOf(null), null)
assert.equal(attachmentOf('image'), null)
assert.equal(attachmentOf(42), null)
assert.equal(attachmentOf({}), null)
assert.equal(attachmentOf({ type: 'text', attachment: { attachmentId: 'a' } }), null)
assert.equal(attachmentOf({ kind: 'mystery', attachment: { attachmentId: 'a' } }), null)
})
test('rejects a missing or malformed attachment envelope', () => {
assert.equal(attachmentOf({ type: 'image' }), null)
assert.equal(attachmentOf({ type: 'image', attachment: null }), null)
assert.equal(attachmentOf({ type: 'image', attachment: 'x' }), null)
assert.equal(attachmentOf({ type: 'file', attachment: {} }), null)
assert.equal(attachmentOf({ type: 'file', attachment: { attachmentId: '' } }), null)
assert.equal(attachmentOf({ type: 'file', attachment: { attachmentId: 42 } }), null)
})
test('a verbatim file keeps the name and size the log carries', () => {
assert.deepEqual(
attachmentOf({ type: 'file', attachment: { attachmentId: 'sha256:x', name: 'notes.md', bytes: 47320 } }),
{ kind: 'file', attachmentId: 'sha256:x', name: 'notes.md', bytes: 47320 },
)
assert.deepEqual(attachmentOf({ kind: 'file', attachment: { attachmentId: 'sha256:x' } }), { kind: 'file', attachmentId: 'sha256:x' })
assert.deepEqual(
attachmentOf({ type: 'file', attachment: { attachmentId: 'sha256:x', name: '', bytes: -1 } }),
{ kind: 'file', attachmentId: 'sha256:x' },
)
})
test('an image passes every proven fact and drops the hostile ones', () => {
assert.deepEqual(
attachmentOf({
type: 'image',
attachment: {
attachmentId: 'sha256:i',
mediaType: 'image/jpeg',
name: 'result-image-1.jpg',
bytes: 146227,
width: 1568,
height: 1350,
originalDimensions: { width: 3462, height: 1211 },
},
}),
{
kind: 'image',
attachmentId: 'sha256:i',
name: 'result-image-1.jpg',
mediaType: 'image/jpeg',
bytes: 146227,
width: 1568,
height: 1350,
originalDimensions: { width: 3462, height: 1211 },
},
)
assert.deepEqual(
attachmentOf({
kind: 'image',
attachment: {
attachmentId: 'sha256:i',
mediaType: '',
name: '',
bytes: '500',
width: NaN,
height: 0,
originalDimensions: 'big',
},
}),
{ kind: 'image', attachmentId: 'sha256:i' },
)
assert.deepEqual(
attachmentOf({ type: 'image', attachment: { attachmentId: 'sha256:i', originalDimensions: null } }),
{ kind: 'image', attachmentId: 'sha256:i' },
)
assert.deepEqual(
attachmentOf({ type: 'image', attachment: { attachmentId: 'sha256:i', originalDimensions: { width: 0, height: 10 } } }),
{ kind: 'image', attachmentId: 'sha256:i' },
)
assert.deepEqual(
attachmentOf({ type: 'image', attachment: { attachmentId: 'sha256:i', originalDimensions: { width: 4000, height: 3000 } } }),
{ kind: 'image', attachmentId: 'sha256:i', originalDimensions: { width: 4000, height: 3000 } },
)
})
})
describe('attachmentsIn', () => {
test('keeps the message order and drops everything else', () => {
const content = [
{ type: 'file', attachment: { attachmentId: 'f1', name: 'a.svg', bytes: 2627 } },
{ type: 'text', text: 'hello' },
{ type: 'image', attachment: { attachmentId: 'i1', width: 10, height: 10 } },
null,
'plain',
{ type: 'file' },
]
assert.deepEqual(attachmentsIn(content), [
{ kind: 'file', attachmentId: 'f1', name: 'a.svg', bytes: 2627 },
{ kind: 'image', attachmentId: 'i1', width: 10, height: 10 },
])
})
test('a non-array content has none', () => {
assert.deepEqual(attachmentsIn(undefined), [])
assert.deepEqual(attachmentsIn('content'), [])
})
})