Files
bowenliang123 bb29bcad59 fix(client): report a file attachment's token estimate as ≈ instead of ≥
The file card priced only the pathless branch of the harness handle — the one
dsh emits when the stored copy cannot be read — and disclosed that shortfall
with a `≥` lower-bound marker, which read as a bug beside the image rows' `≈`.
Every other token row reports a heuristic estimate, so this one now does too.

The tooltip claimed the handle named "where the stored copy can be read": the
path-bearing branch, which costs roughly 1.7x more and is never priced. Under
`≥` the marker covered that gap; under `≈` the explanation described a branch
the number does not come from, so the clause is gone from both locales. The
estimate itself is unchanged.
2026-10-13 23:39:09 +08:00

239 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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'))
// A file card keeps its size and what its handle costs: the extension badge already names the type.
assert.ok(!text(cards[0]).includes('Type'))
assert.ok(text(cards[0]).includes('Size') && text(cards[0]).includes('2.6 kB'))
assert.ok(text(cards[0]).includes('Token') && text(cards[0]).includes('≈58'), 'the handle price')
assert.ok(
text(cards[1]).includes('≈56') && text(cards[2]).includes('≈56'),
'a 778 kB docx costs no more than a 47 kB markdown: only the handle travels',
)
const fileTip = queryAll(cards[0], '.lc-att-row').pop()?.getAttribute('title') ?? ''
assert.ok(fileTip.includes('Estimated') && fileTip.includes('never its contents'), fileTip)
assert.ok(
(queryAll(cards[3], '.lc-att-row').pop()?.getAttribute('title') ?? '').includes('image-size conversion'),
'the image row keeps the vision calculator as its explanation',
)
assert.ok(text(cards[3]).includes('Type'), 'an image has no badge, so its type keeps the row')
// 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 names its kind on the badge, and shows no size or token 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'))
// The handle names the file and its size, so an unproven reference prices nothing.
assert.ok(!text(card).includes('Token'))
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('a row re-render never re-reads the bytes (the reference is re-proven each render)', async () => {
let reads = 0
const counting: ImageLoader = async (att) => {
reads += 1
return 'blob:' + att.attachmentId
}
const m = await mountSection({ load: counting, attachments: [{ kind: 'image', attachmentId: 'sha256:i1', name: 'a.png', width: 4, height: 4 }] })
await flush()
assert.equal(reads, 1)
// The same id re-proven into a NEW object (the fold's shape) must not trigger another read.
await m.update(h(Attachments, { attachments: [{ kind: 'image', attachmentId: 'sha256:i1', name: 'a.png', width: 4, height: 4 }], load: counting }))
await flush()
assert.equal(reads, 1)
assert.ok(query<HTMLImageElement>(m.container, '.lc-att-thumb img').src.includes('blob:sha256:i1'))
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()
})
})