feat(client): render message attachments in order and preview them in the sidebar
A user message's image and file blocks now render as one ordered attachment block in the Context browser: images keep their session-authorized thumbnail, other files show their type glyph, extension, and stored size, and the row chip counts every attachment. One click IS the preview — the card hands the attachment's named host path to dsh's own preview column (the File Activity card's `previewAddressOf` → `openResourceVia` path), which renders markdown, code, PDF, office, and image viewers under the file's real name. A refused open reports on that card alone; the image lightbox and the per-type modal it replaced are gone with `images.tsx`. The path comes from the plugin's own `/api/dsh-context/attachment` route (the same authenticated `/api` fence, Connection exact-route registry, and cold-read gate as the detail and fleet routes). It answers only an id the session's own log cites — a depth-bounded, total walk over the event payload (user content, tool-result blocks, spliced inbox deliveries) — and answers the store's named alias: `attachments.fileHostPath` for verbatim files, and a published-or-reused named hard link for images, whose objects are digest-named and suffixless while the preview column dispatches viewers by suffix. The link shares the immutable object bytes and lives in a disposable cache (`<home>/cache/dsh-context/previews/`); a store that is not host-file-backed, or a media type the harness never admits, resolves the typed null the card reports as an unavailable preview. `Section` moves to its own module so the attachment block heads itself without importing the tool card, and the compat matrix probes `FileTypeIcon` alongside the icons it already pins.
This commit is contained in:
1 parent
a5438855d7
commit
6487602f1e
27 files changed
+1740
-455
No files matched your search
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* The on-click attachment plumbing behind the browser's cards. Nothing is fetched while a message row
|
||||
* is merely rendered: one click resolves that attachment's named host path through the plugin's own
|
||||
* `/api/dsh-context/attachment` route (host/attachment.ts), and the harness's preview column then
|
||||
* opens it — the File Activity card's own two rungs (`previewAddressOf` → `openResourceVia`) with the
|
||||
* attachment's real filename carrying the viewer's file kind.
|
||||
*/
|
||||
|
||||
import { previewAddressOf } from './fileActivity'
|
||||
import type { AttachmentRef } from '../shared/attachment'
|
||||
import { asRecord, openResourceVia } from './services'
|
||||
import type { AttachmentLocator, AttachmentOpener, ClientCtx } from './services'
|
||||
|
||||
// The route of host/attachment.ts, re-declared here: the client bundle inlines every import and the
|
||||
// host module must never reach it (the detail route's own arrangement in timelineSource.ts).
|
||||
const ATTACHMENT_ROUTE = '/api/dsh-context/attachment'
|
||||
|
||||
/** Cache ceiling: cards are clicked one at a time, so a small FIFO holds a session's working set. */
|
||||
const CACHE_MAX = 32
|
||||
|
||||
const paths = new Map<string, string>()
|
||||
|
||||
/** Drop every cached location (tests; a page never needs it — an id's object is immutable). */
|
||||
export function clearAttachmentPaths(): void {
|
||||
paths.clear()
|
||||
}
|
||||
|
||||
/** One route payload's path, re-proved: a non-string or empty path is no path. */
|
||||
export function pathOfValue(value: unknown): string | null {
|
||||
const data = asRecord(value)
|
||||
return data !== null && typeof data.path === 'string' && data.path !== '' ? data.path : null
|
||||
}
|
||||
|
||||
/** The session-authorized attachment locator, or undefined without a session (cards then render
|
||||
* metadata only). A failed resolution resolves null and is never cached, so a retry re-reads. */
|
||||
export function makeAttachmentLocator(sessionId: string | undefined): AttachmentLocator | undefined {
|
||||
if (typeof sessionId !== 'string' || sessionId === '') return undefined
|
||||
return async (attachment: AttachmentRef) => {
|
||||
const key = attachment.attachmentId
|
||||
const cached = paths.get(key)
|
||||
if (cached !== undefined) return cached
|
||||
try {
|
||||
const response = await fetch(ATTACHMENT_ROUTE, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId, attachmentId: key }),
|
||||
})
|
||||
if (!response.ok) return null
|
||||
const body = asRecord(await response.json())
|
||||
if (body === null || body.ok !== true) return null
|
||||
const path = pathOfValue(body.value)
|
||||
if (path === null) return null
|
||||
if (paths.size >= CACHE_MAX) {
|
||||
const oldest = paths.keys().next().value
|
||||
/* v8 ignore next 1 -- the cap fires only on a non-empty cache, so the key always exists. */
|
||||
if (oldest !== undefined) paths.delete(oldest)
|
||||
}
|
||||
paths.set(key, path)
|
||||
return path
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open one attachment in the harness's own preview column, or report that it could not be opened.
|
||||
* Resolution order is the File Activity card's: the cited attachment's named host path behind the
|
||||
* plugin's route, then the harness's `dsh-resource://file/…` address.
|
||||
* @returns whether a preview took the address.
|
||||
*/
|
||||
export function makeAttachmentOpener(
|
||||
ctx: ClientCtx,
|
||||
sessionId: string | undefined,
|
||||
workspace: string | undefined,
|
||||
): AttachmentOpener | undefined {
|
||||
const locate = makeAttachmentLocator(sessionId)
|
||||
const openPreview = openResourceVia(ctx)
|
||||
if (locate === undefined || openPreview === undefined) return undefined
|
||||
return async (attachment: AttachmentRef) => {
|
||||
const path = await locate(attachment)
|
||||
if (path === null) return false
|
||||
// The address layer's form only refuses directories; WHICH viewer renders the file is the
|
||||
// preview column's own decision, keyed off the path's suffix.
|
||||
const address = previewAddressOf(path, 'text', false, sessionId, workspace)
|
||||
return address !== undefined && openPreview(address)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* The Context browser's attachment block. Every `image`/`file` block a message carries renders here
|
||||
* in the message's OWN order — one card each, images with their session-authorized thumbnail, other
|
||||
* files with their type glyph, type badge, and size. One click IS the preview: the card hands the
|
||||
* attachment's named host path to dsh's own preview column (attachmentSource.ts), which renders
|
||||
* markdown, code, PDFs, office documents, and images under the file's real name. A refused open
|
||||
* leaves a note on that card alone.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactElement } from 'react'
|
||||
import { attachmentExtension } from '../../shared/attachment'
|
||||
import type { AttachmentRef } from '../../shared/attachment'
|
||||
import { estimateImageTokens } from '../../shared/imageTokens'
|
||||
import { fmtBytes } from '../format'
|
||||
import type { AttachmentOpener, ImageLoader, ImageRefLike } from '../services'
|
||||
import type { ViewKit } from '../viewkit'
|
||||
import { FileTypeGlyph } from '../primitives'
|
||||
import { Section } from './section'
|
||||
|
||||
/** One message's ordered attachment block, plus that message's own IO seats. */
|
||||
export interface AttachmentKit {
|
||||
Section: (props: {
|
||||
attachments: readonly AttachmentRef[]
|
||||
load?: ImageLoader
|
||||
open?: AttachmentOpener
|
||||
}) => ReactElement
|
||||
load?: ImageLoader
|
||||
open?: AttachmentOpener
|
||||
}
|
||||
|
||||
/** The image reference the harness loader consumes; only proven facts are handed over. */
|
||||
function imageRefOf(ref: AttachmentRef): ImageRefLike {
|
||||
return {
|
||||
attachmentId: ref.attachmentId,
|
||||
...(ref.name !== undefined ? { name: ref.name } : {}),
|
||||
...(ref.bytes !== undefined ? { bytes: ref.bytes } : {}),
|
||||
...(ref.width !== undefined ? { width: ref.width } : {}),
|
||||
...(ref.height !== undefined ? { height: ref.height } : {}),
|
||||
...(ref.originalDimensions !== undefined ? { originalDimensions: ref.originalDimensions } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/** The name a card shows: the attachment's own, else its kind's label. */
|
||||
function nameOf(ref: AttachmentRef, t: ViewKit['t']): string {
|
||||
return ref.name ?? t(ref.kind === 'image' ? 'attach.image' : 'attach.file')
|
||||
}
|
||||
|
||||
/** The type line of an attachment (the harness file card's own rule): the upper-cased extension,
|
||||
* else the attachment's kind. */
|
||||
function typeOf(ref: AttachmentRef, t: ViewKit['t']): string {
|
||||
const ext = attachmentExtension(ref.name ?? '')
|
||||
if (ext !== '') return ext.toUpperCase().slice(0, 8)
|
||||
return ref.kind === 'image' ? t('attach.image') : t('attach.file')
|
||||
}
|
||||
|
||||
/** The image thumbnail's lifecycle: bytes through the harness's session-authorized loader, read
|
||||
* once per mount. */
|
||||
function useImageSrc(attachment: AttachmentRef, load: ImageLoader | undefined): {
|
||||
src: string | null
|
||||
failed: boolean
|
||||
} {
|
||||
const [state, setState] = useState<{ src: string | null; failed: boolean }>({ src: null, failed: false })
|
||||
useEffect(() => {
|
||||
if (load === undefined) {
|
||||
setState({ src: null, failed: false })
|
||||
return undefined
|
||||
}
|
||||
let live = true
|
||||
setState({ src: null, failed: false })
|
||||
void load(imageRefOf(attachment)).then(
|
||||
(url) => { if (live) setState({ src: url, failed: false }) },
|
||||
() => { if (live) setState({ src: null, failed: true }) },
|
||||
)
|
||||
return () => { live = false }
|
||||
}, [attachment, load])
|
||||
return state
|
||||
}
|
||||
|
||||
/** An attachment's facts as label/value rows: an image's dimensions and token estimate, a file's
|
||||
* size — the same figures the chat and the harness's own file card show. */
|
||||
function factsOf(ref: AttachmentRef, t: ViewKit['t'], fmt: ViewKit['fmt']): Array<{ label: string; value: string; tip?: string }> {
|
||||
const dimsOf = (w: number | undefined, h: number | undefined): string | null =>
|
||||
w !== undefined && h !== undefined ? `${w}×${h}` : null
|
||||
if (ref.kind !== 'image') {
|
||||
return ref.bytes === undefined ? [] : [{ label: t('attach.size'), value: fmtBytes(ref.bytes) }]
|
||||
}
|
||||
const rows: Array<{ label: string; value: string; tip?: string }> = []
|
||||
// No byte size is stored for the raw raster — dims only.
|
||||
const rawDims = ref.originalDimensions !== undefined
|
||||
? dimsOf(ref.originalDimensions.width, ref.originalDimensions.height)
|
||||
: null
|
||||
if (rawDims !== null) rows.push({ label: t('attach.raw'), value: rawDims })
|
||||
const sent = dimsOf(ref.width, ref.height)
|
||||
if (sent !== null) {
|
||||
rows.push({
|
||||
label: t('attach.sent'),
|
||||
value: ref.bytes !== undefined ? `${sent} · ${fmtBytes(ref.bytes)}` : sent,
|
||||
})
|
||||
}
|
||||
const tokens = ref.width !== undefined && ref.height !== undefined
|
||||
? estimateImageTokens(ref.width, ref.height)
|
||||
: null
|
||||
if (tokens !== null) rows.push({ label: t('attach.token'), value: `≈${fmt(tokens)}`, tip: t('attach.tokensTip') })
|
||||
return rows
|
||||
}
|
||||
|
||||
function AttachmentCard(props: {
|
||||
attachment: AttachmentRef
|
||||
load?: ImageLoader
|
||||
/** This card's own open state: resolving the preview path, or refused. */
|
||||
state: 'idle' | 'opening' | 'failed'
|
||||
onOpen: () => void
|
||||
t: ViewKit['t']
|
||||
fmt: ViewKit['fmt']
|
||||
}): ReactElement {
|
||||
const { attachment, load, state, t, fmt } = props
|
||||
const name = nameOf(attachment, t)
|
||||
const image = attachment.kind === 'image'
|
||||
const thumb = useImageSrc(attachment, image ? load : undefined)
|
||||
const hint = t('attach.hint')
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={'lc-att-item hover:border-(--dsw-alias-label-dimmed) hover:bg-[var(--dsw-alias-interactive-bg-hover,var(--dsw-alias-bg-layer-2))]'
|
||||
+ (state === 'failed' ? ' lc-att-item-failed' : '')}
|
||||
title={name + ' · ' + hint}
|
||||
data-state={state}
|
||||
onClick={props.onOpen}
|
||||
>
|
||||
{image
|
||||
? (
|
||||
<span className="lc-att-thumb">
|
||||
{thumb.src !== null
|
||||
? <img src={thumb.src} alt={name} />
|
||||
: (
|
||||
<span className={thumb.failed ? 'lc-att-err' : 'lc-att-ph'}>
|
||||
{thumb.failed ? '⚠' : load === undefined ? '🖼' : t('attach.loading')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
: (
|
||||
<span className="lc-att-thumb lc-att-thumb-file">
|
||||
<FileTypeGlyph path={name} size={28} />
|
||||
<span className="lc-att-ext">{typeOf(attachment, t)}</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="lc-att-meta">
|
||||
<span className="lc-att-name" title={name}>{name}</span>
|
||||
<span className="lc-att-row"><b className="lc-att-row-label">{t('attach.type')}</b>{typeOf(attachment, t)}</span>
|
||||
{factsOf(attachment, t, fmt).map(r => (
|
||||
<span key={r.label} className="lc-att-row" title={r.tip}>
|
||||
<b className="lc-att-row-label">{r.label}</b>{r.value}
|
||||
</span>
|
||||
))}
|
||||
{state === 'failed' ? <span className="lc-att-fail">{t('attach.unavailable')}</span> : null}
|
||||
{state === 'failed' ? null : (
|
||||
<span className={'lc-att-hint' + (state === 'opening' ? ' lc-att-hint-on' : '')}>
|
||||
{state === 'opening' ? t('attach.loading') : hint}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/** The ordered attachment block. `load`/`open` are optional: without them the cards stay metadata
|
||||
* only (a harness with no image face, a deployment with no preview column). */
|
||||
export function makeAttachmentSection(kit: ViewKit): AttachmentKit['Section'] {
|
||||
const { t, fmt } = kit
|
||||
return function AttachmentSection(props: {
|
||||
attachments: readonly AttachmentRef[]
|
||||
load?: ImageLoader
|
||||
open?: AttachmentOpener
|
||||
}): ReactElement {
|
||||
const { attachments, load, open } = props
|
||||
const [pending, setPending] = useState<string | null>(null)
|
||||
const [failed, setFailed] = useState<string | null>(null)
|
||||
// A content swap (another message's block) drops every stale state rather than acting on a stranger.
|
||||
const key = useMemo(() => attachments.map(a => a.attachmentId).join('\u0000'), [attachments])
|
||||
useEffect(() => {
|
||||
setPending(null)
|
||||
setFailed(null)
|
||||
}, [key])
|
||||
const activate = useCallback((ref: AttachmentRef) => {
|
||||
if (open === undefined) {
|
||||
setFailed(ref.attachmentId)
|
||||
return
|
||||
}
|
||||
setPending(ref.attachmentId)
|
||||
setFailed(null)
|
||||
void open(ref).then((taken) => {
|
||||
setPending(cur => (cur === ref.attachmentId ? null : cur))
|
||||
setFailed(taken ? null : ref.attachmentId)
|
||||
})
|
||||
}, [open])
|
||||
if (attachments.length === 0) return <></>
|
||||
return (
|
||||
<Section label={t('attach.title')} count={attachments.length}>
|
||||
<div className="lc-att-grid">
|
||||
{attachments.map((a, i) => (
|
||||
<AttachmentCard
|
||||
key={a.attachmentId + ':' + String(i)}
|
||||
attachment={a}
|
||||
load={load}
|
||||
t={t}
|
||||
fmt={fmt}
|
||||
state={pending === a.attachmentId ? 'opening' : failed === a.attachmentId ? 'failed' : 'idle'}
|
||||
onOpen={() => { activate(a) }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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) => (
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ReactElement, ReactNode } from 'react'
|
||||
import type { Category, SurfaceNode } from '../../shared/types'
|
||||
import type { Assembled } from '../assemble'
|
||||
import type { ConversationNodeLike } from '../services'
|
||||
import type { ImageKit } from './images'
|
||||
import type { AttachmentKit } from './attachments'
|
||||
import type { RichKit } from './richText'
|
||||
import { TextSection, BlocksBody, toolErrOf, ToolCallCard } from './browserToolCard'
|
||||
import type { DetailLabels } from './browserToolCard'
|
||||
@@ -14,10 +14,10 @@ export function NodeContent(props: {
|
||||
conv: ConversationNodeLike | undefined
|
||||
hint: ReactNode
|
||||
rich: RichKit
|
||||
img: ImageKit
|
||||
att: AttachmentKit
|
||||
labels: DetailLabels
|
||||
}): ReactElement {
|
||||
const { node, conv, rich, img, labels } = props
|
||||
const { node, conv, rich, att, labels } = props
|
||||
if (conv === undefined) {
|
||||
if (node.text === undefined || node.text === '') {
|
||||
return <div className="lc-br-note">{props.hint}</div>
|
||||
@@ -30,7 +30,7 @@ export function NodeContent(props: {
|
||||
)
|
||||
}
|
||||
if (conv.kind === 'assistant' && Array.isArray(conv.blocks)) {
|
||||
return <BlocksBody blocks={conv.blocks} textLabel={labels.answer} rich={rich} img={img} labels={labels} />
|
||||
return <BlocksBody blocks={conv.blocks} textLabel={labels.answer} rich={rich} att={att} labels={labels} />
|
||||
}
|
||||
if (conv.kind === 'tool-result') {
|
||||
const { err, exit } = toolErrOf(node, conv)
|
||||
@@ -45,7 +45,7 @@ export function NodeContent(props: {
|
||||
/>
|
||||
: null}
|
||||
{Array.isArray(conv.content)
|
||||
? <BlocksBody blocks={conv.content} textLabel={labels.result} rich={rich} img={img} labels={labels} />
|
||||
? <BlocksBody blocks={conv.content} textLabel={labels.result} rich={rich} att={att} labels={labels} />
|
||||
: null}
|
||||
</>
|
||||
)
|
||||
@@ -56,7 +56,7 @@ export function NodeContent(props: {
|
||||
: <></>
|
||||
}
|
||||
if (Array.isArray(conv.content)) {
|
||||
return <BlocksBody blocks={conv.content} textLabel={labels.content} rich={rich} img={img} labels={labels} />
|
||||
return <BlocksBody blocks={conv.content} textLabel={labels.content} rich={rich} att={att} labels={labels} />
|
||||
}
|
||||
return <div className="lc-br-note">{props.hint}</div>
|
||||
}
|
||||
|
||||
@@ -4,10 +4,11 @@ import { useMemo, useState, type ChangeEvent, type ReactElement, type ReactNode
|
||||
import type { SurfaceNode } from '../../shared/types'
|
||||
import type { ConversationNodeLike } from '../services'
|
||||
import { parseCallArgs } from '../callSummary'
|
||||
import { imageRefOf } from './images'
|
||||
import type { ImageKit } from './images'
|
||||
import { attachmentOf } from '../../shared/attachment'
|
||||
import type { AttachmentRef } from '../../shared/attachment'
|
||||
import type { AttachmentKit } from './attachments'
|
||||
import type { RichKit } from './richText'
|
||||
import type { ImageRefLike } from '../services'
|
||||
import { Section } from './section'
|
||||
import { typeOf, paramsOf } from './browserSchema'
|
||||
import type { ParamSchema } from './browserSchema'
|
||||
|
||||
@@ -32,29 +33,6 @@ function ParamRow(props: {
|
||||
)
|
||||
}
|
||||
|
||||
export function Section(props: {
|
||||
label: string
|
||||
labelClass?: string
|
||||
/** Fold the head's trailing group onto a second line under width pressure (rich-text heads; the call-name head must stay one-line). */
|
||||
foldHead?: boolean
|
||||
count?: number
|
||||
actions?: ReactNode
|
||||
meta?: ReactNode
|
||||
children: ReactNode
|
||||
}): ReactElement {
|
||||
const right = props.actions !== undefined || props.meta !== undefined
|
||||
return (
|
||||
<div className="lc-ts-card">
|
||||
<div className={'lc-ts-card-head' + (props.foldHead === true ? ' lc-ts-card-head-wrap' : '')}>
|
||||
<b className={props.labelClass} title={props.label}>{props.label}</b>
|
||||
{right ? <span className="lc-ts-card-right">{props.meta}{props.actions}</span> : null}
|
||||
{props.count !== undefined ? <span className="lc-ts-card-count">{props.count}</span> : null}
|
||||
</div>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function lineCountOf(text: string): number {
|
||||
return text.split(/\r\n|\r|\n/).length
|
||||
}
|
||||
@@ -190,40 +168,39 @@ export interface DetailLabels {
|
||||
content: string
|
||||
result: string
|
||||
summary: string
|
||||
images: string
|
||||
other: string
|
||||
lines: (n: number) => string
|
||||
callState: (err: boolean, exit: number | null) => ReactNode
|
||||
}
|
||||
|
||||
/** Both block vocabularies normalize here — raw durable blocks (`type`) and snapshot assistant blocks (`kind`).
|
||||
* Consecutive images group into one grid; nested tool-result blocks flatten into the same flow. */
|
||||
/** Both block vocabularies normalize here — raw durable blocks (`type`) and snapshot assistant
|
||||
* blocks (`kind`). Every attachment block collects into ONE ordered attachment group, rendered where
|
||||
* the first of them sits (the chat's own arrangement: attachments above the text); nested
|
||||
* tool-result blocks flatten into the same flow. */
|
||||
export function BlocksBody(props: {
|
||||
blocks: readonly unknown[]
|
||||
textLabel: string
|
||||
rich: RichKit
|
||||
img: ImageKit
|
||||
att: AttachmentKit
|
||||
labels: DetailLabels
|
||||
}): ReactElement {
|
||||
const { rich, img, labels } = props
|
||||
const { rich, att, labels } = props
|
||||
const out: ReactNode[] = []
|
||||
let images: ImageRefLike[] = []
|
||||
const flushImages = (): void => {
|
||||
if (images.length === 0) return
|
||||
const group = images
|
||||
images = []
|
||||
out.push(
|
||||
<Section key={'img' + String(out.length)} label={labels.images} count={group.length}>
|
||||
<div className="lc-att-grid">
|
||||
{group.map((a, i) => <img.Card key={`${a.attachmentId}:${i}`} attachment={a} load={img.load} />)}
|
||||
</div>
|
||||
</Section>,
|
||||
)
|
||||
}
|
||||
for (const b of props.blocks) {
|
||||
const image = imageRefOf(b)
|
||||
if (image !== null) { images.push(image); continue }
|
||||
flushImages()
|
||||
const refs = props.blocks.map(attachmentOf)
|
||||
const attachments = refs.filter((ref): ref is AttachmentRef => ref !== null)
|
||||
const firstAt = refs.findIndex(ref => ref !== null)
|
||||
for (const [i, b] of props.blocks.entries()) {
|
||||
if (i === firstAt) {
|
||||
out.push(
|
||||
<att.Section
|
||||
key={'att' + String(out.length)}
|
||||
attachments={attachments}
|
||||
load={att.load}
|
||||
open={att.open}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
if (refs[i] !== null) continue
|
||||
const blk = b !== null && typeof b === 'object'
|
||||
? b as { type?: unknown; kind?: unknown; text?: unknown; name?: unknown; argsRaw?: unknown; arguments?: unknown; content?: unknown }
|
||||
: null
|
||||
@@ -255,14 +232,13 @@ export function BlocksBody(props: {
|
||||
blocks={blk.content as unknown[]}
|
||||
textLabel={labels.result}
|
||||
rich={rich}
|
||||
img={img}
|
||||
att={att}
|
||||
labels={labels}
|
||||
/>)
|
||||
continue
|
||||
}
|
||||
out.push(<Section key={out.length} label={labels.other}><pre className="lc-ts-desc-body lc-br-dim">{JSON.stringify(b, null, 2)}</pre></Section>)
|
||||
}
|
||||
flushImages()
|
||||
return <>{out}</>
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
/** dsh stores only a normalized reference per image (no inline bytes); the display URL comes from the harness
|
||||
* `uiConversation.imageUrl` loader handed down as `load`. */
|
||||
|
||||
import { useCallback, useEffect, useState, type ReactElement } from 'react'
|
||||
import { ImageLightbox } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { fmtBytes } from '../format'
|
||||
import type { ImageLoader, ImageRefLike } from '../services'
|
||||
import { estimateImageTokens } from '../../shared/imageTokens'
|
||||
import type { ViewKit } from '../viewkit'
|
||||
|
||||
export interface ImageKit {
|
||||
Card: (props: { attachment: ImageRefLike; load?: ImageLoader }) => ReactElement
|
||||
load?: ImageLoader
|
||||
}
|
||||
|
||||
/** Accepts both raw message blocks (`type: 'image'`) and the snapshot's assistant blocks (`kind: 'image'`);
|
||||
* every optional fact is re-proved. */
|
||||
export function imageRefOf(block: unknown): ImageRefLike | null {
|
||||
if (block === null || typeof block !== 'object') return null
|
||||
const b = block as { type?: unknown; kind?: unknown; attachment?: unknown }
|
||||
if (b.type !== 'image' && b.kind !== 'image') return null
|
||||
const a = b.attachment
|
||||
if (a === null || typeof a !== 'object') return null
|
||||
const r = a as Record<string, unknown>
|
||||
if (typeof r.attachmentId !== 'string' || r.attachmentId === '') return null
|
||||
const num = (v: unknown): number | undefined =>
|
||||
typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined
|
||||
const orig = r.originalDimensions !== null && typeof r.originalDimensions === 'object'
|
||||
? r.originalDimensions as Record<string, unknown>
|
||||
: undefined
|
||||
const origDims = orig !== undefined && num(orig.width) !== undefined && num(orig.height) !== undefined
|
||||
? { width: num(orig.width) as number, height: num(orig.height) as number }
|
||||
: undefined
|
||||
return {
|
||||
attachmentId: r.attachmentId,
|
||||
...(typeof r.name === 'string' && r.name !== '' ? { name: r.name } : {}),
|
||||
...(num(r.bytes) !== undefined ? { bytes: num(r.bytes) } : {}),
|
||||
...(num(r.width) !== undefined ? { width: num(r.width) } : {}),
|
||||
...(num(r.height) !== undefined ? { height: num(r.height) } : {}),
|
||||
...(origDims !== undefined ? { originalDimensions: origDims } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function makeImageCard(kit: ViewKit): ImageKit['Card'] {
|
||||
const { t, fmt } = kit
|
||||
return function ImageCard(props: { attachment: ImageRefLike; load?: ImageLoader }): ReactElement {
|
||||
const { attachment, load } = props
|
||||
const [src, setSrc] = useState<string | null>(null)
|
||||
const [error, setError] = useState(false)
|
||||
const [attempt, setAttempt] = useState(0)
|
||||
const [preview, setPreview] = useState(false)
|
||||
const closePreview = useCallback(() => { setPreview(false) }, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (load === undefined) return
|
||||
let live = true
|
||||
setError(false)
|
||||
setSrc(null)
|
||||
void load(attachment).then((url) => { if (live) setSrc(url) }).catch(() => { if (live) setError(true) })
|
||||
return () => { live = false }
|
||||
}, [attachment, load, attempt])
|
||||
|
||||
const name = attachment.name ?? t('attach.image')
|
||||
const dimsOf = (w: number | undefined, h: number | undefined): string | null =>
|
||||
w !== undefined && h !== undefined ? `${w}×${h}` : null
|
||||
const rows: Array<{ label: string; value: string; tip?: string }> = []
|
||||
// No byte size is stored for the raw raster — dims only.
|
||||
const raw = attachment.originalDimensions !== undefined
|
||||
? dimsOf(attachment.originalDimensions.width, attachment.originalDimensions.height)
|
||||
: null
|
||||
if (raw !== null) rows.push({ label: t('attach.raw'), value: raw })
|
||||
const sent = dimsOf(attachment.width, attachment.height)
|
||||
if (sent !== null) {
|
||||
rows.push({
|
||||
label: t('attach.sent'),
|
||||
value: attachment.bytes !== undefined ? `${sent} · ${fmtBytes(attachment.bytes)}` : sent,
|
||||
})
|
||||
}
|
||||
const tokens = attachment.width !== undefined && attachment.height !== undefined
|
||||
? estimateImageTokens(attachment.width, attachment.height)
|
||||
: null
|
||||
if (tokens !== null) rows.push({ label: t('attach.token'), value: `≈${fmt(tokens)}`, tip: t('attach.tokensTip') })
|
||||
|
||||
const activate = (): void => {
|
||||
if (error) { setAttempt(a => a + 1); return }
|
||||
if (src !== null) setPreview(true)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="lc-att-item hover:border-(--dsw-alias-label-dimmed) hover:bg-[var(--dsw-alias-interactive-bg-hover,var(--dsw-alias-bg-layer-2))]"
|
||||
title={error ? t('attach.loadFailed') : t('attach.open')}
|
||||
onClick={activate}
|
||||
>
|
||||
<span className="lc-att-thumb">
|
||||
{src !== null
|
||||
? <img src={src} alt={name} />
|
||||
: (
|
||||
<span className={error ? 'lc-att-err' : 'lc-att-ph'}>
|
||||
{error ? '⚠' : load === undefined ? '🖼' : t('attach.loading')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="lc-att-meta">
|
||||
<span className="lc-att-name" title={name}>{name}</span>
|
||||
{rows.map(r => (
|
||||
<span key={r.label} className="lc-att-row" title={r.tip}>
|
||||
<b className="lc-att-row-label">{r.label}</b>{r.value}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</button>
|
||||
{preview && src !== null && (
|
||||
<ImageLightbox
|
||||
src={src}
|
||||
alt={name}
|
||||
labels={{ dialog: t('attach.preview'), close: t('attach.close') }}
|
||||
onClose={closePreview}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/** The Context browser's titled card head, shared by the tool/detail cards and the attachment block. */
|
||||
|
||||
import type { ReactElement, ReactNode } from 'react'
|
||||
|
||||
export function Section(props: {
|
||||
label: string
|
||||
labelClass?: string
|
||||
/** Fold the head's trailing group onto a second line under width pressure (rich-text heads; the call-name head must stay one-line). */
|
||||
foldHead?: boolean
|
||||
count?: number
|
||||
actions?: ReactNode
|
||||
meta?: ReactNode
|
||||
children: ReactNode
|
||||
}): ReactElement {
|
||||
const right = props.actions !== undefined || props.meta !== undefined
|
||||
return (
|
||||
<div className="lc-ts-card">
|
||||
<div className={'lc-ts-card-head' + (props.foldHead === true ? ' lc-ts-card-head-wrap' : '')}>
|
||||
<b className={props.labelClass} title={props.label}>{props.label}</b>
|
||||
{right ? <span className="lc-ts-card-right">{props.meta}{props.actions}</span> : null}
|
||||
{props.count !== undefined ? <span className="lc-ts-card-count">{props.count}</span> : null}
|
||||
</div>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** Named imports from the ui-primitives platform module, resolved defensively: an absent or renamed
|
||||
* export would render as React error #130 inside the Context tab, so each icon resolves by name
|
||||
* export would render as React error #130 inside the Context tab, so each component resolves by name
|
||||
* through a bounded read and degrades to a render-nothing fallback. */
|
||||
|
||||
import type { ReactElement } from 'react'
|
||||
@@ -10,13 +10,19 @@ export type IconComponent = (props: IconProps) => ReactElement | null
|
||||
|
||||
const ns = primitives as unknown as Record<string, unknown>
|
||||
|
||||
/** One export's raw value: undefined when the name is absent or the namespace read throws. */
|
||||
function exportOf(name: string, source: Record<string, unknown>): unknown {
|
||||
try {
|
||||
return source[name]
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve one icon by export name; a missing, non-component, or hostile-namespace read renders nothing. */
|
||||
export function resolveIcon(name: string, source: Record<string, unknown> = ns): IconComponent {
|
||||
try {
|
||||
const found = source[name]
|
||||
if (typeof found === 'function') return found as IconComponent
|
||||
} catch { /* the fallback below */ }
|
||||
return () => null
|
||||
const found = exportOf(name, source)
|
||||
return typeof found === 'function' ? found as IconComponent : () => null
|
||||
}
|
||||
|
||||
export const IconBranch = resolveIcon('IconBranchOutlineRegular')
|
||||
@@ -28,3 +34,21 @@ export const IconSettings = resolveIcon('IconSettingsOutlineMedium')
|
||||
export const IconChevronDown = resolveIcon('IconChevronDownOutlineMedium')
|
||||
export const IconChevronUp = resolveIcon('IconChevronUpOutlineMedium')
|
||||
export const IconSearch = resolveIcon('IconSearchOutlineRegular')
|
||||
|
||||
/** The harness's file-type glyph: its props are its own (`path`/`kind`), not an icon's. */
|
||||
export interface FileGlyphProps {
|
||||
path?: string
|
||||
kind?: string
|
||||
size?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
export type FileGlyphComponent = (props: FileGlyphProps) => ReactElement | null
|
||||
|
||||
/** Resolve the file-type glyph the defensive way the icons resolve. */
|
||||
export function resolveFileGlyph(source: Record<string, unknown> = ns): FileGlyphComponent {
|
||||
const found = exportOf('FileTypeIcon', source)
|
||||
return typeof found === 'function' ? found as FileGlyphComponent : () => null
|
||||
}
|
||||
|
||||
export const FileTypeGlyph = resolveFileGlyph()
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { ComponentType } from 'react'
|
||||
import type { AttachmentRef } from '../shared/attachment'
|
||||
import { estimateSystemTokens } from '../shared/estimate'
|
||||
import type { ActivityDay, ContextActivity, ContextBreakdown, ContextHeaders, ContextPressure, ContextTimeline, CostModelUsage, HeaderEpochContent, SystemPromptNode, TimingTotals, TokenUsage, ToolTimingTotals } from '../shared/types'
|
||||
|
||||
@@ -138,6 +139,13 @@ export function imageLoaderOf(
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** The named host path of one session-cited attachment (the plugin's own location route);
|
||||
* null resolves the browser's unavailable note. */
|
||||
export type AttachmentLocator = (attachment: AttachmentRef) => Promise<string | null>
|
||||
|
||||
/** On-click opener of one attachment's preview; false = nothing took it. */
|
||||
export type AttachmentOpener = (attachment: AttachmentRef) => Promise<boolean>
|
||||
|
||||
/** The `useChat` seat. The selector must return a reference-stable slice: a fresh object re-renders the view on every event. */
|
||||
export type UseChatLike = <T>(selector: (snapshot: unknown) => T) => T
|
||||
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* The attachment-preview route (`/api/dsh-context/attachment`) — the previewable host path of one
|
||||
* attachment a session's log cites.
|
||||
*
|
||||
* dsh's own preview column renders a file from a `dsh-resource://file/…` address and dispatches its
|
||||
* viewer by the path's SUFFIX, so this route answers with a path that carries the attachment's name:
|
||||
*
|
||||
* - A verbatim FILE already has one: the attachment store publishes every stored file as a named
|
||||
* alias (`<home>/attachments/v1/files/<xx>/<digest>/<name>`), returned by `attachments.fileHostPath`.
|
||||
* - A normalized IMAGE does not — its object is digest-named and suffixless — so the route publishes
|
||||
* (or reuses) a named hard link under the plugin's own preview cache and answers with that. The
|
||||
* link shares the object's immutable, digest-verified bytes; nothing is copied on the common path.
|
||||
*
|
||||
* No bytes travel: the browser hands the path to the harness, exactly as the File Activity card does.
|
||||
*
|
||||
* Same transport and posture as the detail and fleet routes (detail.ts): Connection's exact
|
||||
* Fetch-route registry behind the authenticated `/api` fence, the durable log read through
|
||||
* `ctx.sessionQuery.observeSession` (live-preferred), and the read admitted by the shared cold-read
|
||||
* gate. Authorization mirrors the harness's own attachment gateway: a path resolves only while the
|
||||
* SESSION'S OWN log cites the attachment, so an id an untrusted replay invented resolves to nothing.
|
||||
*/
|
||||
|
||||
import { copyFile, link, mkdir } from 'node:fs/promises'
|
||||
import { basename, dirname, join } from 'node:path'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { attachmentOf, isImageName, leafNameOf } from '../shared/attachment'
|
||||
import type { AttachmentRef } from '../shared/attachment'
|
||||
import { type ColdReadGate, makeColdReadGate } from './coldRead'
|
||||
import { type ConnectionHostFace, registerPostRoute } from './connection'
|
||||
|
||||
/** The plugin's attachment route, under the authenticated `/api` fence. */
|
||||
export const ATTACHMENT_ROUTE = '/api/dsh-context/attachment'
|
||||
|
||||
/** Normalized image media types → the suffix a viewer dispatches on. */
|
||||
const IMAGE_MEDIA_EXTENSION: Readonly<Record<string, string>> = {
|
||||
'image/png': 'png',
|
||||
'image/jpeg': 'jpg',
|
||||
'image/webp': 'webp',
|
||||
'image/gif': 'gif',
|
||||
}
|
||||
|
||||
/** The host attachment store, as far as the route consumes it. */
|
||||
interface AttachmentStoreFace {
|
||||
fileHostPath?(ref: unknown): string | undefined
|
||||
imageHostPath?(ref: unknown): string | undefined
|
||||
}
|
||||
|
||||
interface SessionQueryFace {
|
||||
observeSession?(id: string, options: unknown): Promise<unknown>
|
||||
}
|
||||
|
||||
function reply(value: unknown): Response {
|
||||
return Response.json(value, { headers: { 'cache-control': 'no-store' } })
|
||||
}
|
||||
|
||||
function failure(code: string, message: string): Response {
|
||||
return reply({ ok: false, error: { code, message } })
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null
|
||||
}
|
||||
|
||||
/** Depth cap for the payload walk: real citation sites sit at `data.content[…]`,
|
||||
* `data.message.content[…]`, and `data.inserted[…].content[…]` (a spliced inbox delivery). */
|
||||
const WALK_MAX_DEPTH = 8
|
||||
|
||||
/** The first attachment block citing `id` anywhere below `value`, or null. Untrusted input: only
|
||||
* arrays and objects are walked, the depth is capped, and a hostile object drops that subtree. */
|
||||
function attachmentIn(value: unknown, id: string, depth: number): AttachmentRef | null {
|
||||
if (depth > WALK_MAX_DEPTH) return null
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
const hit = attachmentIn(item, id, depth + 1)
|
||||
if (hit !== null) return hit
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (value === null || typeof value !== 'object') return null
|
||||
const ref = attachmentOf(value)
|
||||
if (ref !== null && ref.attachmentId === id) return ref
|
||||
for (const child of Object.values(value as Record<string, unknown>)) {
|
||||
const hit = attachmentIn(child, id, depth + 1)
|
||||
if (hit !== null) return hit
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** The session's own citation of one attachment: the first logged block naming it, wherever the
|
||||
* event payload carries it — a user message's content, a tool result's blocks, a spliced inbox
|
||||
* delivery. An untrusted log: a hostile payload drops out instead of throwing. */
|
||||
export function referencedAttachmentOf(events: readonly unknown[], attachmentId: string): AttachmentRef | null {
|
||||
for (const entry of events) {
|
||||
try {
|
||||
const hit = attachmentIn(entry, attachmentId, 0)
|
||||
if (hit !== null) return hit
|
||||
} catch { /* a hostile payload names nothing */ }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** The name one image previews under. The preview column dispatches its viewer by suffix, so a
|
||||
* display name counts only when its suffix is one a viewer claims — the verified media type names
|
||||
* the rest (a re-encoded source keeps its original name), and an image whose media type the harness
|
||||
* never admits names nothing. */
|
||||
export function imagePreviewNameOf(ref: AttachmentRef): string | null {
|
||||
const leaf = leafNameOf(ref.name ?? '')
|
||||
if (leaf !== '' && isImageName(leaf)) return leaf
|
||||
const extension = ref.mediaType !== undefined ? IMAGE_MEDIA_EXTENSION[ref.mediaType] : undefined
|
||||
return extension === undefined ? null : `attachment.${extension}`
|
||||
}
|
||||
|
||||
/** The plugin's preview cache under the same home the attachment store lives in: the store's object
|
||||
* path is `<home>/attachments/v1/objects/<xx>/<digest>`. Null when the path is not that shape (an
|
||||
* unfamiliar backend), so the route reports no preview instead of guessing a directory. */
|
||||
function previewAliasDirOf(objectPath: string): string | null {
|
||||
const root = dirname(dirname(dirname(objectPath)))
|
||||
if (basename(root) !== 'v1' || basename(dirname(root)) !== 'attachments') return null
|
||||
return join(dirname(dirname(root)), 'cache', 'dsh-context', 'previews')
|
||||
}
|
||||
|
||||
/** The verbatim file's own named alias, or null when the store cannot locate it (an absent store, a
|
||||
* non-host-file backend, or a reference the store refuses). */
|
||||
function filePathOf(store: AttachmentStoreFace | undefined, ref: AttachmentRef): string | null {
|
||||
if (typeof store?.fileHostPath !== 'function') return null
|
||||
try {
|
||||
const path = store.fileHostPath({ attachmentId: ref.attachmentId, name: ref.name ?? '', bytes: ref.bytes ?? 0 })
|
||||
return typeof path === 'string' && path !== '' ? path : null
|
||||
} catch {
|
||||
// A malformed reference (bad digest, unsanitized name) is no path, never a throw.
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Publish (or reuse) the named hard link an image previews through; an existing link is the same
|
||||
* immutable object, so it is the answer. A cross-device or otherwise link-less host falls back to a
|
||||
* copy, and any failure resolves null rather than throwing out of the route. */
|
||||
async function imageAliasOf(store: AttachmentStoreFace | undefined, ref: AttachmentRef): Promise<string | null> {
|
||||
if (typeof store?.imageHostPath !== 'function') return null
|
||||
let objectPath: string | undefined
|
||||
try {
|
||||
objectPath = store.imageHostPath({
|
||||
attachmentId: ref.attachmentId,
|
||||
...(ref.mediaType !== undefined ? { mediaType: ref.mediaType } : {}),
|
||||
...(ref.bytes !== undefined ? { bytes: ref.bytes } : {}),
|
||||
...(ref.width !== undefined ? { width: ref.width } : {}),
|
||||
...(ref.height !== undefined ? { height: ref.height } : {}),
|
||||
...(ref.name !== undefined ? { name: ref.name } : {}),
|
||||
})
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (typeof objectPath !== 'string' || objectPath === '') return null
|
||||
const name = imagePreviewNameOf(ref)
|
||||
if (name === null) return null
|
||||
const directory = previewAliasDirOf(objectPath)
|
||||
if (directory === null) return null
|
||||
const alias = join(directory, basename(dirname(objectPath)), basename(objectPath), name)
|
||||
try {
|
||||
await mkdir(dirname(alias), { recursive: true })
|
||||
try {
|
||||
await link(objectPath, alias)
|
||||
} catch (err) {
|
||||
if (!(err instanceof Error && 'code' in err && err.code === 'EEXIST')) await copyFile(objectPath, alias)
|
||||
}
|
||||
return alias
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** The previewable path of one cited attachment: a file's own named alias, or an image's. */
|
||||
async function previewPathOf(store: AttachmentStoreFace | undefined, ref: AttachmentRef): Promise<string | null> {
|
||||
return ref.kind === 'file' ? filePathOf(store, ref) : await imageAliasOf(store, ref)
|
||||
}
|
||||
|
||||
/** Serve the attachment route whenever the connection service is composed (the fleet route's own
|
||||
* posture); sessionQuery and the attachment store are re-read per request, so a late-arriving
|
||||
* service still arms. */
|
||||
export function watchAttachmentChannel(ctx: Context, coldReads: ColdReadGate = makeColdReadGate()): void {
|
||||
ctx.inject(['connection'], (c) => {
|
||||
const handler = async (request: Request): Promise<Response> => {
|
||||
let sessionId: unknown
|
||||
let attachmentId: unknown
|
||||
try {
|
||||
const body = asRecord(await request.json())
|
||||
sessionId = body?.sessionId
|
||||
attachmentId = body?.attachmentId
|
||||
} catch {
|
||||
return failure('dsh-context/bad-request', 'body is not JSON')
|
||||
}
|
||||
if (typeof sessionId !== 'string' || sessionId === '') {
|
||||
return failure('dsh-context/bad-request', 'missing sessionId')
|
||||
}
|
||||
if (typeof attachmentId !== 'string' || attachmentId === '') {
|
||||
return failure('dsh-context/bad-request', 'missing attachmentId')
|
||||
}
|
||||
try {
|
||||
const query = ctx.get('sessionQuery') as SessionQueryFace | undefined
|
||||
const observe = typeof query?.observeSession === 'function'
|
||||
? query.observeSession.bind(query)
|
||||
: undefined
|
||||
if (observe === undefined) return reply({ ok: true, value: null })
|
||||
const store = ctx.get('attachments') as AttachmentStoreFace | undefined
|
||||
const value = await coldReads.admit(async () => {
|
||||
const observation = await observe(sessionId, { projectionMode: 'none' })
|
||||
try {
|
||||
const events = (observation as { events?: unknown } | null)?.events
|
||||
if (!Array.isArray(events)) return null
|
||||
const ref = referencedAttachmentOf(events, attachmentId)
|
||||
if (ref === null) return null
|
||||
const path = await previewPathOf(store, ref)
|
||||
return path === null ? null : { path }
|
||||
} finally {
|
||||
const dispose = (observation as { [Symbol.dispose]?: unknown } | null)?.[Symbol.dispose]
|
||||
if (typeof dispose === 'function') dispose.call(observation)
|
||||
}
|
||||
})
|
||||
return reply({ ok: true, value: value ?? null })
|
||||
} catch (err) {
|
||||
return failure('gateway/internal', err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
}
|
||||
registerPostRoute(c, c.get('connection') as ConnectionHostFace | undefined, ATTACHMENT_ROUTE, handler, 'dsh-context: attachment route')
|
||||
})
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* The durable attachment vocabulary both halves share. A user message's content carries
|
||||
* `{ type: 'image' | 'file', attachment: … }` blocks (the conversation snapshot spells the same
|
||||
* blocks `kind:`), and the host's preview route must re-prove WHICH attachment a session cites
|
||||
* before resolving its stored object — so one parser normalizes both block shapes and both
|
||||
* spellings here.
|
||||
*/
|
||||
|
||||
export type AttachmentKind = 'image' | 'file'
|
||||
|
||||
/** One attachment block's proven facts; every optional field is re-proved, never borrowed. */
|
||||
export interface AttachmentRef {
|
||||
kind: AttachmentKind
|
||||
attachmentId: string
|
||||
name?: string
|
||||
bytes?: number
|
||||
/** Images only: the media type verified when the raster was admitted. */
|
||||
mediaType?: string
|
||||
width?: number
|
||||
height?: number
|
||||
originalDimensions?: { width: number; height: number }
|
||||
}
|
||||
|
||||
/** Suffixes the preview column's image viewers claim — the harness's own image-body extensions. */
|
||||
const EXTENSION_IMAGE: ReadonlySet<string> = new Set([
|
||||
'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'ico', 'svg',
|
||||
])
|
||||
|
||||
/** The leaf of a display name: both separator styles stripped, so a hostile log path never names a
|
||||
* file outside the directory the caller resolves it in. */
|
||||
export function leafNameOf(name: string): string {
|
||||
return name.slice(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1)
|
||||
}
|
||||
|
||||
/** The lower-case extension of a display name (no dot), following the harness's own `fileExtension`:
|
||||
* a leading dot starts the suffix, so `.env` is `env`; a missing dot yields an empty string. */
|
||||
export function attachmentExtension(name: string): string {
|
||||
const base = leafNameOf(name)
|
||||
const dot = base.lastIndexOf('.')
|
||||
return dot < 0 ? '' : base.slice(dot + 1).toLowerCase()
|
||||
}
|
||||
|
||||
/** Whether a display name's suffix is one the preview column's image viewers claim — the host names
|
||||
* an image's preview alias by it, and an image whose name carries another suffix is named by its
|
||||
* verified media type instead. */
|
||||
export function isImageName(name: string): boolean {
|
||||
return EXTENSION_IMAGE.has(attachmentExtension(name))
|
||||
}
|
||||
|
||||
function numOf(value: unknown): number | undefined {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined
|
||||
}
|
||||
|
||||
function dimensionsOf(value: unknown): { width: number; height: number } | undefined {
|
||||
if (value === null || typeof value !== 'object') return undefined
|
||||
const dims = value as Record<string, unknown>
|
||||
const width = numOf(dims.width)
|
||||
const height = numOf(dims.height)
|
||||
return width !== undefined && height !== undefined ? { width, height } : undefined
|
||||
}
|
||||
|
||||
/** One content block → its attachment reference, or null when it is none. Accepts the durable
|
||||
* `type:` spelling and the snapshot's `kind:`, and re-proves every optional fact. */
|
||||
export function attachmentOf(block: unknown): AttachmentRef | null {
|
||||
if (block === null || typeof block !== 'object') return null
|
||||
const b = block as { type?: unknown; kind?: unknown; attachment?: unknown }
|
||||
const spelling = typeof b.type === 'string' ? b.type : b.kind
|
||||
if (spelling !== 'image' && spelling !== 'file') return null
|
||||
if (b.attachment === null || typeof b.attachment !== 'object') return null
|
||||
const a = b.attachment as Record<string, unknown>
|
||||
if (typeof a.attachmentId !== 'string' || a.attachmentId === '') return null
|
||||
const name = typeof a.name === 'string' && a.name !== '' ? a.name : undefined
|
||||
const bytes = numOf(a.bytes)
|
||||
if (spelling === 'file') {
|
||||
return {
|
||||
kind: 'file',
|
||||
attachmentId: a.attachmentId,
|
||||
...(name !== undefined ? { name } : {}),
|
||||
...(bytes !== undefined ? { bytes } : {}),
|
||||
}
|
||||
}
|
||||
const origDims = dimensionsOf(a.originalDimensions)
|
||||
const width = numOf(a.width)
|
||||
const height = numOf(a.height)
|
||||
const mediaType = typeof a.mediaType === 'string' && a.mediaType !== '' ? a.mediaType : undefined
|
||||
return {
|
||||
kind: 'image',
|
||||
attachmentId: a.attachmentId,
|
||||
...(name !== undefined ? { name } : {}),
|
||||
...(mediaType !== undefined ? { mediaType } : {}),
|
||||
...(bytes !== undefined ? { bytes } : {}),
|
||||
...(width !== undefined ? { width } : {}),
|
||||
...(height !== undefined ? { height } : {}),
|
||||
...(origDims !== undefined ? { originalDimensions: origDims } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/** Every attachment of one message content, in the message's own order; a non-array content or a
|
||||
* malformed block drops out instead of throwing. */
|
||||
export function attachmentsIn(content: unknown): AttachmentRef[] {
|
||||
if (!Array.isArray(content)) return []
|
||||
const out: AttachmentRef[] = []
|
||||
for (const block of content) {
|
||||
const ref = attachmentOf(block)
|
||||
if (ref !== null) out.push(ref)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
// The attachment plumbing (src/client/attachmentSource.ts): the location route read behind one
|
||||
// click, its cache, and the preview-column opener the File Activity card also rides.
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { afterEach, describe, test, vi } from 'vitest'
|
||||
import {
|
||||
clearAttachmentPaths,
|
||||
makeAttachmentLocator,
|
||||
makeAttachmentOpener,
|
||||
pathOfValue,
|
||||
} from '../../src/client/attachmentSource'
|
||||
import type { AttachmentRef } from '../../src/shared/attachment'
|
||||
import { asClientCtx, TestClientCtx } from './helpers/harness'
|
||||
|
||||
const FILE: AttachmentRef = { kind: 'file', attachmentId: 'sha256:f1', name: 'report.md', bytes: 47320 }
|
||||
const ALIAS = '/home/me/.dsh/attachments/v1/files/a3/a3f0/report.md'
|
||||
|
||||
interface Call {
|
||||
url: string
|
||||
body: unknown
|
||||
}
|
||||
|
||||
/** Stub the global fetch with per-call answers; returns the recorded calls. */
|
||||
function stubFetch(answer: (call: Call) => unknown): Call[] {
|
||||
const calls: Call[] = []
|
||||
vi.stubGlobal('fetch', async (url: string, init: { body?: string }) => {
|
||||
const call: Call = { url, body: init.body === undefined ? undefined : JSON.parse(init.body) }
|
||||
calls.push(call)
|
||||
return answer(call)
|
||||
})
|
||||
return calls
|
||||
}
|
||||
|
||||
function json(value: unknown, ok = true): { ok: boolean; json: () => Promise<unknown> } {
|
||||
return { ok, json: () => Promise.resolve(value) }
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
clearAttachmentPaths()
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('pathOfValue', () => {
|
||||
test('proves the envelope down to a non-empty string path', () => {
|
||||
assert.equal(pathOfValue({ path: ALIAS }), ALIAS)
|
||||
assert.equal(pathOfValue({ path: '' }), null)
|
||||
assert.equal(pathOfValue({ path: 42 }), null)
|
||||
assert.equal(pathOfValue({}), null)
|
||||
assert.equal(pathOfValue(null), null)
|
||||
assert.equal(pathOfValue('path'), null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('makeAttachmentLocator', () => {
|
||||
test('no session means no locator', () => {
|
||||
assert.equal(makeAttachmentLocator(undefined), undefined)
|
||||
assert.equal(makeAttachmentLocator(''), undefined)
|
||||
})
|
||||
|
||||
test('one read resolves the path and the next click reuses it', async () => {
|
||||
const calls = stubFetch(() => json({ ok: true, value: { path: ALIAS } }))
|
||||
const locate = makeAttachmentLocator('s1')!
|
||||
assert.equal(await locate(FILE), ALIAS)
|
||||
assert.equal(await locate(FILE), ALIAS)
|
||||
assert.equal(calls.length, 1, 'the second read is served from the cache')
|
||||
assert.equal(calls[0].url, '/api/dsh-context/attachment')
|
||||
assert.deepEqual(calls[0].body, { sessionId: 's1', attachmentId: 'sha256:f1' })
|
||||
})
|
||||
|
||||
test('every refusal resolves null, and none of them is cached', async () => {
|
||||
const answers: ((call: Call) => unknown)[] = [
|
||||
() => json({ ok: true, value: null }),
|
||||
() => json({ ok: false, error: { code: 'x' } }),
|
||||
() => json({ ok: true, value: { path: '' } }),
|
||||
() => ({ ok: false, json: () => Promise.resolve({}) }),
|
||||
() => {
|
||||
throw new Error('offline')
|
||||
},
|
||||
() => json('not-an-envelope'),
|
||||
]
|
||||
for (const answer of answers) {
|
||||
clearAttachmentPaths()
|
||||
const calls = stubFetch(answer)
|
||||
const locate = makeAttachmentLocator('s1')!
|
||||
assert.equal(await locate(FILE), null)
|
||||
assert.equal(await locate(FILE), null)
|
||||
assert.equal(calls.length, 2, 'a failed resolution is retried, never cached')
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
|
||||
test('a malformed JSON body resolves null', async () => {
|
||||
vi.stubGlobal('fetch', async () => ({ ok: true, json: () => Promise.reject(new Error('bad json')) }))
|
||||
const locate = makeAttachmentLocator('s1')!
|
||||
assert.equal(await locate(FILE), null)
|
||||
})
|
||||
|
||||
test('the cache is bounded: the oldest path is dropped first', async () => {
|
||||
const calls = stubFetch(call => json({ ok: true, value: { path: `${ALIAS}#${String((call.body as { attachmentId: string }).attachmentId)}` } }))
|
||||
const locate = makeAttachmentLocator('s1')!
|
||||
for (let i = 0; i < 33; i++) {
|
||||
await locate({ kind: 'file', attachmentId: `sha256:f${String(i)}`, name: 'x.md' })
|
||||
}
|
||||
assert.equal(calls.length, 33)
|
||||
// The first id fell out of the 32-entry cache, so it reads again; the newest stays cached.
|
||||
await locate({ kind: 'file', attachmentId: 'sha256:f0', name: 'x.md' })
|
||||
assert.equal(calls.length, 34)
|
||||
await locate({ kind: 'file', attachmentId: 'sha256:f32', name: 'x.md' })
|
||||
assert.equal(calls.length, 34)
|
||||
})
|
||||
})
|
||||
|
||||
describe('makeAttachmentOpener', () => {
|
||||
test('without a session or a preview column there is no opener', async () => {
|
||||
stubFetch(() => json({ ok: true, value: { path: ALIAS } }))
|
||||
const bare = new TestClientCtx()
|
||||
assert.equal(makeAttachmentOpener(asClientCtx(bare), undefined, undefined), undefined)
|
||||
assert.equal(makeAttachmentOpener(asClientCtx(bare), '', undefined), undefined)
|
||||
assert.equal(makeAttachmentOpener(asClientCtx(bare), 's1', undefined), undefined)
|
||||
})
|
||||
|
||||
test('a resolved path opens the harness preview column at the session file address', async () => {
|
||||
stubFetch(() => json({ ok: true, value: { path: ALIAS } }))
|
||||
const opened: string[] = []
|
||||
const ctx = new TestClientCtx({ services: { sidebarRight: { openResource: (address: string) => { opened.push(address) } } } })
|
||||
const open = makeAttachmentOpener(asClientCtx(ctx), 's1', '/work/repo')!
|
||||
assert.equal(await open(FILE), true)
|
||||
assert.equal(opened.length, 1)
|
||||
assert.ok(opened[0].startsWith('dsh-resource://file/session/s1/'), opened[0])
|
||||
assert.ok(opened[0].endsWith('report.md'), opened[0])
|
||||
assert.ok(opened[0].includes('/home/me/.dsh/attachments/v1/files/a3/a3f0/'), 'an absolute path outside the workspace stays absolute')
|
||||
})
|
||||
|
||||
test('an image-formed file keeps its stored name in the address', async () => {
|
||||
stubFetch(() => json({ ok: true, value: { path: '/store/a3/shot.PNG' } }))
|
||||
const opened: string[] = []
|
||||
const ctx = new TestClientCtx({ services: { sidebarRight: { openResource: (address: string) => { opened.push(address) } } } })
|
||||
const open = makeAttachmentOpener(asClientCtx(ctx), 's1', undefined)!
|
||||
assert.equal(await open({ kind: 'file', attachmentId: 'sha256:f9', name: 'shot.PNG' }), true)
|
||||
assert.ok(opened[0].endsWith('shot.PNG'))
|
||||
})
|
||||
|
||||
test('an unresolved path and a refused address both report false', async () => {
|
||||
stubFetch(() => json({ ok: true, value: null }))
|
||||
const refused: string[] = []
|
||||
const ctx = new TestClientCtx({ services: { sidebarRight: { openResource: (address: string) => { refused.push(address) } } } })
|
||||
const open = makeAttachmentOpener(asClientCtx(ctx), 's1', undefined)!
|
||||
assert.equal(await open(FILE), false)
|
||||
assert.deepEqual(refused, [], 'nothing to open, the column is never called')
|
||||
|
||||
clearAttachmentPaths()
|
||||
vi.unstubAllGlobals()
|
||||
stubFetch(() => json({ ok: true, value: { path: ALIAS } }))
|
||||
const throwing = new TestClientCtx({
|
||||
services: {
|
||||
sidebarRight: {
|
||||
openResource: () => {
|
||||
throw new Error('no viewer claims it')
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
const openThrowing = makeAttachmentOpener(asClientCtx(throwing), 's1', undefined)!
|
||||
assert.equal(await openThrowing(FILE), false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,207 @@
|
||||
// The attachment block (src/client/components/attachments.tsx): the ordered cards and the one-click
|
||||
// hand-off to the harness preview column, with a refusal reported on the card itself.
|
||||
|
||||
import { createElement as h } from 'react'
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, test } from 'vitest'
|
||||
import { makeAttachmentSection } from '../../../src/client/components/attachments'
|
||||
import type { AttachmentOpener, ImageLoader } from '../../../src/client/services'
|
||||
import type { AttachmentRef } from '../../../src/shared/attachment'
|
||||
import { click, flush, makeKit, mount, query, queryAll, text } from '../helpers/kit'
|
||||
|
||||
const kit = makeKit()
|
||||
const Attachments = makeAttachmentSection(kit)
|
||||
|
||||
const CONTENT: AttachmentRef[] = [
|
||||
{ kind: 'file', attachmentId: 'sha256:f1', name: 'dsh-context-标题.svg', bytes: 2627 },
|
||||
{ kind: 'file', attachmentId: 'sha256:f2', name: '调研报告.docx', bytes: 778285 },
|
||||
{ kind: 'file', attachmentId: 'sha256:f3', name: 'REPORT.md', bytes: 47320 },
|
||||
{ kind: 'image', attachmentId: 'sha256:i1', name: 'result-image-1.jpg', mediaType: 'image/jpeg', bytes: 146227, width: 1568, height: 1350, originalDimensions: { width: 3462, height: 1211 } },
|
||||
]
|
||||
|
||||
const loadImage: ImageLoader = async (att) => {
|
||||
if (att.attachmentId === 'sha256:i1') return 'blob:image-1'
|
||||
throw new Error('denied')
|
||||
}
|
||||
|
||||
async function mountSection(over: {
|
||||
attachments?: readonly AttachmentRef[]
|
||||
load?: ImageLoader
|
||||
open?: AttachmentOpener
|
||||
} = {}): Promise<Awaited<ReturnType<typeof mount>>> {
|
||||
return await mount(h(Attachments, {
|
||||
attachments: over.attachments ?? CONTENT,
|
||||
...(over.load !== undefined ? { load: over.load } : {}),
|
||||
...(over.open !== undefined ? { open: over.open } : {}),
|
||||
}))
|
||||
}
|
||||
|
||||
describe('the attachment block', () => {
|
||||
test('renders every attachment in message order, one card each', async () => {
|
||||
const m = await mountSection({ load: loadImage })
|
||||
await flush()
|
||||
const cards = queryAll(m.container, '.lc-att-item')
|
||||
assert.equal(cards.length, 4)
|
||||
assert.deepEqual(
|
||||
cards.map(c => text(query(c, '.lc-att-name'))),
|
||||
['dsh-context-标题.svg', '调研报告.docx', 'REPORT.md', 'result-image-1.jpg'],
|
||||
)
|
||||
assert.deepEqual(text(query(m.container, '.lc-ts-card-head')), 'Attachments4')
|
||||
// Files show their extension badge; the image shows its resolved thumbnail.
|
||||
assert.deepEqual(queryAll(m.container, '.lc-att-ext').map(e => text(e)), ['SVG', 'DOCX', 'MD'])
|
||||
assert.equal(queryAll<HTMLImageElement>(m.container, '.lc-att-thumb img').length, 1)
|
||||
assert.ok(query<HTMLImageElement>(m.container, '.lc-att-thumb img').src.includes('blob:image-1'))
|
||||
// The image card carries the chat's own facts: raw dims, sent size, token estimate.
|
||||
const imageCard = cards[3]
|
||||
assert.ok(text(imageCard).includes('3462×1211'), 'raw dimensions')
|
||||
assert.ok(text(imageCard).includes('1568×1350 · 146.2 kB'), 'sent dimensions and size')
|
||||
assert.ok(text(imageCard).includes('Token') && text(imageCard).includes('≈325'))
|
||||
// File cards carry type and size instead.
|
||||
assert.ok(text(cards[0]).includes('Type') && text(cards[0]).includes('Size'))
|
||||
assert.ok(text(cards[0]).includes('2.6 kB'))
|
||||
// Every card invites the same one click.
|
||||
assert.ok(queryAll(m.container, '.lc-att-hint').every(hint => text(hint).includes('Click to preview')))
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
test('a rejected or absent image loader degrades the thumbnail, never the block', async () => {
|
||||
const m = await mountSection({ load: loadImage, attachments: [{ kind: 'image', attachmentId: 'sha256:x', name: 'x.png' }] })
|
||||
await flush()
|
||||
assert.ok(text(query(m.container, '.lc-att-err')).includes('⚠'))
|
||||
await m.unmount()
|
||||
|
||||
const bare = await mountSection({ attachments: [{ kind: 'image', attachmentId: 'sha256:x' }] })
|
||||
await flush()
|
||||
assert.equal(text(query(bare.container, '.lc-att-ph')), '🖼')
|
||||
assert.ok(text(query(bare.container, '.lc-att-name')).includes('Image'))
|
||||
await bare.unmount()
|
||||
})
|
||||
|
||||
test('an unnamed file falls back to its kind for type, and shows no size row', async () => {
|
||||
const m = await mountSection({ attachments: [{ kind: 'file', attachmentId: 'sha256:u' }] })
|
||||
const card = query(m.container, '.lc-att-item')
|
||||
assert.ok(text(query(card, '.lc-att-name')).includes('File'))
|
||||
assert.equal(text(query(card, '.lc-att-ext')), 'File', 'the kind stands in for a missing extension')
|
||||
assert.ok(!text(card).includes('Size'))
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
test('an image without sent bytes still shows its dimensions', async () => {
|
||||
const m = await mountSection({ attachments: [{ kind: 'image', attachmentId: 'sha256:i2', name: 'x.png', width: 10, height: 20 }] })
|
||||
assert.ok(text(query(m.container, '.lc-att-item')).includes('10×20'))
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
test('an empty block renders nothing at all', async () => {
|
||||
const m = await mountSection({ attachments: [] })
|
||||
assert.equal(m.container.textContent, '')
|
||||
await m.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('the card click', () => {
|
||||
test('hands every kind to the preview column at once, with no intermediate surface', async () => {
|
||||
const opened: string[] = []
|
||||
const open: AttachmentOpener = async (att) => {
|
||||
opened.push(att.attachmentId)
|
||||
return true
|
||||
}
|
||||
const m = await mountSection({ load: loadImage, open })
|
||||
await flush()
|
||||
for (const card of queryAll(m.container, '.lc-att-item')) await click(card)
|
||||
await flush()
|
||||
assert.deepEqual(opened, ['sha256:f1', 'sha256:f2', 'sha256:f3', 'sha256:i1'])
|
||||
assert.equal(queryAll(m.container, '.lc-att-fail').length, 0)
|
||||
assert.equal(queryAll(m.container, '[role=dialog]').length, 0)
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
test('an open in flight shows on its own card, and a refusal leaves the note there', async () => {
|
||||
let settle: ((taken: boolean) => void) | undefined
|
||||
const open: AttachmentOpener = () => new Promise((resolve) => { settle = resolve })
|
||||
const m = await mountSection({ open })
|
||||
await click(queryAll(m.container, '.lc-att-item')[2])
|
||||
const opening = queryAll(m.container, '.lc-att-item')[2]
|
||||
assert.equal(opening.dataset.state, 'opening')
|
||||
assert.ok(text(query(opening, '.lc-att-hint')).includes('…'))
|
||||
settle?.(false)
|
||||
await flush()
|
||||
assert.equal(queryAll(m.container, '.lc-att-item')[2].dataset.state, 'failed')
|
||||
assert.ok(text(query(queryAll(m.container, '.lc-att-item')[2], '.lc-att-fail')).includes('Preview unavailable'))
|
||||
// A later success clears the note.
|
||||
await click(queryAll(m.container, '.lc-att-item')[2])
|
||||
settle?.(true)
|
||||
await flush()
|
||||
assert.equal(queryAll(m.container, '.lc-att-item')[2].dataset.state, 'idle')
|
||||
assert.equal(queryAll(m.container, '.lc-att-fail').length, 0)
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
test('without an opener every click reports on the card it hit', async () => {
|
||||
const m = await mountSection({})
|
||||
const cards = queryAll(m.container, '.lc-att-item')
|
||||
await click(cards[0])
|
||||
assert.equal(text(query(cards[0], '.lc-att-fail')), 'Preview unavailable')
|
||||
// The state follows the newest refusal: one report at a time.
|
||||
await click(cards[3])
|
||||
assert.equal(queryAll(m.container, '.lc-att-fail').length, 1)
|
||||
assert.equal(queryAll(m.container, '.lc-att-item')[3].dataset.state, 'failed')
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
test('a content swap drops stale card states, even for an open still in flight', async () => {
|
||||
let settle: ((taken: boolean) => void) | undefined
|
||||
const m = await mountSection({ open: () => new Promise((resolve) => { settle = resolve }) })
|
||||
await click(queryAll(m.container, '.lc-att-item')[0])
|
||||
settle?.(false)
|
||||
await flush()
|
||||
assert.equal(queryAll(m.container, '.lc-att-fail').length, 1)
|
||||
await m.update(h(Attachments, { attachments: [{ kind: 'file', attachmentId: 'sha256:new', name: 'new.md', bytes: 1 }] }))
|
||||
assert.equal(queryAll(m.container, '.lc-att-fail').length, 0)
|
||||
// An open that lands AFTER the swap must not resurrect the state it belonged to.
|
||||
await click(queryAll(m.container, '.lc-att-item')[0])
|
||||
await m.update(h(Attachments, { attachments: [{ kind: 'file', attachmentId: 'sha256:other', name: 'other.md', bytes: 2 }] }))
|
||||
settle?.(false)
|
||||
await flush()
|
||||
assert.equal(queryAll(m.container, '.lc-att-fail').length, 0)
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
test('a load that settles after unmount never writes state', async () => {
|
||||
let settle: ((url: string) => void) | undefined
|
||||
let reject: ((err: Error) => void) | undefined
|
||||
const lateLoad: ImageLoader = () => new Promise((_resolve, rejectPromise) => {
|
||||
settle = _resolve
|
||||
reject = rejectPromise
|
||||
})
|
||||
const m = await mountSection({ load: lateLoad, attachments: [{ kind: 'image', attachmentId: 'sha256:i9', name: 'late.png' }] })
|
||||
await m.unmount()
|
||||
settle?.('blob:late')
|
||||
await flush()
|
||||
|
||||
// The rejection arm too: both settle paths must stay silent after unmount.
|
||||
const failing = await mountSection({ load: lateLoad, attachments: [{ kind: 'image', attachmentId: 'sha256:i8', name: 'late-2.png' }] })
|
||||
await failing.unmount()
|
||||
reject?.(new Error('denied'))
|
||||
await flush()
|
||||
})
|
||||
|
||||
test('two opens in flight keep their own cards marked, in landing order', async () => {
|
||||
const settles = new Map<string, (taken: boolean) => void>()
|
||||
const open: AttachmentOpener = (att) => new Promise((resolve) => { settles.set(att.attachmentId, resolve) })
|
||||
const m = await mountSection({ open })
|
||||
const cards = queryAll(m.container, '.lc-att-item')
|
||||
await click(cards[0])
|
||||
await click(cards[1])
|
||||
assert.equal(queryAll(m.container, '.lc-att-item')[1].dataset.state, 'opening')
|
||||
// The FIRST open lands while the second is still pending: its clear must not touch the newer one.
|
||||
settles.get('sha256:f1')?.(true)
|
||||
await flush()
|
||||
assert.equal(queryAll(m.container, '.lc-att-item')[0].dataset.state, 'idle')
|
||||
assert.equal(queryAll(m.container, '.lc-att-item')[1].dataset.state, 'opening')
|
||||
settles.get('sha256:f2')?.(true)
|
||||
await flush()
|
||||
assert.equal(queryAll(m.container, '.lc-att-item')[1].dataset.state, 'idle')
|
||||
await m.unmount()
|
||||
})
|
||||
})
|
||||
@@ -98,18 +98,18 @@ describe('ContextBrowser message categories', () => {
|
||||
const rows = elemRows(m)
|
||||
assert.equal(rows.length, 8, 'user + compaction nodes, newest first')
|
||||
assert.equal(text(query(rows[0], '.lc-br-preview')), 'empty summary')
|
||||
// Image chips ride the collapsed row (seq 1 ×2, seq 2 ×1).
|
||||
// Attachment chips ride the collapsed row (seq 1 ×2 images, seq 2 ×1).
|
||||
const rowOf = (preview: string) => rows.find(r => text(r).includes(preview)) as HTMLElement
|
||||
assert.ok(text(rowOf('with images')).includes('Image ×2'))
|
||||
assert.ok(text(rowOf('one pic')).includes('Image'))
|
||||
assert.ok(text(rowOf('with images')).includes('Attachments ×2'))
|
||||
assert.ok(text(rowOf('one pic')).includes('Attachments'))
|
||||
assert.ok(!text(rowOf('one pic')).includes('×'))
|
||||
// Open seq 1: the chip makes way for the grid; loader resolves two cards.
|
||||
await click(rowOf('with images'))
|
||||
await flush()
|
||||
let content = query(m.container, '.lc-br-content')
|
||||
assert.ok(!text(elemRows(m).find(r => text(r).includes('with images')) as HTMLElement).includes('Image ×2'),
|
||||
assert.ok(!text(elemRows(m).find(r => text(r).includes('with images')) as HTMLElement).includes('Attachments ×2'),
|
||||
'expanded row drops the chip')
|
||||
assert.ok(text(content).includes('Images'))
|
||||
assert.ok(text(content).includes('Attachments'))
|
||||
const imgs = queryAll<HTMLImageElement>(content, '.lc-att-thumb img')
|
||||
assert.equal(imgs.length, 2, 'both loads resolved')
|
||||
assert.ok(imgs[0].src.includes('blob:a1'))
|
||||
@@ -331,7 +331,7 @@ describe('ContextBrowser message categories', () => {
|
||||
assert.ok(heads.some(s => s.includes('→ noargs')))
|
||||
assert.ok(heads.some(s => s.includes('Result')), 'nested tool-result text section')
|
||||
assert.ok(heads.filter(s => s.includes('Other content')).length === 6, 'unknown blocks render raw JSON')
|
||||
assert.ok(heads.some(s => s.includes('Images')))
|
||||
assert.ok(heads.some(s => s.includes('Attachments')))
|
||||
const argVals = queryAll(content, '.lc-ts-arg-row').map(el => text(el))
|
||||
assert.ok(argVals.some(s => s.includes('command') && s.includes('ls')))
|
||||
assert.ok(argVals.some(s => s.includes('timeout') && s.includes('30')))
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
// ImagesCard (src/client/components/images.tsx): the preview it renders is the platform ImageLightbox, whose dialog
|
||||
// behavior is covered upstream, so only the plugin-owned open/close wiring is pinned here.
|
||||
|
||||
import { createElement as h } from 'react'
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, test, vi } from 'vitest'
|
||||
import { imageRefOf, makeImageCard } from '../../../src/client/components/images'
|
||||
import type { ImageRefLike } from '../../../src/client/services'
|
||||
import { estimateImageTokens } from '../../../src/shared/imageTokens'
|
||||
import { click, flush, keydown, makeKit, mount, query, queryAll } from '../helpers/kit'
|
||||
|
||||
const kit = makeKit()
|
||||
const ImageCard = makeImageCard(kit)
|
||||
|
||||
const dialog = (): Element | null => document.body.querySelector('[role=dialog]')
|
||||
|
||||
describe('imageRefOf', () => {
|
||||
test('rejects non-objects and non-image blocks', () => {
|
||||
assert.equal(imageRefOf(null), null)
|
||||
assert.equal(imageRefOf('image'), null)
|
||||
assert.equal(imageRefOf(42), null)
|
||||
assert.equal(imageRefOf({}), null)
|
||||
assert.equal(imageRefOf({ type: 'text', attachment: { attachmentId: 'a' } }), null)
|
||||
})
|
||||
|
||||
test('accepts both type:image and kind:image shapes', () => {
|
||||
const attachment = { attachmentId: 'a1' }
|
||||
assert.deepEqual(imageRefOf({ type: 'image', attachment }), { attachmentId: 'a1' })
|
||||
assert.deepEqual(imageRefOf({ kind: 'image', attachment }), { attachmentId: 'a1' })
|
||||
})
|
||||
|
||||
test('rejects a missing or malformed attachment', () => {
|
||||
assert.equal(imageRefOf({ type: 'image' }), null)
|
||||
assert.equal(imageRefOf({ type: 'image', attachment: null }), null)
|
||||
assert.equal(imageRefOf({ type: 'image', attachment: 'x' }), null)
|
||||
assert.equal(imageRefOf({ type: 'image', attachment: {} }), null)
|
||||
assert.equal(imageRefOf({ type: 'image', attachment: { attachmentId: '' } }), null)
|
||||
assert.equal(imageRefOf({ type: 'image', attachment: { attachmentId: 42 } }), null)
|
||||
})
|
||||
|
||||
test('keeps only well-formed optional facts', () => {
|
||||
assert.deepEqual(
|
||||
imageRefOf({
|
||||
type: 'image',
|
||||
attachment: {
|
||||
attachmentId: 'a1',
|
||||
name: '',
|
||||
bytes: '500',
|
||||
width: NaN,
|
||||
height: 0,
|
||||
originalDimensions: 'big',
|
||||
},
|
||||
}),
|
||||
{ attachmentId: 'a1' },
|
||||
)
|
||||
assert.deepEqual(
|
||||
imageRefOf({ type: 'image', attachment: { attachmentId: 'a1', bytes: -5, originalDimensions: null } }),
|
||||
{ attachmentId: 'a1' },
|
||||
)
|
||||
assert.deepEqual(
|
||||
imageRefOf({ type: 'image', attachment: { attachmentId: 'a1', originalDimensions: { width: 100 } } }),
|
||||
{ attachmentId: 'a1' },
|
||||
)
|
||||
assert.deepEqual(
|
||||
imageRefOf({ type: 'image', attachment: { attachmentId: 'a1', originalDimensions: { width: 0, height: 10 } } }),
|
||||
{ attachmentId: 'a1' },
|
||||
)
|
||||
})
|
||||
|
||||
test('passes a fully populated attachment through', () => {
|
||||
assert.deepEqual(
|
||||
imageRefOf({
|
||||
kind: 'image',
|
||||
attachment: {
|
||||
attachmentId: 'a1',
|
||||
name: 'photo.png',
|
||||
bytes: 500,
|
||||
width: 100,
|
||||
height: 100,
|
||||
originalDimensions: { width: 4000, height: 3000 },
|
||||
},
|
||||
}),
|
||||
{
|
||||
attachmentId: 'a1',
|
||||
name: 'photo.png',
|
||||
bytes: 500,
|
||||
width: 100,
|
||||
height: 100,
|
||||
originalDimensions: { width: 4000, height: 3000 },
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
const FULL: ImageRefLike = {
|
||||
attachmentId: 'a1',
|
||||
name: 'photo.png',
|
||||
bytes: 500,
|
||||
width: 100,
|
||||
height: 100,
|
||||
originalDimensions: { width: 4000, height: 3000 },
|
||||
}
|
||||
|
||||
describe('ImageCard metadata', () => {
|
||||
test('without a loader: placeholder tile, metadata rows, click is a no-op', async () => {
|
||||
const m = await mount(h(ImageCard, { attachment: FULL }))
|
||||
const card = query(m.container, '.lc-att-item')
|
||||
assert.equal(card.getAttribute('title'), 'Open full image')
|
||||
assert.equal(query(m.container, '.lc-att-ph').textContent, '🖼')
|
||||
assert.equal(query(m.container, '.lc-att-name').textContent, 'photo.png')
|
||||
const rows = queryAll(m.container, '.lc-att-row')
|
||||
assert.equal(rows.length, 3)
|
||||
assert.equal(rows[0].textContent, 'Raw4000×3000')
|
||||
assert.equal(rows[1].textContent, 'Sent100×100 · 500 B')
|
||||
assert.equal(rows[2].textContent, `Token≈${estimateImageTokens(100, 100)}`)
|
||||
assert.ok(rows[2].getAttribute('title')!.includes('384'))
|
||||
assert.equal(rows[0].getAttribute('title'), null)
|
||||
await click(card)
|
||||
assert.equal(dialog(), null)
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
test('without a name the generic label applies; without dimensions no rows render', async () => {
|
||||
const m = await mount(h(ImageCard, { attachment: { attachmentId: 'a2' } }))
|
||||
const name = query(m.container, '.lc-att-name')
|
||||
assert.equal(name.textContent, 'Image')
|
||||
assert.equal(name.getAttribute('title'), 'Image')
|
||||
assert.equal(queryAll(m.container, '.lc-att-row').length, 0)
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
test('sent row without a byte size shows dimensions only', async () => {
|
||||
const m = await mount(h(ImageCard, { attachment: { attachmentId: 'a3', name: 'n.png', width: 200, height: 50 } }))
|
||||
const rows = queryAll(m.container, '.lc-att-row')
|
||||
assert.equal(rows.length, 2)
|
||||
assert.equal(rows[0].textContent, 'Sent200×50')
|
||||
assert.equal(rows[1].textContent, `Token≈${estimateImageTokens(200, 50)}`)
|
||||
await m.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ImageCard loading', () => {
|
||||
test('a resolving loader swaps the placeholder for the image', async () => {
|
||||
const load = vi.fn((_att: ImageRefLike) => Promise.resolve('blob:x'))
|
||||
const m = await mount(h(ImageCard, { attachment: FULL, load }))
|
||||
await flush()
|
||||
const img = query<HTMLImageElement>(m.container, '.lc-att-thumb img')
|
||||
assert.equal(img.getAttribute('src'), 'blob:x')
|
||||
assert.equal(img.getAttribute('alt'), 'photo.png')
|
||||
assert.equal(load.mock.calls.length, 1)
|
||||
assert.deepEqual(load.mock.calls[0][0], FULL)
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
test('a pending loader shows the loading glyph', async () => {
|
||||
const load = () => new Promise<string>(() => {})
|
||||
const m = await mount(h(ImageCard, { attachment: FULL, load }))
|
||||
assert.equal(query(m.container, '.lc-att-ph').textContent, '…')
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
test('a rejecting loader shows the error tile; clicking retries the load', async () => {
|
||||
const load = vi.fn()
|
||||
.mockRejectedValueOnce(new Error('boom'))
|
||||
.mockResolvedValue('blob:y')
|
||||
const m = await mount(h(ImageCard, { attachment: FULL, load }))
|
||||
await flush()
|
||||
assert.equal(query(m.container, '.lc-att-err').textContent, '⚠')
|
||||
assert.equal(query(m.container, '.lc-att-item').getAttribute('title'), 'Load failed · click to retry')
|
||||
await click(query(m.container, '.lc-att-item'))
|
||||
await flush()
|
||||
assert.equal(load.mock.calls.length, 2)
|
||||
const img = query<HTMLImageElement>(m.container, '.lc-att-thumb img')
|
||||
assert.equal(img.getAttribute('src'), 'blob:y')
|
||||
assert.equal(query(m.container, '.lc-att-item').getAttribute('title'), 'Open full image')
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
test('a late answer after unmount is dropped (resolve and reject alike)', async () => {
|
||||
let resolveLoad: ((url: string) => void) | null = null
|
||||
const m = await mount(h(ImageCard, { attachment: FULL, load: () => new Promise<string>((r) => { resolveLoad = r }) }))
|
||||
await m.unmount()
|
||||
resolveLoad!('blob:z')
|
||||
await flush()
|
||||
|
||||
let rejectLoad: ((err: unknown) => void) | null = null
|
||||
const m2 = await mount(h(ImageCard, {
|
||||
attachment: FULL,
|
||||
load: () => new Promise<string>((_, rej) => { rejectLoad = rej }),
|
||||
}))
|
||||
await m2.unmount()
|
||||
rejectLoad!(new Error('late'))
|
||||
await flush()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ImageLightbox wiring', () => {
|
||||
async function mountOpenable() {
|
||||
const load = () => Promise.resolve('blob:x')
|
||||
const m = await mount(h(ImageCard, { attachment: FULL, load }))
|
||||
await flush()
|
||||
return m
|
||||
}
|
||||
|
||||
test('click opens the body-portaled dialog; Escape closes it', async () => {
|
||||
const m = await mountOpenable()
|
||||
await click(query(m.container, '.lc-att-item'))
|
||||
const box = dialog()!
|
||||
assert.equal(box.getAttribute('aria-label'), 'Image preview')
|
||||
assert.equal(query<HTMLImageElement>(box, 'img').getAttribute('src'), 'blob:x')
|
||||
assert.equal(query(box, 'button').getAttribute('aria-label'), 'Close')
|
||||
await keydown('Escape')
|
||||
assert.equal(dialog(), null)
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
test('the close control closes the lightbox', async () => {
|
||||
const m = await mountOpenable()
|
||||
await click(query(m.container, '.lc-att-item'))
|
||||
await click(query(dialog()!, 'button'))
|
||||
assert.equal(dialog(), null)
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
test('switching the attachment while open closes the preview', async () => {
|
||||
const load = () => Promise.resolve('blob:x')
|
||||
const m = await mount(h(ImageCard, { attachment: FULL, load }))
|
||||
await flush()
|
||||
await click(query(m.container, '.lc-att-item'))
|
||||
assert.ok(dialog() !== null)
|
||||
await m.update(h(ImageCard, { attachment: { ...FULL, attachmentId: 'a2' }, load: () => new Promise<string>(() => {}) }))
|
||||
assert.equal(dialog(), null)
|
||||
await m.unmount()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
// The attachment-location route (src/host/attachment.ts): session-authorized named-path resolution
|
||||
// for verbatim file attachments, plus the route's gating/transport behavior.
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, test } from 'vitest'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach } from 'vitest'
|
||||
import { ATTACHMENT_ROUTE, referencedAttachmentOf, imagePreviewNameOf, watchAttachmentChannel } from '../../src/host/attachment'
|
||||
import type { ColdReadGate } from '../../src/host/coldRead'
|
||||
|
||||
type RouteFetch = (request: Request) => Promise<Response>
|
||||
|
||||
interface CtxSpec {
|
||||
connection?: unknown
|
||||
sessionQuery?: unknown
|
||||
attachments?: unknown
|
||||
}
|
||||
|
||||
/** The fleet route spec's host ctx double: cordis inject semantics over a service map. */
|
||||
function ctxOf(spec: CtxSpec): { ctx: Context; captured: { path?: string; fetch?: RouteFetch }; disposers: (() => void)[] } {
|
||||
const captured: { path?: string; fetch?: RouteFetch } = {}
|
||||
const disposers: (() => void)[] = []
|
||||
const services = new Map<string, unknown>()
|
||||
if ('connection' in spec) services.set('connection', spec.connection)
|
||||
if ('sessionQuery' in spec) services.set('sessionQuery', spec.sessionQuery)
|
||||
if ('attachments' in spec) services.set('attachments', spec.attachments)
|
||||
const ctx = {
|
||||
get: (name: string) => services.get(name),
|
||||
effect(fn: () => unknown, _label?: string) {
|
||||
const d = fn()
|
||||
if (typeof d === 'function') disposers.push(d as () => void)
|
||||
return () => {}
|
||||
},
|
||||
inject(deps: string[], cb: (c: unknown) => unknown) {
|
||||
if (!deps.every(d => services.has(d))) return
|
||||
const d = cb(ctx)
|
||||
if (typeof d === 'function') disposers.push(d as () => void)
|
||||
},
|
||||
}
|
||||
if (spec.connection !== undefined && spec.connection !== null) {
|
||||
const conn = spec.connection as { fetch?: { register?: unknown } }
|
||||
if (typeof conn.fetch?.register === 'function') {
|
||||
conn.fetch.register = (route: { path: string; fetch: RouteFetch }) => {
|
||||
captured.path = route.path
|
||||
captured.fetch = route.fetch
|
||||
return () => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ctx: ctx as unknown as Context, captured, disposers }
|
||||
}
|
||||
|
||||
const OPEN_GATE: ColdReadGate = { admit: read => read() }
|
||||
const SKIP_GATE: ColdReadGate = { admit: () => Promise.resolve(undefined) }
|
||||
|
||||
function armed(spec: CtxSpec = {}, gate: ColdReadGate = OPEN_GATE): { captured: { path?: string; fetch?: RouteFetch } } {
|
||||
const { ctx, captured } = ctxOf({ connection: { fetch: { register: () => () => {} } }, ...spec })
|
||||
watchAttachmentChannel(ctx, gate)
|
||||
return { captured }
|
||||
}
|
||||
|
||||
async function call(captured: { fetch?: RouteFetch }, body: unknown): Promise<Record<string, unknown>> {
|
||||
const request = new Request(`http://dsh.test${ATTACHMENT_ROUTE}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
})
|
||||
const response = await captured.fetch!(request)
|
||||
return await response.json() as Record<string, unknown>
|
||||
}
|
||||
|
||||
const BARE_IMAGE_EVENT = {
|
||||
type: 'user/message',
|
||||
seq: 10,
|
||||
time: 2,
|
||||
data: { content: [{ type: 'image', attachment: { attachmentId: 'sha256:i2' } }] },
|
||||
}
|
||||
|
||||
/** The screenshot session's own content: three verbatim files, then an image, then the text. */
|
||||
const USER_EVENT = {
|
||||
type: 'user/message',
|
||||
seq: 9,
|
||||
time: 1,
|
||||
data: {
|
||||
content: [
|
||||
{ type: 'file', attachment: { attachmentId: 'sha256:f1', name: 'report.md', bytes: 47320 } },
|
||||
{ type: 'file', attachment: { attachmentId: 'sha256:f2', name: 'report.docx', bytes: 778285 } },
|
||||
{ type: 'image', attachment: { attachmentId: 'sha256:i1', name: 'result-image.jpg', mediaType: 'image/jpeg', bytes: 146227, width: 1568, height: 1350 } },
|
||||
{ type: 'text', text: 'how many files?' },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
function basename(path: string): string {
|
||||
return path.slice(path.lastIndexOf('/') + 1)
|
||||
}
|
||||
|
||||
function observing(events: unknown): { observeSession(id: string, options: unknown): Promise<unknown>; disposed(): boolean } {
|
||||
let disposed = false
|
||||
return {
|
||||
observeSession: () => Promise.resolve({
|
||||
events,
|
||||
[Symbol.dispose]() { disposed = true },
|
||||
}),
|
||||
disposed: () => disposed,
|
||||
}
|
||||
}
|
||||
|
||||
const ALIAS = '/home/me/.dsh/attachments/v1/files/a3/a3f0/report.md'
|
||||
|
||||
describe('referencedAttachmentOf', () => {
|
||||
test('finds the block citing the id at every real citation site', () => {
|
||||
assert.deepEqual(
|
||||
referencedAttachmentOf([USER_EVENT], 'sha256:f1'),
|
||||
{ kind: 'file', attachmentId: 'sha256:f1', name: 'report.md', bytes: 47320 },
|
||||
)
|
||||
// A tool result's own content, and a nested block inside it.
|
||||
assert.deepEqual(
|
||||
referencedAttachmentOf([
|
||||
{ type: 'tool/result', seq: 2, time: 2, data: { message: { content: [{ type: 'image', attachment: { attachmentId: 'sha256:i9', width: 1, height: 1 } }] } } },
|
||||
], 'sha256:i9'),
|
||||
{ kind: 'image', attachmentId: 'sha256:i9', width: 1, height: 1 },
|
||||
)
|
||||
assert.deepEqual(
|
||||
referencedAttachmentOf([
|
||||
{
|
||||
type: 'tool/result',
|
||||
seq: 3,
|
||||
time: 3,
|
||||
data: { message: { content: [{ type: 'tool-result', content: [{ type: 'text', text: 'shot' }, { type: 'image', attachment: { attachmentId: 'sha256:i10', width: 2, height: 2 } }] }] } },
|
||||
},
|
||||
], 'sha256:i10'),
|
||||
{ kind: 'image', attachmentId: 'sha256:i10', width: 2, height: 2 },
|
||||
)
|
||||
// A spliced inbox delivery, before its user/message materializes.
|
||||
assert.deepEqual(
|
||||
referencedAttachmentOf([
|
||||
{ type: 'agent/inbox/spliced', seq: 4, time: 4, data: { target: 'next-turn', inserted: [{ content: [{ type: 'file', attachment: { attachmentId: 'sha256:f9', name: 'delivered.md', bytes: 7 } }] }] } },
|
||||
], 'sha256:f9'),
|
||||
{ kind: 'file', attachmentId: 'sha256:f9', name: 'delivered.md', bytes: 7 },
|
||||
)
|
||||
})
|
||||
|
||||
test('the walk is bounded and total over a hostile log', () => {
|
||||
// Deeper than the cap: the block is out of reach, so nothing resolves.
|
||||
let deep: Record<string, unknown> = { type: 'file', attachment: { attachmentId: 'sha256:deep', name: 'x.md', bytes: 1 } }
|
||||
for (let i = 0; i < 10; i++) deep = { nest: deep }
|
||||
assert.equal(referencedAttachmentOf([{ type: 'user/message', data: deep }], 'sha256:deep'), null)
|
||||
// An object that throws on the walk drops that event, and the next one still serves.
|
||||
const hostile = new Proxy({}, { ownKeys() { throw new Error('hostile payload') } })
|
||||
assert.equal(referencedAttachmentOf([{ type: 'x', data: hostile }], 'sha256:f1'), null)
|
||||
assert.deepEqual(
|
||||
referencedAttachmentOf([{ type: 'x', data: hostile }, USER_EVENT], 'sha256:f1'),
|
||||
{ kind: 'file', attachmentId: 'sha256:f1', name: 'report.md', bytes: 47320 },
|
||||
)
|
||||
})
|
||||
|
||||
test('an id the log never cites resolves null, over hostile entries', () => {
|
||||
assert.equal(referencedAttachmentOf([USER_EVENT], 'sha256:other'), null)
|
||||
assert.equal(referencedAttachmentOf([], 'sha256:f1'), null)
|
||||
assert.equal(
|
||||
referencedAttachmentOf([
|
||||
null,
|
||||
42,
|
||||
{ type: 'x' },
|
||||
{ type: 'x', data: null },
|
||||
{ type: 'x', data: { content: 'not-an-array' } },
|
||||
{ type: 'x', data: { content: [null, 'plain', { type: 'file' }] } },
|
||||
{ type: 'x', data: { message: null } },
|
||||
], 'sha256:f1'),
|
||||
null,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('watchAttachmentChannel gating', () => {
|
||||
test('no route without the connection service or its fetch registry', () => {
|
||||
assert.equal(ctxOf({}).captured.path, undefined)
|
||||
assert.equal(ctxOf({ connection: {} }).captured.path, undefined)
|
||||
assert.equal(ctxOf({ connection: { fetch: {} } }).captured.path, undefined)
|
||||
assert.equal(armed().captured.path, ATTACHMENT_ROUTE)
|
||||
})
|
||||
|
||||
test('a rejecting registry keeps the route absent without throwing', () => {
|
||||
const ctx = {
|
||||
get: (name: string) => name === 'connection'
|
||||
? {
|
||||
fetch: {
|
||||
register: () => {
|
||||
throw new Error('registration rejected')
|
||||
},
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
inject(_deps: string[], cb: (c: unknown) => unknown) { cb(ctx) },
|
||||
}
|
||||
assert.doesNotThrow(() => { watchAttachmentChannel(ctx as unknown as Context, OPEN_GATE) })
|
||||
})
|
||||
|
||||
test('bad bodies, a missing session, and a missing attachment are typed failures', async () => {
|
||||
const { captured } = armed()
|
||||
const badJson = await captured.fetch!(new Request(`http://dsh.test${ATTACHMENT_ROUTE}`, { method: 'POST', body: '{' }))
|
||||
assert.equal((await badJson.json() as { ok: boolean }).ok, false)
|
||||
for (const body of [undefined, null, {}, { sessionId: 42 }, { sessionId: '' }, { sessionId: 's1' }, { sessionId: 's1', attachmentId: 7 }, { sessionId: 's1', attachmentId: '' }]) {
|
||||
const r = await call(captured, body)
|
||||
assert.equal(r.ok, false, JSON.stringify(body))
|
||||
}
|
||||
})
|
||||
|
||||
test('no sessionQuery, a verbless query, or a skipped read answers a typed null', async () => {
|
||||
assert.deepEqual(await call(armed().captured, { sessionId: 's1', attachmentId: 'a' }), { ok: true, value: null })
|
||||
assert.deepEqual(await call(armed({ sessionQuery: {} }).captured, { sessionId: 's1', attachmentId: 'a' }), { ok: true, value: null })
|
||||
assert.deepEqual(await call(armed({ sessionQuery: observing(null) }).captured, { sessionId: 's1', attachmentId: 'a' }), { ok: true, value: null })
|
||||
const skipped = armed({ sessionQuery: observing([USER_EVENT]) }, SKIP_GATE)
|
||||
assert.deepEqual(await call(skipped.captured, { sessionId: 's1', attachmentId: 'sha256:f1' }), { ok: true, value: null })
|
||||
})
|
||||
|
||||
test('an id the session never cited resolves null (the authorization rule)', async () => {
|
||||
const query = observing([USER_EVENT])
|
||||
const { captured } = armed({ sessionQuery: query })
|
||||
assert.deepEqual(await call(captured, { sessionId: 's1', attachmentId: 'sha256:invented' }), { ok: true, value: null })
|
||||
assert.equal(query.disposed(), true)
|
||||
})
|
||||
|
||||
test('a throwing observation surfaces as an internal failure', async () => {
|
||||
const { captured } = armed({ sessionQuery: { observeSession: () => Promise.reject(new Error('log unreadable')) } })
|
||||
const r = await call(captured, { sessionId: 's1', attachmentId: 'a' })
|
||||
assert.equal(r.ok, false)
|
||||
assert.equal((r.error as { message: string }).message, 'log unreadable')
|
||||
const nonError = armed({ sessionQuery: { observeSession: () => Promise.reject('nope') } })
|
||||
assert.equal(((await call(nonError.captured, { sessionId: 's1', attachmentId: 'a' })).error as { message: string }).message, 'nope')
|
||||
})
|
||||
})
|
||||
|
||||
describe('watchAttachmentChannel resolution', () => {
|
||||
test('a cited file resolves its named host path and the observation disposes', async () => {
|
||||
const query = observing([USER_EVENT])
|
||||
const seen: unknown[] = []
|
||||
const { captured } = armed({
|
||||
sessionQuery: query,
|
||||
attachments: {
|
||||
fileHostPath: (ref: unknown) => {
|
||||
seen.push(ref)
|
||||
return ALIAS
|
||||
},
|
||||
},
|
||||
})
|
||||
assert.deepEqual(await call(captured, { sessionId: 's1', attachmentId: 'sha256:f1' }), { ok: true, value: { path: ALIAS } })
|
||||
assert.deepEqual(seen, [{ attachmentId: 'sha256:f1', name: 'report.md', bytes: 47320 }])
|
||||
assert.equal(query.disposed(), true)
|
||||
})
|
||||
|
||||
test('a store without an image face answers a typed null for an image', async () => {
|
||||
const { captured } = armed({
|
||||
sessionQuery: observing([USER_EVENT]),
|
||||
attachments: { fileHostPath: () => ALIAS },
|
||||
})
|
||||
assert.deepEqual(await call(captured, { sessionId: 's1', attachmentId: 'sha256:i1' }), { ok: true, value: null })
|
||||
})
|
||||
|
||||
test('every unlocatable attachment shape resolves a typed null', async () => {
|
||||
const cases: { attachments: unknown; id: string }[] = [
|
||||
// No store composed, a store without the verb, and a store answering nothing path-shaped.
|
||||
{ attachments: undefined, id: 'sha256:f1' },
|
||||
{ attachments: {}, id: 'sha256:f1' },
|
||||
{ attachments: { fileHostPath: () => undefined }, id: 'sha256:f1' },
|
||||
{ attachments: { fileHostPath: () => '' }, id: 'sha256:f1' },
|
||||
{ attachments: { fileHostPath: () => 42 }, id: 'sha256:f1' },
|
||||
// A hostile reference (bad digest, unsanitized name) throws inside the store.
|
||||
{
|
||||
attachments: {
|
||||
fileHostPath: () => {
|
||||
throw new Error('invalid attachment ref')
|
||||
},
|
||||
},
|
||||
id: 'sha256:f1',
|
||||
},
|
||||
]
|
||||
for (const c of cases) {
|
||||
const { captured } = armed({ sessionQuery: observing([USER_EVENT]), attachments: c.attachments })
|
||||
assert.deepEqual(await call(captured, { sessionId: 's1', attachmentId: c.id }), { ok: true, value: null }, JSON.stringify(c))
|
||||
}
|
||||
})
|
||||
|
||||
test('an observation without a dispose method still serves', async () => {
|
||||
const { captured } = armed({
|
||||
sessionQuery: { observeSession: () => Promise.resolve({ events: [USER_EVENT] }) },
|
||||
attachments: { fileHostPath: () => ALIAS },
|
||||
})
|
||||
assert.deepEqual(await call(captured, { sessionId: 's1', attachmentId: 'sha256:f1' }), { ok: true, value: { path: ALIAS } })
|
||||
})
|
||||
|
||||
test('a nameless file reference still resolves through the store', async () => {
|
||||
const { captured } = armed({
|
||||
sessionQuery: observing([{ type: 'user/message', seq: 1, data: { content: [{ type: 'file', attachment: { attachmentId: 'sha256:f3' } }] } }]),
|
||||
attachments: { fileHostPath: (ref: unknown) => `${ALIAS}:${JSON.stringify(ref)}` },
|
||||
})
|
||||
const r = await call(captured, { sessionId: 's1', attachmentId: 'sha256:f3' })
|
||||
assert.deepEqual(r.value, { path: `${ALIAS}:${JSON.stringify({ attachmentId: 'sha256:f3', name: '', bytes: 0 })}` })
|
||||
})
|
||||
})
|
||||
|
||||
describe('image preview names', () => {
|
||||
test('an image keeps a viewable suffix, leaf only', () => {
|
||||
assert.equal(imagePreviewNameOf({ kind: 'image', attachmentId: 'a', name: 'photo.PNG', mediaType: 'image/jpeg' }), 'photo.PNG')
|
||||
assert.equal(imagePreviewNameOf({ kind: 'image', attachmentId: 'a', name: '../../evil.svg' }), 'evil.svg')
|
||||
assert.equal(imagePreviewNameOf({ kind: 'image', attachmentId: 'a', name: 'C:\\tmp\\x.webp' }), 'x.webp')
|
||||
})
|
||||
|
||||
test('anything the column cannot view is named by the verified media type', () => {
|
||||
assert.equal(imagePreviewNameOf({ kind: 'image', attachmentId: 'a', name: 'clipboard', mediaType: 'image/jpeg' }), 'attachment.jpg')
|
||||
assert.equal(imagePreviewNameOf({ kind: 'image', attachmentId: 'a', name: 'shot.png', mediaType: 'image/webp' }), 'shot.png', 'a suffix a viewer claims keeps the displayed name; the viewer renders the stored bytes')
|
||||
assert.equal(imagePreviewNameOf({ kind: 'image', attachmentId: 'a', name: 'scan.tiff', mediaType: 'image/png' }), 'attachment.png')
|
||||
assert.equal(imagePreviewNameOf({ kind: 'image', attachmentId: 'a', mediaType: 'image/gif' }), 'attachment.gif')
|
||||
assert.equal(imagePreviewNameOf({ kind: 'image', attachmentId: 'a', mediaType: 'image/tiff' }), null)
|
||||
assert.equal(imagePreviewNameOf({ kind: 'image', attachmentId: 'a' }), null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('watchAttachmentChannel image aliases', () => {
|
||||
const homes: string[] = []
|
||||
afterEach(() => {
|
||||
for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/** A real store layout: `<home>/attachments/v1/objects/<xx>/<digest>` holding the image bytes. */
|
||||
function storeOf(bytes: string): { home: string; objectPath: string; store: { imageHostPath: () => string } } {
|
||||
const home = mkdtempSync(join(tmpdir(), 'dsh-context-att-'))
|
||||
homes.push(home)
|
||||
const digest = 'ab'.repeat(32)
|
||||
const objectPath = join(home, 'attachments', 'v1', 'objects', 'ab', digest)
|
||||
mkdirSync(join(home, 'attachments', 'v1', 'objects', 'ab'), { recursive: true })
|
||||
writeFileSync(objectPath, bytes)
|
||||
return { home, objectPath, store: { imageHostPath: () => objectPath } }
|
||||
}
|
||||
|
||||
test('a cited image answers a named hard link under the plugin preview cache', async () => {
|
||||
const { home, objectPath, store } = storeOf('image-bytes')
|
||||
const { captured } = armed({ sessionQuery: observing([USER_EVENT]), attachments: store })
|
||||
const r = await call(captured, { sessionId: 's1', attachmentId: 'sha256:i1' })
|
||||
const path = (r.value as { path: string }).path
|
||||
assert.equal(path, join(home, 'cache', 'dsh-context', 'previews', 'ab', basename(objectPath), 'result-image.jpg'))
|
||||
assert.equal(readFileSync(path, 'utf8'), 'image-bytes', 'the link shares the stored bytes')
|
||||
// A second read reuses the link instead of failing on EEXIST.
|
||||
const again = await call(captured, { sessionId: 's1', attachmentId: 'sha256:i1' })
|
||||
assert.deepEqual(again.value, { path })
|
||||
})
|
||||
|
||||
test('an unfamiliar object layout and an unlocatable object both answer a typed null', async () => {
|
||||
const flat = armed({
|
||||
sessionQuery: observing([USER_EVENT]),
|
||||
attachments: { imageHostPath: () => join(tmpdir(), 'objects', 'ab', 'digest') },
|
||||
})
|
||||
assert.deepEqual(await call(flat.captured, { sessionId: 's1', attachmentId: 'sha256:i1' }), { ok: true, value: null })
|
||||
|
||||
const { home } = storeOf('image-bytes')
|
||||
const missing = armed({
|
||||
sessionQuery: observing([USER_EVENT]),
|
||||
attachments: { imageHostPath: () => join(home, 'attachments', 'v1', 'objects', 'ab', 'gone') },
|
||||
})
|
||||
assert.deepEqual(await call(missing.captured, { sessionId: 's1', attachmentId: 'sha256:i1' }), { ok: true, value: null })
|
||||
})
|
||||
|
||||
test('a hostile or empty image reference resolves a typed null', async () => {
|
||||
const cases: { store: unknown; id: string }[] = [
|
||||
{ store: {}, id: 'sha256:i1' },
|
||||
{
|
||||
store: {
|
||||
imageHostPath: () => {
|
||||
throw new Error('invalid attachment ref')
|
||||
},
|
||||
},
|
||||
id: 'sha256:i1',
|
||||
},
|
||||
{ store: { imageHostPath: () => '' }, id: 'sha256:i1' },
|
||||
{ store: { imageHostPath: () => 42 }, id: 'sha256:i1' },
|
||||
// The cited image carries no media type and no usable suffix.
|
||||
{ store: { imageHostPath: () => '/x/attachments/v1/objects/ab/digest' }, id: 'sha256:i2' },
|
||||
]
|
||||
for (const c of cases) {
|
||||
const { captured } = armed({ sessionQuery: observing([USER_EVENT, BARE_IMAGE_EVENT]), attachments: c.store })
|
||||
assert.deepEqual(await call(captured, { sessionId: 's1', attachmentId: c.id }), { ok: true, value: null }, JSON.stringify(c.id))
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -186,7 +186,7 @@ describe('dsh-context host plugin', () => {
|
||||
|
||||
test('the split generation with the detail route live: slim wire head + the endpoint serves the collections', async () => {
|
||||
const ctx = new Context()
|
||||
// The plugin mounts two routes (detail + warm-up trigger); keep them by path.
|
||||
// The plugin's own routes (detail, fleet, attachment, warm-up trigger, balance, skills); keep them by path.
|
||||
const routes = new Map<string, { fetch?: (request: Request) => Promise<Response> }>()
|
||||
ctx.provide('connection', {
|
||||
fetch: {
|
||||
@@ -215,6 +215,7 @@ describe('dsh-context host plugin', () => {
|
||||
assert.equal(timeline.nodes.length, 0, 'the collections stay off the wire value')
|
||||
assert.equal(timeline.requests.length, 0)
|
||||
|
||||
assert.ok(routes.has('/api/dsh-context/attachment'), 'the attachment-location route registered beside the detail route')
|
||||
const route = routes.get('/api/dsh-context/detail')
|
||||
assert.ok(route !== undefined && route.fetch !== undefined, 'the detail route registered')
|
||||
const response = await route.fetch(new Request('http://dsh.test/api/dsh-context/detail', {
|
||||
|
||||
@@ -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'), [])
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user