diff --git a/src/client/attachmentSource.ts b/src/client/attachmentSource.ts new file mode 100644 index 0000000..5bbaa08 --- /dev/null +++ b/src/client/attachmentSource.ts @@ -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() + +/** 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) + } +} diff --git a/src/client/components/attachments.tsx b/src/client/components/attachments.tsx new file mode 100644 index 0000000..34e7668 --- /dev/null +++ b/src/client/components/attachments.tsx @@ -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 ( + + ) +} + +/** 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(null) + const [failed, setFailed] = useState(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 ( +
+
+ {attachments.map((a, i) => ( + { activate(a) }} + /> + ))} +
+
+ ) + } +} diff --git a/src/client/components/browser.tsx b/src/client/components/browser.tsx index ad430b2..361d0a0 100644 --- a/src/client/components/browser.tsx +++ b/src/client/components/browser.tsx @@ -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) => ( diff --git a/src/client/components/browserNodes.tsx b/src/client/components/browserNodes.tsx index 5e872a6..f7f2d7b 100644 --- a/src/client/components/browserNodes.tsx +++ b/src/client/components/browserNodes.tsx @@ -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
{props.hint}
@@ -30,7 +30,7 @@ export function NodeContent(props: { ) } if (conv.kind === 'assistant' && Array.isArray(conv.blocks)) { - return + return } if (conv.kind === 'tool-result') { const { err, exit } = toolErrOf(node, conv) @@ -45,7 +45,7 @@ export function NodeContent(props: { /> : null} {Array.isArray(conv.content) - ? + ? : null} ) @@ -56,7 +56,7 @@ export function NodeContent(props: { : <> } if (Array.isArray(conv.content)) { - return + return } return
{props.hint}
} diff --git a/src/client/components/browserToolCard.tsx b/src/client/components/browserToolCard.tsx index 54fcd0c..8621bfb 100644 --- a/src/client/components/browserToolCard.tsx +++ b/src/client/components/browserToolCard.tsx @@ -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 ( -
-
- {props.label} - {right ? {props.meta}{props.actions} : null} - {props.count !== undefined ? {props.count} : null} -
- {props.children} -
- ) -} - 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( -
-
- {group.map((a, i) => )} -
-
, - ) - } - 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( + , + ) + } + 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(
{JSON.stringify(b, null, 2)}
) } - flushImages() return <>{out} } diff --git a/src/client/components/contextModal.tsx b/src/client/components/contextModal.tsx index af63c43..6aeae09 100644 --- a/src/client/components/contextModal.tsx +++ b/src/client/components/contextModal.tsx @@ -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} diff --git a/src/client/components/contextView.tsx b/src/client/components/contextView.tsx index 5e0f0b3..7eaa086 100644 --- a/src/client/components/contextView.tsx +++ b/src/client/components/contextView.tsx @@ -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} diff --git a/src/client/components/images.tsx b/src/client/components/images.tsx deleted file mode 100644 index 323ce46..0000000 --- a/src/client/components/images.tsx +++ /dev/null @@ -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 - 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 - : 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(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 ( - <> - - {preview && src !== null && ( - - )} - - ) - } -} diff --git a/src/client/components/section.tsx b/src/client/components/section.tsx new file mode 100644 index 0000000..18c8008 --- /dev/null +++ b/src/client/components/section.tsx @@ -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 ( +
+
+ {props.label} + {right ? {props.meta}{props.actions} : null} + {props.count !== undefined ? {props.count} : null} +
+ {props.children} +
+ ) +} diff --git a/src/client/components/taskBoard.tsx b/src/client/components/taskBoard.tsx index be63b78..e0c3d62 100644 --- a/src/client/components/taskBoard.tsx +++ b/src/client/components/taskBoard.tsx @@ -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' diff --git a/src/client/i18nEn.ts b/src/client/i18nEn.ts index 169430e..6452cbf 100644 --- a/src/client/i18nEn.ts +++ b/src/client/i18nEn.ts @@ -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', diff --git a/src/client/i18nZh.ts b/src/client/i18nZh.ts index 4c091e9..dbfebfb 100644 --- a/src/client/i18nZh.ts +++ b/src/client/i18nZh.ts @@ -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', diff --git a/src/client/primitives.ts b/src/client/primitives.ts index cba1c34..c63ad1d 100644 --- a/src/client/primitives.ts +++ b/src/client/primitives.ts @@ -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 +/** One export's raw value: undefined when the name is absent or the namespace read throws. */ +function exportOf(name: string, source: Record): 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 = 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 = ns): FileGlyphComponent { + const found = exportOf('FileTypeIcon', source) + return typeof found === 'function' ? found as FileGlyphComponent : () => null +} + +export const FileTypeGlyph = resolveFileGlyph() diff --git a/src/client/services.ts b/src/client/services.ts index 5fdef0d..6f0dd95 100644 --- a/src/client/services.ts +++ b/src/client/services.ts @@ -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 + +/** On-click opener of one attachment's preview; false = nothing took it. */ +export type AttachmentOpener = (attachment: AttachmentRef) => Promise + /** The `useChat` seat. The selector must return a reference-stable slice: a fresh object re-renders the view on every event. */ export type UseChatLike = (selector: (snapshot: unknown) => T) => T diff --git a/src/client/styles/attachments.css b/src/client/styles/attachments.css index 4d9c6a1..d9e31ed 100644 --- a/src/client/styles/attachments.css +++ b/src/client/styles/attachments.css @@ -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); } +} diff --git a/src/host/attachment.ts b/src/host/attachment.ts new file mode 100644 index 0000000..8f28522 --- /dev/null +++ b/src/host/attachment.ts @@ -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 (`/attachments/v1/files///`), 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> = { + '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 +} + +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 | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : 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)) { + 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 `/attachments/v1/objects//`. 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 { + 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 { + 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 => { + 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') + }) +} diff --git a/src/host/index.ts b/src/host/index.ts index a422f96..83c79dc 100644 --- a/src/host/index.ts +++ b/src/host/index.ts @@ -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. diff --git a/src/shared/attachment.ts b/src/shared/attachment.ts new file mode 100644 index 0000000..3d1a48c --- /dev/null +++ b/src/shared/attachment.ts @@ -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 = 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 + 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 + 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 +} diff --git a/tests/client/attachmentSource.spec.ts b/tests/client/attachmentSource.spec.ts new file mode 100644 index 0000000..9b894c7 --- /dev/null +++ b/tests/client/attachmentSource.spec.ts @@ -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 } { + 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) + }) +}) diff --git a/tests/client/components/attachments.spec.ts b/tests/client/components/attachments.spec.ts new file mode 100644 index 0000000..f5e8b35 --- /dev/null +++ b/tests/client/components/attachments.spec.ts @@ -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>> { + 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(m.container, '.lc-att-thumb img').length, 1) + assert.ok(query(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 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() + }) +}) diff --git a/tests/client/components/browserMessageCategories.spec.ts b/tests/client/components/browserMessageCategories.spec.ts index bd9dd2f..5c868e6 100644 --- a/tests/client/components/browserMessageCategories.spec.ts +++ b/tests/client/components/browserMessageCategories.spec.ts @@ -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(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'))) diff --git a/tests/client/components/images.spec.ts b/tests/client/components/images.spec.ts deleted file mode 100644 index 48d212f..0000000 --- a/tests/client/components/images.spec.ts +++ /dev/null @@ -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(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(() => {}) - 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(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((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((_, 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(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(() => {}) })) - assert.equal(dialog(), null) - await m.unmount() - }) -}) diff --git a/tests/client/primitives.spec.ts b/tests/client/primitives.spec.ts index 10e23e3..049af0f 100644 --- a/tests/client/primitives.spec.ts +++ b/tests/client/primitives.spec.ts @@ -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) }) }) diff --git a/tests/compat/staging.ts b/tests/compat/staging.ts index a939692..d21bc2a 100644 --- a/tests/compat/staging.ts +++ b/tests/compat/staging.ts @@ -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 diff --git a/tests/host/attachment.spec.ts b/tests/host/attachment.spec.ts new file mode 100644 index 0000000..e01d3e0 --- /dev/null +++ b/tests/host/attachment.spec.ts @@ -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 + +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() + 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> { + 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 +} + +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; 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 = { 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: `/attachments/v1/objects//` 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)) + } + }) +}) diff --git a/tests/host/index.spec.ts b/tests/host/index.spec.ts index 28b5608..bd82307 100644 --- a/tests/host/index.spec.ts +++ b/tests/host/index.spec.ts @@ -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 Promise }>() 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', { diff --git a/tests/shared/attachment.spec.ts b/tests/shared/attachment.spec.ts new file mode 100644 index 0000000..ab8b2e4 --- /dev/null +++ b/tests/shared/attachment.spec.ts @@ -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'), []) + }) +})