diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.i18n.yaml new file mode 100644 index 0000000000..cc6eb7ff26 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.md +2026-09-15-trajectory-attachment-presentation.md: 03d737d67e626497bbcb957911b1578e0dadd5fb +2026-09-15-trajectory-attachment-presentation.zh.md: 55c63bf3d97de2e3e102e92730ce4d2637547d6b diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.md b/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.md new file mode 100644 index 0000000000..03d737d67e --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.md @@ -0,0 +1,55 @@ +# Agent Note: Consistent Trajectory attachment presentation + +Status: implemented + +English | [中文](2026-09-15-trajectory-attachment-presentation.zh.md) + +## Problem + +A user message can contain text, images, and ordinary files. Trajectory omitted image counts beside text, left ordinary files out of Preview, and mixed file JSON with rendered images in Raw. Readers could not identify the complete attachment set consistently across the ledger and inspector (Issue #4356). + +## Decision + +Trajectory presents every attachment occurrence while keeping visual inspection separate from inspection of recorded fields. + +| Location | Behavior | +|---|---| +| Ledger | Separate nonzero image and ordinary-file counts accompany the text excerpt, including messages without text. Each record stays on one compact line. | +| Summary and Preview | Message text precedes one ordered attachment list. Both tabs use the same renderer; Summary retains its compact spacing and scroll container. | +| Attachment list | Equal-width compact rows share spacing and corners. A file-type icon or contained image thumbnail accompanies the recorded name and available metadata. Repeated references retain their occurrence order. | +| Image interaction | The complete image fits inside a 48px square thumbnail without cropping. Activation opens the existing image viewer, with keyboard access. Loading and retry icons fit the thumbnail and expose localized tooltips and accessible names. | +| Raw | Image and file blocks use initially collapsed metadata disclosures with complete recorded fields. Original content-block order and unrendered text remain intact; generated display labels do not enter raw fields. | + +Recorded filenames take precedence over localized numbered image labels. Metadata comes from recorded fields or the filename extension; zero-byte sizes remain visible. Visually truncated names retain their complete accessible text and tooltip. Messages without text still expose all attachments inside the inspector's scroll area. + +### Ownership + +[ui-trajectory](../../../../packages/client/ui-trajectory/README.md) owns counts, ordered attachment projection, and inspector composition. Its view data carries typed image and file references separately from the serialized content used by Raw, so display code does not recover file metadata by parsing JSON. + +[ui-attachment](../../../../packages/client/ui-attachment/README.md) owns image loading and the viewer through the existing image slot. [ui-conversation](../../../../packages/client/ui-conversation/README.md) owns the per-session image cache: Chat and Trajectory share one authorized read and browser URL per attachment. Thumbnail sizing and optional display labels affect presentation only; loading still receives the original reference. Chat and tool-result gallery sizing retain their existing behavior. + +This decision uses existing durable attachment references without changing upload, storage, model requests, or the Session format. Ordinary files expose metadata without a content viewer or download action. Product labels use typed locale dictionaries, and styles use the existing semantic tokens. + +The [generic file upload decision](../feature/2026-08-26-generic-file-upload.md) owns upload, storage, admission, and ordered message attachments; this record owns Trajectory presentation. The [unreadable attachment quarantine proposal](../../proposed/bug-fix/2026-08-20-attachment-read-quarantine.md) concerns model-request recovery independently of thumbnail loading feedback. + +## Alternatives considered + +**Keep large inline images as the default inspector presentation.** This makes one image immediately readable, but a tall image displaces attachment names and message text in the narrow inspector. Compact rows with explicit image activation provide a consistent overview of mixed attachments. + +**Use two-column attachment cards.** A grid can make thumbnails more prominent on wide panels, but it leaves less room for long filenames and requires a different narrow-panel arrangement. A single equal-width list accommodates both panel widths. + +**Keep file JSON and image previews together in Raw.** This assigns different meanings to the same tab for two attachment kinds. Metadata disclosures let readers inspect recorded fields while Preview owns visual image presentation. + +## Consequences + +The shared attachment renderer keeps names, order, metadata, and image actions consistent between Summary and Preview. Grouping attachments after message text makes the complete set easy to scan; Raw preserves interleaving when readers need the original block sequence. Repeated references remain separate rows even when image loading shares a cache entry. + +Compact thumbnails require opening the viewer to read detailed diagrams. Missing metadata stays absent, and image-read failures retain a retry control without hiding ordinary files. Changes to the shared image slot also affect Chat and tool results, so their gallery behavior and cache reuse remain regression requirements. + +## Verification + +[Projection tests](../../../../packages/client/ui-trajectory/tests/layout.client.spec.tsx) cover mixed, attachment-only, empty-text, and repeated occurrences. [Inspector tests](../../../../packages/client/ui-trajectory/tests/table.client.spec.tsx) cover both locales, unnamed images, long filenames, zero-byte files, ordered lists, and complete Raw fields. [Image tests](../../../../packages/client/ui-attachment/tests/message-image.client.spec.tsx) cover thumbnail labels, loading, retry, and the existing viewer. + +The recorded [mixed-upload browser scenario](../../../../apps/web/tests/file-upload-round.e2e.ts) compares Summary and Preview, verifies keyboard activation and focus restoration, checks collapsed and expanded Raw content, and repeats inspection after reload. Its [Trajectory expectation](../../../../snapshots/web/file-upload-round/trajectory.expected.md) accompanies the unchanged Session fixture. The [assembled image-cache test](../../../../apps/web/tests/trajectory-image-display.expected.e2e.ts) also checks reuse between Chat and Trajectory. + +Layout acceptance covers narrow and wide panels, both themes and locales, complete thumbnail containment, and reachable final attachments. The [GIF workflow](../../../skills/record-browser-gif/SKILL.md) requires review evidence from a clean committed tree and one real-server, real-model session showing mixed submission, ledger counts, Summary, Preview image activation, and expanded Raw metadata. Issue #4356 and its linked PR track executed checks and demonstration evidence. diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.zh.md b/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.zh.md new file mode 100644 index 0000000000..55c63bf3d9 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.zh.md @@ -0,0 +1,55 @@ +# Agent Note: 统一 Trajectory 附件展示 + +Status: implemented + +[English](2026-09-15-trajectory-attachment-presentation.md) | 中文 + +## 问题 + +用户消息可以包含文本、图片和普通文件。Trajectory 曾在文本旁省略图片数量,在预览中遗漏普通文件,并在原始内容中混排文件 JSON 与渲染后的图片。读者无法在列表和详情面板中一致地确认完整附件集合(Issue #4356)。 + +## 决定 + +Trajectory 展示每次附件出现的记录,并分别提供视觉预览与已记录字段检查。 + +| 位置 | 行为 | +|---|---| +| 列表 | 文本摘要旁分别显示非零的图片数量与普通文件数量,无正文的消息也显示计数。每条记录保持为紧凑单行。 | +| 概述与预览 | 消息正文之后展示一个有序附件列表。两个标签页使用相同的渲染器;概述保留紧凑间距和滚动容器。 | +| 附件列表 | 等宽的紧凑行使用一致的间距和圆角。文件类型图标或完整缩放的图片缩略图旁展示已记录的名称与可用元数据。重复引用保留各次出现的顺序。 | +| 图片交互 | 图片完整缩放到 48px 方形缩略图内,不裁剪。激活时打开现有图片查看器,保留键盘访问。加载与重试图标适配缩略图尺寸,并提供本地化的工具提示和可访问名称。 | +| 原始内容 | 图片块和文件块使用默认收起的元数据折叠区,包含完整记录字段。原始内容块顺序与未渲染文本保持完整;生成的显示标签不进入原始字段。 | + +已记录文件名优先于本地化的编号图片标签。元数据来自记录字段或文件扩展名,零字节大小仍然可见。视觉上截断的名称保留完整的无障碍文本和工具提示。无正文的消息仍在详情面板滚动区域内展示全部附件。 + +### 职责 + +[ui-trajectory](../../../../packages/client/ui-trajectory/README.zh.md)负责计数、有序附件投影和详情面板组合。其视图数据将带类型的图片与文件引用和原始内容使用的序列化文本分别保存,展示代码因此无需解析 JSON 来恢复文件元数据。 + +[ui-attachment](../../../../packages/client/ui-attachment/README.zh.md)通过现有图片 slot 负责图片加载与查看器。[ui-conversation](../../../../packages/client/ui-conversation/README.zh.md)拥有会话级图片缓存:Chat 与 Trajectory 对每个附件共享一次授权读取和一个浏览器 URL。缩略图尺寸和可选显示标签只影响展示,加载仍接收原始引用。Chat 和工具结果图库保留现有的尺寸行为。 + +本决定使用现有持久附件引用,不改变上传、存储、模型请求或会话格式。普通文件展示元数据,不提供内容查看器或下载操作。产品标签使用带类型的本地化字典,样式使用现有语义 token。 + +[通用文件上传决定](../feature/2026-08-26-generic-file-upload.zh.md)负责上传、存储、准入和有序消息附件;本记录负责 Trajectory 展示。[不可读附件隔离提案](../../proposed/bug-fix/2026-08-20-attachment-read-quarantine.zh.md)处理模型请求恢复,与缩略图加载反馈相互独立。 + +## 考虑过的替代方案 + +**默认在详情面板中内联展示大图。** 这样可以立即阅读单张图片,但高图片会在窄面板中挤走附件名称和消息正文。紧凑行加显式图片激活入口能一致地概览混合附件。 + +**使用双列附件卡片。** 网格能在宽面板上突出缩略图,但会压缩长文件名空间,并要求窄面板采用不同排布。单列等宽列表适用于两种面板宽度。 + +**在原始内容中混排文件 JSON 和图片预览。** 这让同一个标签页对两种附件具有不同含义。元数据折叠区用于检查已记录字段,预览负责图片的视觉展示。 + +## 影响 + +共享附件渲染器使概述与预览中的名称、顺序、元数据和图片操作保持一致。将附件集中在消息正文之后便于浏览完整集合;需要原始块顺序时,原始内容保留交错关系。即使图片加载共享缓存项,重复引用仍各占一行。 + +紧凑缩略图需要打开查看器才能阅读详细图表。缺失的元数据保持缺失,图片读取失败保留重试控件,并且不隐藏普通文件。共享图片 slot 的修改也会影响 Chat 和工具结果,因此其图库行为和缓存复用仍需回归验证。 + +## 验证 + +[投影测试](../../../../packages/client/ui-trajectory/tests/layout.client.spec.tsx)覆盖混合附件、纯附件、空文本和重复引用。[详情面板测试](../../../../packages/client/ui-trajectory/tests/table.client.spec.tsx)覆盖两种语言、无名称图片、长文件名、零字节文件、有序列表和完整的原始字段。[图片测试](../../../../packages/client/ui-attachment/tests/message-image.client.spec.tsx)覆盖缩略图标签、加载、重试和现有查看器。 + +已录制的[混合上传浏览器场景](../../../../apps/web/tests/file-upload-round.e2e.ts)比较概述与预览,验证键盘激活和焦点恢复,检查原始内容收起与展开状态,并在刷新后重复检查。其 [Trajectory 预期输出](../../../../snapshots/web/file-upload-round/trajectory.expected.md)配合未改动的会话 fixture(测试前置数据)使用。[组装应用图片缓存测试](../../../../apps/web/tests/trajectory-image-display.expected.e2e.ts)也检查 Chat 与 Trajectory 之间的复用。 + +布局验收覆盖窄版与宽版面板、两种主题和语言、缩略图完整缩放及末尾附件可达性。[GIF 工作流](../../../skills/record-browser-gif/SKILL.md)要求评审证据基于干净的已提交代码树,在同一个真实服务、真实模型会话中展示混合提交、列表计数、概述、预览中的图片激活和展开的原始元数据。Issue #4356 及其关联 PR(Pull Request)记录已执行的检查和演示证据。 diff --git a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml index 56fb8a76d2..d5f37e0569 100644 --- a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-26-generic-file-upload.md -2026-08-26-generic-file-upload.md: 6d0d2901b14c44930b4342292d317ebe03705730 -2026-08-26-generic-file-upload.zh.md: 2d78811c24ba1a801d43f21f4e63db5646b0431b +2026-08-26-generic-file-upload.md: ddaf92a396ade59d65a81300531be9bce2aeeaba +2026-08-26-generic-file-upload.zh.md: 27a73536ec3632f40ee49a59d932406e77b5591d diff --git a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md index 6d0d2901b1..ddaf92a396 100644 --- a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md +++ b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md @@ -48,3 +48,7 @@ Default mode, active Plan Mode, and an active goal share ordinary queue, follow- ## Testing Unit and integration coverage pins verbatim and streamed storage, name sanitization, a declared 2.19 GiB request passing through the streaming bridge without aggregate buffering, raw and RPC wire admission, Blob and transferable `ReadableStream` carriers, bounded background-upload concurrency, progress and cancellation, cross-Session upload residency, command file-receipt submission without a second byte read, native and PTC projection under read-only and workspace-write permissions, upload staging, ordered queue and steer submission, queue-to-steer conversion, pending echo retirement and failure recovery, active plan and goal intake, `/plan` and `/goal` mixed attachments, subagent refusal, composer and Chat attachment layouts, and Trajectory file summaries. The keyless `file-upload-round` snapshot records the browser upload, model file read, and rendered answer. + +## Related + +The [Trajectory attachment presentation decision](../bug-fix/2026-09-15-trajectory-attachment-presentation.md) owns ledger counts and inspector attachment layouts. diff --git a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md index 2d78811c24..27a73536ec 100644 --- a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md +++ b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md @@ -48,3 +48,7 @@ Status: implemented ## 测试 单元与集成覆盖原样与流式存储、文件名清洗、声明为 2.19 GiB 的请求通过流式 bridge 且不发生总量缓冲、原始字节与 RPC wire 准入、Blob 与可转移 `ReadableStream` 载体、后台上传并发上限、进度与取消、跨 Session 上传常驻、命令通过文件凭证提交且不再次读取字节、read-only 与 workspace-write 权限下的 native 和 PTC 投影、上传暂存、有序 queue 与 steer 提交、队列转 steer、pending 回显退休与失败恢复、active plan 和 goal 的附件入口、`/plan` 与 `/goal` 混合附件、子代理拒绝、composer 与 Chat 附件布局,以及 Trajectory 文件摘要。keyless `file-upload-round` 快照记录浏览器上传、模型读取文件与答案呈现。 + +## 相关记录 + +[Trajectory 附件展示决定](../bug-fix/2026-09-15-trajectory-attachment-presentation.zh.md)负责列表计数与详情面板附件布局。 diff --git a/apps/web/tests/file-upload-round.e2e.ts b/apps/web/tests/file-upload-round.e2e.ts index 5df27af9d9..c25211dd13 100644 --- a/apps/web/tests/file-upload-round.e2e.ts +++ b/apps/web/tests/file-upload-round.e2e.ts @@ -12,10 +12,10 @@ import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' -import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { - assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, + acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' @@ -24,6 +24,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/file-upload-r const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/session.v3.jsonl', import.meta.url)) const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/ui.expected.md', import.meta.url)) const TRAJECTORY_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/trajectory.expected.md', import.meta.url)) +const TRAJECTORY_STATUS_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/trajectory-status.expected.md', import.meta.url)) const OVERRIDE = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/replay.override.json', import.meta.url)) const DRAFT_EXPECTED = fileURLToPath(new URL('./expected/file-upload-round/draft.expected.md', import.meta.url)) const HISTORY_EXPECTED = fileURLToPath(new URL('./expected/file-upload-round/history.expected.md', import.meta.url)) @@ -108,6 +109,93 @@ describe('web e2e: generic file upload through the real assembly', () => { let page: Page let tripwire: ReturnType const sessionEvents: SessionEvent[] = [] + let liveTrajectory: string | undefined + + /** Inspect one durable mixed message through the real image slot and all message tabs. */ + async function inspectTrajectoryAttachments(): Promise { + const userMessage = sessionEvents.find(event => event.type === 'user/message' && event.data.source.kind === 'user') + if (userMessage?.type !== 'user/message') throw new Error('the replayed turn recorded no user message') + const content = userMessage.data.content + const imageName = IMAGE_NAMES[0]! + await page.getByRole('tab', { name: 'Chat', exact: true }).click() + const chatImage = page.getByRole('img', { name: imageName, exact: true }) + await expect.poll(() => chatImage.getAttribute('src'), { timeout: 10_000 }).toMatch(/^blob:/) + const chatImageUrl = await chatImage.getAttribute('src') + + await page.getByRole('tab', { name: 'Trajectory', exact: true }).click() + await page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 }) + const row = page.getByRole('row', { name: `USER, Images ×1 · Files ×1 · ${PROMPT}`, exact: true }) + await row.waitFor({ timeout: 10_000 }) + const ledger = await captureStableAria(page, '[data-trajectory-row-key][aria-label*="Files ×1"]', scaffold.workspaceCwd) + await row.click() + + const panel = page.getByRole('tabpanel') + const list = panel.getByRole('list', { name: 'Attachments', exact: true }) + let summaryAttachments: string | undefined + let preview: string | undefined + for (const tab of ['Summary', 'Preview']) { + await page.getByRole('tab', { name: tab, exact: true }).click() + await list.waitFor() + expect(await panel.getByText(PROMPT, { exact: true }).count()).toBe(1) + const rows = list.getByRole('listitem') + expect(await rows.count()).toBe(2) + expect(await rows.nth(0).getByTitle(FILE_NAME, { exact: true }).count()).toBe(1) + expect(await rows.nth(1).getByTitle(imageName, { exact: true }).count()).toBe(1) + expect(await rows.nth(0).textContent()).toContain('TXT · 16B') + expect(await rows.nth(1).textContent()).toContain('image/png · 69B · 1 × 1') + const thumbnail = list.getByRole('img', { name: imageName, exact: true }) + await expect.poll(() => thumbnail.getAttribute('src'), { timeout: 10_000 }).toBe(chatImageUrl) + expect(await thumbnail.evaluate(element => getComputedStyle(element).objectFit)).toBe('contain') + + const attachments = await captureStableAria(page, '[role="tabpanel"] [aria-label="Attachments"]', scaffold.workspaceCwd) + if (tab === 'Summary') summaryAttachments = attachments + else { + expect(attachments).toBe(summaryAttachments) + preview = await captureStableAria(page, '[role="tabpanel"]', scaffold.workspaceCwd) + } + const opener = list.getByRole('button', { name: `${imageName}, click to view original`, exact: true }) + await opener.focus() + await opener.press('Enter') + const dialog = page.getByRole('dialog', { name: 'Original image preview', exact: true }) + await dialog.waitFor() + expect(await dialog.getByRole('img', { name: imageName }).getAttribute('src')).toBe(chatImageUrl) + await page.keyboard.press('Escape') + await dialog.waitFor({ state: 'hidden' }) + expect(await opener.evaluate(element => element === document.activeElement)).toBe(true) + } + + await page.getByRole('tab', { name: 'Raw', exact: true }).click() + const disclosures = panel.locator('details') + expect(await disclosures.count()).toBe(2) + expect(await panel.getByRole('img').count()).toBe(0) + for (const disclosure of await disclosures.all()) { + expect(await disclosure.getAttribute('open')).toBeNull() + expect(await disclosure.locator('pre').isVisible()).toBe(false) + } + const rawCollapsed = await captureStableAria(page, '[role="tabpanel"]', scaffold.workspaceCwd) + const blocks = panel.locator('details, section') + expect(await blocks.count()).toBe(content.length) + for (const [index, block] of content.entries()) { + const renderedBlock = blocks.nth(index) + if (block.type === 'text') { + expect(await renderedBlock.locator('pre').textContent()).toBe(block.text) + } else { + expect(['image', 'file']).toContain(block.type) + await renderedBlock.locator('summary').click() + expect(await renderedBlock.locator('pre').isVisible()).toBe(true) + const raw: unknown = JSON.parse((await renderedBlock.locator('pre').textContent())!) + expect(raw).toEqual(block) + } + } + const rawExpanded = await captureStableAria(page, '[role="tabpanel"]', scaffold.workspaceCwd) + return [ + '# Ledger', ledger, + '# Summary attachments', summaryAttachments, + '# Preview', preview, + '# Raw (collapsed)', rawCollapsed, + '# Raw (expanded)', rawExpanded, + ].join('\n\n') + } beforeAll(async () => { scaffold = await launchWebScaffold({ @@ -283,23 +371,68 @@ describe('web e2e: generic file upload through the real assembly', () => { await compareOrRefreshGolden(HISTORY_EXPECTED, renderHistoryAttachmentGeometry(geometry), MODE) }) - it.skipIf(MODE === 'record')('marks the durable file in Trajectory without copying the Chat card', async () => { - await page.getByRole('tab', { name: 'Trajectory', exact: true }).click() - await page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 }) - await page.getByRole('row', { name: /Files ×1/ }).waitFor({ timeout: 10_000 }) - const snapshot = await captureStableAria( - page, - '[data-trajectory-row-key][aria-label*="Files ×1"]', - scaffold.workspaceCwd, - ) - await compareOrRefreshGolden(TRAJECTORY_EXPECTED, snapshot, MODE) + it.skipIf(MODE === 'record')('shows both attachment kinds throughout the Trajectory inspector', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-file-upload-trajectory')) + liveTrajectory = await inspectTrajectoryAttachments() + await compareOrRefreshGolden(TRAJECTORY_EXPECTED, liveTrajectory, MODE) + }) + + it.skipIf(MODE === 'record')('restores the same Trajectory attachments from persisted history after reload', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-file-upload-trajectory-reload')) + expect(liveTrajectory).toBeDefined() + const warningStart = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + acknowledgeReloadConnectionLoss(tripwire, warningStart) + expect(await inspectTrajectoryAttachments()).toBe(liveTrajectory) + }) + + it.skipIf(MODE === 'record')('keeps thumbnail loading and retry feedback inside the attachment row', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-thumbnail-status')) + const releaseRead = Promise.withResolvers() + const read = vi.spyOn(scaffold.ctx.attachments, 'readImage').mockImplementationOnce(async () => { + await releaseRead.promise + throw new Error('fixture image read failed') + }) + try { + // Reload clears the browser image cache so the real image slot observes the delayed read. + const warningStart = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + acknowledgeReloadConnectionLoss(tripwire, warningStart) + await page.getByRole('tab', { name: 'Trajectory', exact: true }).click() + await page.getByRole('row', { name: `USER, Images ×1 · Files ×1 · ${PROMPT}`, exact: true }).click() + await page.getByRole('tab', { name: 'Preview', exact: true }).click() + const panel = page.getByRole('tabpanel') + const thumbnail = panel.locator('[data-variant="thumbnail"]') + await thumbnail.waitFor() + await expect.poll(() => read.mock.calls.length).toBe(1) + const feedbackFits = () => thumbnail.evaluate(element => element.scrollHeight <= element.clientHeight + && element.scrollWidth <= element.clientWidth) + expect(await feedbackFits()).toBe(true) + const loading = await captureStableAria(page, '[role="tabpanel"] [aria-label="Attachments"]', scaffold.workspaceCwd) + + releaseRead.resolve(undefined) + const retry = panel.getByRole('button', { name: 'Image failed to load; click to retry', exact: true }) + await retry.waitFor() + expect(await feedbackFits()).toBe(true) + const failed = await captureStableAria(page, '[role="tabpanel"] [aria-label="Attachments"]', scaffold.workspaceCwd) + await retry.focus() + await retry.press('Enter') + await panel.getByRole('img', { name: IMAGE_NAMES[0]!, exact: true }).waitFor() + expect(read).toHaveBeenCalledTimes(2) + await compareOrRefreshGolden(TRAJECTORY_STATUS_EXPECTED, [ + '# Loading', loading, '# Failed', failed, + ].join('\n\n'), MODE) + } finally { + releaseRead.resolve(undefined) + read.mockRestore() + } }) it.skipIf(MODE === 'record')('stayed clean and kept the exact fixture inventory', async () => { expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, [ - 'session.v3.jsonl', 'replay.override.json', 'ui.expected.md', 'trajectory.expected.md', + 'session.v3.jsonl', 'replay.override.json', 'ui.expected.md', 'trajectory.expected.md', 'trajectory-status.expected.md', ]) }) }) diff --git a/packages/client/ui-attachment/README.i18n.yaml b/packages/client/ui-attachment/README.i18n.yaml index 8b9ac24565..5ce716a2ae 100644 --- a/packages/client/ui-attachment/README.i18n.yaml +++ b/packages/client/ui-attachment/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-attachment/README.md -README.md: 3c69b387914c631f50d783c4a35089ab8db1668f -README.zh.md: c21b2acdbeb7ced6e9a8733a861c5324121ad194 +README.md: 0765068c73c8ca3cf79b8b23a54629669625ca2e +README.zh.md: 5976acfda5b605dd85ba3d43e18a83af4d9d58e0 diff --git a/packages/client/ui-attachment/README.md b/packages/client/ui-attachment/README.md index 3c69b38791..0765068c73 100644 --- a/packages/client/ui-attachment/README.md +++ b/packages/client/ui-attachment/README.md @@ -35,6 +35,8 @@ Images and generic files retain pick order in one non-wrapping horizontal rail. In Chat, one user message presents files and images in a right-aligned wrapping flow that preserves source order. A lone image without another attachment renders at 240px on its longer edge (aspect clamped to [0.25, 4], never upscaled); when the message has more than one attachment, each image is a fixed 64px square beside 240×64px file cards. A loaded image opens the document-level lightbox on click; a failed load shows a retry control instead. The lightbox closes on Escape, a mask press, or its close control, and restores focus to its opener. +Trajectory attachment rows request 48px square thumbnails that contain the complete image without cropping. Loading and retry icons keep the same box, with localized tooltips and accessible names; the image opens the same lightbox. A slot owner may supply a presentation-only image label for the thumbnail and lightbox without changing the durable reference or the cache lookup. + ### Drop overlay While a file drag is over the page, the full-viewport overlay announces the drop: illustration, title, and a limits line when drops are accepted. The overlay only shows state — the owner's document-level listeners decide accept or reject. diff --git a/packages/client/ui-attachment/README.zh.md b/packages/client/ui-attachment/README.zh.md index c21b2acdbe..5976acfda5 100644 --- a/packages/client/ui-attachment/README.zh.md +++ b/packages/client/ui-attachment/README.zh.md @@ -35,6 +35,8 @@ kind: "package-reference" Chat 中的一条用户消息把文件与图片放在同一个靠右、可换行的排列中,并保持来源顺序。消息仅有一张图片且没有其他附件时,图片按长边 240px 渲染(宽高比钳制在 [0.25, 4],从不放大);消息有多个附件时,每张图片显示为固定 64px 方块,与 240×64px 文件卡同排。加载完成的图片单击打开文档级灯箱;加载失败则显示重试控件。灯箱按 Escape、按下遮罩或点关闭按钮关闭,并把焦点还给打开者。 +Trajectory 附件行使用 48px 方形缩略图,完整缩放图片而不裁剪。加载与重试图标保持相同尺寸,提供本地化的工具提示和可访问名称;图片打开同一个灯箱。插槽持有方可以为缩略图与灯箱提供仅用于展示的图片名称,而不改变持久化引用或缓存查询。 + ### 拖放遮罩 文件拖到页面上方时,全视口遮罩显示拖放提示,包括插画和标题;接受拖放时还会显示一行限制说明。遮罩只呈现状态——是否接受由持有方的文档级监听器决定。 diff --git a/packages/client/ui-attachment/src/MessageImage.module.css b/packages/client/ui-attachment/src/MessageImage.module.css index 6c691c20a1..f551605a4a 100644 --- a/packages/client/ui-attachment/src/MessageImage.module.css +++ b/packages/client/ui-attachment/src/MessageImage.module.css @@ -67,3 +67,41 @@ overflow: hidden; border-radius: 16px; } + +.frame[data-variant='thumbnail'], +.error[data-variant='thumbnail'] { + box-sizing: border-box; + display: grid; + place-items: center; + width: 48px; + height: 48px; + min-width: 48px; + min-height: 48px; + padding: 2px; + overflow: hidden; + border-radius: 8px; +} + +.frame[data-variant='thumbnail'] img { + object-fit: contain; +} + +.spinner { + animation: spinner-rotate 0.9s linear infinite; +} + +@keyframes spinner-rotate { + to { transform: rotate(360deg); } +} + +@media (prefers-reduced-motion: reduce) { + .spinner { + animation: none; + } +} + +.frame:focus-visible, +.error:focus-visible { + outline: 2px solid var(--dsw-alias-state-business-primary); + outline-offset: 2px; +} diff --git a/packages/client/ui-attachment/src/MessageImage.tsx b/packages/client/ui-attachment/src/MessageImage.tsx index 3cf3b6c51e..be35aa650c 100644 --- a/packages/client/ui-attachment/src/MessageImage.tsx +++ b/packages/client/ui-attachment/src/MessageImage.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import { IconLoadingOutline16, IconRefreshOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' import { ImageLightbox } from './ImageLightbox.tsx' import type { ImageLightboxLabels } from './ImageLightbox.tsx' import css from './MessageImage.module.css' @@ -11,7 +12,11 @@ export type ImageLoader = ((attachment: ImageAttachmentRef) => Promise) /** One gallery entry: a durable admitted reference, or a submission echo's local preview. */ export type MessageImageSpec = - | { readonly attachment: ImageAttachmentRef } + | { + readonly attachment: ImageAttachmentRef + /** Presentation-only name for the thumbnail and lightbox; loading uses the original reference. */ + readonly label?: string + } | { readonly preview: { readonly url: string @@ -69,17 +74,18 @@ function dimensionsOf(image: MessageImageSpec): { readonly width: number; readon * preview. A lone image renders at its `singleFit` size; an image among * several renders as a fixed 64px square tile. The preview arm displays its * local URL directly — no loader round-trip, no failure/retry surface. + * List thumbnails use status icons with localized tooltips and accessible names. * * @param props.image - the durable reference to load, or the local preview to display. * @param props.load - session-authorized URL loader for the durable arm. - * @param props.variant - `single` for a message's lone image, `tile` otherwise. + * @param props.variant - lone image, cropped gallery tile, or uncropped list thumbnail. * @param props.labels - resolved strings (tooltip, loading, retry, lightbox). * @returns the bounded thumbnail button, or the retry control on failure. */ export function MessageImage({ image, load, variant, labels }: { image: MessageImageSpec load: ImageLoader - variant: 'single' | 'tile' + variant: 'single' | 'tile' | 'thumbnail' labels: MessageImageLabels }) { const preview = 'preview' in image ? image.preview : undefined @@ -116,8 +122,23 @@ export function MessageImage({ image, load, variant, labels }: { }, [attachment, load, attempt]) const src = preview?.url ?? loaded - const label = (preview?.name ?? attachment?.name) ?? labels.image - if (error) return + const label = ('attachment' in image ? image.label : undefined) + ?? preview?.name ?? attachment?.name ?? labels.image + const loadingThumbnail = variant === 'thumbnail' && src === null + if (error) return ( + + ) return ( <> {open && src !== null && } @@ -139,16 +165,17 @@ export function MessageImage({ image, load, variant, labels }: { } /** Wrapping image group shared by user and assistant history: a lone image - * renders large unless its owning mixed-attachment row requests compact tiles. */ -export function ImageGallery({ images, load, align, compact = false, labels }: { + * renders large unless its owner requests compact tiles or contained list thumbnails. */ +export function ImageGallery({ images, load, align, compact = false, thumbnail = false, labels }: { images: readonly MessageImageSpec[] load: ImageLoader align: 'start' | 'end' compact?: boolean + thumbnail?: boolean labels: MessageImageLabels }) { if (images.length === 0) return null - const variant = compact || images.length > 1 ? 'tile' : 'single' + const variant = thumbnail ? 'thumbnail' : compact || images.length > 1 ? 'tile' : 'single' return (
{images.map((image, index) => ( diff --git a/packages/client/ui-attachment/src/client/MessageImages.tsx b/packages/client/ui-attachment/src/client/MessageImages.tsx index 92cdaa455b..192147f877 100644 --- a/packages/client/ui-attachment/src/client/MessageImages.tsx +++ b/packages/client/ui-attachment/src/client/MessageImages.tsx @@ -3,13 +3,14 @@ import { ImageGallery } from '../MessageImage.tsx' import { messageImageLabels } from './labels.ts' /** Historical message-image slot entry. */ -export function MessageImages({ images, loadImage, align, compact = false, t }: MessageImagesProps) { +export function MessageImages({ images, loadImage, align, compact = false, thumbnail = false, t }: MessageImagesProps) { return ( ) diff --git a/packages/client/ui-attachment/tests/message-image.client.spec.tsx b/packages/client/ui-attachment/tests/message-image.client.spec.tsx index 3d3711a77a..ee14237ad5 100644 --- a/packages/client/ui-attachment/tests/message-image.client.spec.tsx +++ b/packages/client/ui-attachment/tests/message-image.client.spec.tsx @@ -2,7 +2,7 @@ import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, waitFor } from '@testing-library/react' +import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react' import { AttachmentId } from '@deepseek-ai/dsh-attachment' import { EMPTY_CHAT_SNAPSHOT, type MessageImagesProps } from '@deepseek-ai/dsh-client-ui-chat/client' import { EMPTY_CONVERSATION_SNAPSHOT } from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -144,11 +144,56 @@ describe('MessageImage', () => { expect(frame.getAttribute('style')).toBeNull() }) - it('keeps the tile variant on the failed-load retry control', async () => { + it.each(['tile', 'thumbnail'] as const)('keeps the %s variant on the failed-load retry control', async (variant) => { const load = vi.fn().mockRejectedValue(new Error('offline')) - const view = render() + const view = render() const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' }) - expect(retry.getAttribute('data-variant')).toBe('tile') + expect(retry.getAttribute('data-variant')).toBe(variant) + }) + + it.each([ + ['Chinese', labels], + ['English', { ...labels, loading: 'Loading image…', loadFailed: 'Image failed to load; click to retry' }], + ] as const)('labels thumbnail status icons and retries a failed read in %s', async (_locale, statusLabels) => { + const pending = Promise.withResolvers() + const load = vi.fn().mockReturnValueOnce(pending.promise).mockResolvedValue('blob:retry') + const view = render() + const loading = view.getByRole('button', { name: statusLabels.loading }) + expect(loading.getAttribute('title')).toBe(statusLabels.loading) + expect(loading.getAttribute('aria-busy')).toBe('true') + expect(loading.textContent).toBe('') + expect(loading.querySelector('svg')).not.toBeNull() + + await act(async () => { pending.reject(new Error('offline')) }) + const retry = view.getByRole('button', { name: statusLabels.loadFailed }) + expect(retry.getAttribute('title')).toBe(statusLabels.loadFailed) + expect(retry.textContent).toBe('') + expect(retry.querySelector('svg')).not.toBeNull() + fireEvent.click(retry) + await view.findByAltText(attachment.name) + const opener = view.getByRole('button', { name: statusLabels.openNamed(attachment.name) }) + expect(opener.getAttribute('aria-busy')).toBeNull() + expect(opener.getAttribute('title')).toBe(statusLabels.open) + expect(load).toHaveBeenCalledTimes(2) + expect(load).toHaveBeenLastCalledWith(attachment) + }) + + it('uses the owner display name for an unnamed thumbnail and its original-image viewer', async () => { + const { name: _name, ...unnamed } = attachment + const load = vi.fn().mockRejectedValueOnce(new Error('offline')).mockResolvedValue('blob:thumbnail') + const view = render() + fireEvent.click(await view.findByRole('button', { name: labels.loadFailed })) + const image = await view.findByAltText('Image 2') + expect(image.closest('button')?.getAttribute('data-variant')).toBe('thumbnail') + const opener = view.getByRole('button', { name: 'Image 2,点击查看原图' }) + opener.focus() + fireEvent.click(opener) + expect(view.getByRole('dialog')).toBeTruthy() + fireEvent.keyDown(window, { key: 'Escape' }) + expect(view.queryByRole('dialog')).toBeNull() + expect(document.activeElement).toBe(opener) + expect(load).toHaveBeenLastCalledWith(unnamed) + expect(unnamed).not.toHaveProperty('name') }) it('ignores a load settling after unmount', async () => { diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 73a82ebe58..0adc73f139 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 23690a0c71f9a3b81639fa763c6a095e1bf93011 -README.zh.md: 251fa9c6513773cfca3e092742a045b0ac57030c +README.md: c611304e5a359ef5556b232ea9c3236a95f56897 +README.zh.md: fad2daa506414d731936f3a60ed7278f92eaae94 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 23690a0c71..c611304e5a 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -36,6 +36,8 @@ Target packages declaration-merge their snapshot and Location data maps, then re ## Shell and standard props +The shared image slot props keep display choices separate from durable references: `thumbnail` requests a contained attachment-list thumbnail, while `compact` requests a cropped gallery tile. An optional per-image `label` supplies the accessible display name; loading and cache identity still use the original attachment reference. [ui-attachment](../ui-attachment/README.md) owns rendering and the lightbox. + The context-occupancy button shows a ring and percentage below the input card, after the Session statistics. Clicking it opens the token breakdown in a panel kept inside the viewport, including when no statistics are shown; the button stays hidden until context usage and capacity are available. The composer registers the File command action and owns its label, availability, and native file-dialog callback. Menu availability and invocation both consult the mounted composer's current attachment-intake policy. Unmounting or locking the composer disables that action; disposing the plugin removes its registration. The callback binding stays inside the input module. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 251fa9c651..fad2daa506 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -36,6 +36,8 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map, ## Shell 与标准 props +共享图片插槽属性将展示选择与持久化引用分开:`thumbnail` 请求完整缩放的附件列表缩略图,`compact` 请求裁剪的图片方块。每张图片可通过可选的 `label` 提供无障碍展示名称;加载和缓存标识仍使用原始附件引用。[ui-attachment](../ui-attachment/README.zh.md) 负责渲染与灯箱。 + 上下文占用按钮在输入卡片下方、会话统计右侧显示圆环和百分比。点击按钮可在视口内的面板查看 token 构成,没有统计项时面板也不会越界;上下文用量和容量尚不可用时,按钮保持隐藏。 输入框注册「文件」命令动作,负责其标题、可用性和原生文件选择器回调。菜单可用性与实际调用都读取已挂载输入框当前的附件接收策略。输入框卸载或锁定后该动作不可用,插件 dispose(资源释放)时移除注册。回调绑定留在输入模块内部。 diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index d6abe6c657..3c85e5e1ef 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -77,7 +77,11 @@ export interface ComposerAttachmentsOwnerProps { * local preview of a submission echo whose admission is still in flight. */ export type MessageImageSource = - | { readonly attachment: ImageAttachmentRef } + | { + readonly attachment: ImageAttachmentRef + /** Presentation-only name for the thumbnail and lightbox; loading uses the original reference. */ + readonly label?: string + } | { readonly preview: { /** Browser-owned preview URL (lifecycle stays with the submitter). */ @@ -105,6 +109,8 @@ export interface MessageImagesOwnerProps { align: 'start' | 'end' /** Force every image into the compact message-attachment tile size. */ compact?: boolean + /** Fixed, uncropped thumbnail for an attachment list row. */ + thumbnail?: boolean } /** Slot-backed renderer used by Conversation targets without importing an attachment implementation. */ diff --git a/packages/client/ui-trajectory/README.i18n.yaml b/packages/client/ui-trajectory/README.i18n.yaml index e930caa64a..8ab91c8e9f 100644 --- a/packages/client/ui-trajectory/README.i18n.yaml +++ b/packages/client/ui-trajectory/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-trajectory/README.md -README.md: 02cbf402a702a4a51e9e3a122e3df13c4eb4e772 -README.zh.md: 00971c0710e1620b97fc51ae03486a9114629a49 +README.md: b58466f0c38efef89ff3aa2b5d6da49b6c85ac86 +README.zh.md: ab9ecbfd26478c30c9078a25f92bd121245b7bf9 diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md index 02cbf402a7..b58466f0c3 100644 --- a/packages/client/ui-trajectory/README.md +++ b/packages/client/ui-trajectory/README.md @@ -31,7 +31,9 @@ Open the Trajectory tab in the conversation's view ring to inspect agent activit Calls are identified by their recorded tool name. For `run_code`, the row shows the program description and the inspector opens numbered, highlighted source. The Code tab provides wrapping, a `{}` toggle for the original JSON arguments, and exact source copying, including trailing newlines. Copying the original arguments retains their recorded JSON whitespace. Each newly opened code view takes the last wrapping choice; changing it leaves other open views as they are. Output preserves the recorded text, using a tree for complete JSON objects or arrays. Highlighting uses only an unambiguous TypeScript or Python hint in the recorded tool schema; missing or conflicting hints leave plain source. See the [PTC inspection decision](../../../.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.md) for replay constraints. -Selection, timeline navigation, folding, and search cover the React-visible window. Request numbers and cumulative usage cover the complete resident snapshot. Selecting a record opens a local inspector for token usage, duration, Input, Output, Timing, and durable images. Image URLs use the Conversation-owned per-session cache, so Chat and Trajectory share one authorized read per attachment. A user record shows the generic-file count beside its text, while a record without text shows its image and file counts. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. +Selection, timeline navigation, folding, and search cover the React-visible window. Request numbers and cumulative usage cover the complete resident snapshot. Selecting a record opens a local inspector for token usage, duration, Input, Output, Timing, and durable images. Image URLs use the Conversation-owned per-session cache, so Chat and Trajectory share one authorized read per attachment. A user record shows both nonzero image and ordinary-file counts beside its text, including attachment-only records. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. + +Summary and Preview share one ordered attachment list after the message text, preserving repeated references. Each row shows a contained image thumbnail or file-type icon, the recorded filename (a localized numbered label for unnamed images), and recorded size, type, and image dimensions where available. Zero-byte files retain their size, and truncated filenames expose the full name in a tooltip. Images open the existing lightbox. Raw keeps content-block order and unrendered text, with images and files in initially collapsed disclosures containing their complete recorded fields. Thinking in the inspector uses compact Markdown at the inspector's fixed 13px size and 20px line height, independent of the content-size setting. Headings add bold weight without increasing size or line height. Assistant output keeps its regular Markdown typography. diff --git a/packages/client/ui-trajectory/README.zh.md b/packages/client/ui-trajectory/README.zh.md index 00971c0710..ab9ecbfd26 100644 --- a/packages/client/ui-trajectory/README.zh.md +++ b/packages/client/ui-trajectory/README.zh.md @@ -31,7 +31,9 @@ Trajectory 标签页让你以按轮次组织的事件记录表和交互式时间 调用按已记录的工具名识别。对于 `run_code`,记录行展示程序说明,检查器显示带行号和语法高亮的源码。“代码”页提供换行、查看原始 JSON 参数的 `{}` 切换按钮,以及保留末尾换行的源码原样复制。复制原始参数时保留记录中 JSON 的空白。每个新打开的代码视图采用上次选择的换行状态;切换时,其他已打开视图保持原状。输出保留记录中的文本,完整 JSON 对象或数组使用树形展示。高亮只采用已记录工具 Schema 中明确且无冲突的 TypeScript 或 Python 提示;提示缺失或冲突时显示纯文本源码。回放约束见 [PTC 检查决策](../../../.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.zh.md)。 -选择、时间线导航、折叠与搜索只覆盖 React 可见窗口。请求编号与累计用量覆盖完整的驻留快照。选择记录会打开局部检查器,查看 token 用量、耗时、输入、输出、计时与持久保留的图片。图片 URL 使用 Conversation 拥有的逐会话缓存,因此 Chat 与 Trajectory 对每个附件共享一次已授权读取。用户记录会在文本旁显示通用文件数量;记录没有文本时,则显示图片与文件数量。独立运行的压缩请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。 +选择、时间线导航、折叠与搜索只覆盖 React 可见窗口。请求编号与累计用量覆盖完整的驻留快照。选择记录会打开局部检查器,查看 token 用量、耗时、输入、输出、计时与持久保留的图片。图片 URL 使用 Conversation 拥有的逐会话缓存,因此 Chat 与 Trajectory 对每个附件共享一次已授权读取。用户记录会在文本旁同时显示非零的图片与普通文件数量,仅含附件的记录也显示这些数量。独立运行的压缩请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。 + +概述与预览共用正文之后的有序附件列表,保留重复引用。每行展示完整缩放的图片缩略图或文件类型图标、已记录的文件名(未命名图片使用本地化的编号名称),以及可用的已记录大小、类型和图片尺寸。零字节文件保留大小显示,截断的文件名通过提示显示完整名称。图片打开现有灯箱。原始内容保留内容块顺序和未渲染文本,图片与文件使用默认折叠的展开项,包含完整的已记录字段。 检查器中的思考内容使用紧凑 Markdown,沿用检查器固定的 13px 字号和 20px 行高,不受内容字号设置影响。标题只加粗,不放大字号或行高。助手输出保留常规 Markdown 排版。 diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css index a679c82569..520b71c4da 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css @@ -1710,11 +1710,88 @@ gap: 8px; } -.messageImagesPreview { - margin: 6px 14px 8px; +.attachments { + display: flex; + flex-direction: column; + margin: 8px 14px 14px; + padding: 0; + gap: 8px; + list-style: none; +} + +.attachmentsPreview { + margin-block: 6px 8px; gap: 6px; } +.attachmentRow { + display: flex; + align-items: center; + min-width: 0; + padding: 8px; + gap: 10px; + border: 0.5px solid var(--dsw-alias-border-l2-darkmode-thin); + border-radius: 12px; +} + +.attachmentIcon { + display: grid; + flex: 0 0 48px; + width: 48px; + height: 48px; + place-items: center; +} + +.attachmentInfo { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; + gap: 3px; +} + +.attachmentName { + overflow: hidden; + color: var(--dsw-alias-label-primary); + font-size: 12px; + line-height: 18px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.attachmentMetadata { + overflow: hidden; + color: var(--dsw-alias-label-tertiary); + font-size: 11px; + line-height: 16px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.attachmentDisclosure { + margin-block: 8px; + border: 0.5px solid var(--dsw-alias-border-l2-darkmode-thin); + border-radius: 8px; +} + +.attachmentDisclosure > summary { + padding: 8px; + cursor: pointer; +} + +.attachmentDisclosure > summary .attachmentName { + display: block; +} + +.attachmentDisclosure > summary:focus-visible { + outline: 2px solid var(--dsw-alias-state-business-primary); + outline-offset: 2px; +} + +.attachmentDisclosure > pre { + padding: 0 8px 8px; +} + .assistantToolCalls { box-sizing: border-box; max-width: 100%; diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index c5ae3ad812..630d0bebfe 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -5,6 +5,9 @@ import type { CSSProperties, ReactNode } from 'react' import { useVirtualizer } from '@tanstack/react-virtual' import { CodeBlock, + FileTypeIcon, + fileExtension, + fileSizeText, IconCheckOutline16, IconChevronRightOutline14, IconCodeOutline16, @@ -20,7 +23,6 @@ import { } from '@deepseek-ai/dsh-client-ui-primitives' import type { JsonTreeLabels, JsonTreeProps, MarkdownLabels } from '@deepseek-ai/dsh-client-ui-primitives' import { structuredPatch } from 'diff' -import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { AssistantRequestConfig, ConversationPromptSnapshot, RenderMessageImages, } from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -1157,75 +1159,120 @@ function MarkdownFragment({ function SourceBlocks({ blocks, onOpenCall, - renderImages, t, }: { blocks: readonly TrajectorySourceBlock[] onOpenCall: (callId: string) => void - renderImages: RenderMessageImages t: TrajectoryTranslate }) { + const attachments = new Map(recordAttachments(blocks, t).map(entry => [entry.index, entry])) return (
- {blocks.map((block, index) => ( -
- {block.callId !== undefined - ? ( - - ) - : ( -
- - {t('block.label', { index: index + 1, type: block.type })} - -
- )} - {/* The Raw view keeps model block order and granularity: one - gallery per image block, unlike the aggregated record gallery. */} - {block.attachment !== undefined - ? renderImages({ images: [{ attachment: block.attachment }], align: 'start' }) - :
{block.content}
} -
- ))} + {blocks.map((block, index) => { + const attachment = attachments.get(index) + return attachment !== undefined ? ( +
+ + + {t('block.label', { index: index + 1, type: block.type })} + + + {attachment.name} + + +
{block.content}
+
+ ) + : ( +
+ {block.callId !== undefined + ? ( + + ) + : ( +
+ + {t('block.label', { index: index + 1, type: block.type })} + +
+ )} +
{block.content}
+
+ ) + })}
) } -function recordImages( +function recordAttachments( blocks: readonly TrajectorySourceBlock[] | undefined, -): { readonly attachment: ImageAttachmentRef }[] { - return (blocks ?? []).flatMap(block => - block.attachment !== undefined ? [{ attachment: block.attachment }] : []) + t: TrajectoryTranslate, +) { + let imageIndex = 0 + return (blocks ?? []).flatMap((block, index) => { + const ref = block.attachment ?? block.file + if (ref === undefined) return [] + if (block.attachment !== undefined) imageIndex += 1 + return [{ + block, + index, + name: ref.name ?? t('attachment.imageName', { index: imageIndex }), + metadata: [ + block.attachment?.mediaType ?? fileExtension(ref.name ?? '').toUpperCase(), + fileSizeText(ref.bytes), + ...(block.attachment === undefined ? [] : [`${block.attachment.width} × ${block.attachment.height}`]), + ].filter(Boolean).join(' · '), + }] + }) } -function MessageImages({ +function RecordAttachments({ blocks, preview, renderImages, + t, }: { blocks: readonly TrajectorySourceBlock[] | undefined preview: boolean renderImages: RenderMessageImages + t: TrajectoryTranslate }) { - const images = recordImages(blocks) - if (images.length === 0) return null + const attachments = recordAttachments(blocks, t) + if (attachments.length === 0) return null return ( -
- {renderImages({ images, align: 'start' })} -
+
    + {attachments.map(({ block, index, name, metadata }) => ( +
  • + {block.attachment !== undefined + ? renderImages({ + images: [{ attachment: block.attachment, label: name }], + align: 'start', + thumbnail: true, + }) + : } +
    + {name} + {metadata} +
    +
  • + ))} +
) } @@ -1488,7 +1535,6 @@ function MarkdownRecordContent({ ) @@ -1544,25 +1590,26 @@ function MarkdownRecordContent({ onOpenCall={onOpenCall} t={t} /> -
) } const source = markdownSource(record) - const hasImages = record.cell.sourceBlocks?.some(block => block.attachment !== undefined) === true + const hasAttachments = record.cell.sourceBlocks?.some(block => block.attachment !== undefined || block.file !== undefined) === true const hasToolCalls = record.cell.kind === 'message' && record.cell.sourceBlocks?.some(block => block.type === 'tool-call') === true - if (!source && !hasImages && !hasToolCalls) { + if (!source && !hasAttachments && !hasToolCalls) { const emptyLabel = isToolCallOnly(record.cell, t) ? t('record.toolCallOnly') : record.cell.text || t('record.noContent') return

{emptyLabel}

} - if (!rendered || (!hasImages && !hasToolCalls)) { + if (!rendered || (!hasAttachments && !hasToolCalls)) { return } return ( @@ -1576,7 +1623,7 @@ function MarkdownRecordContent({ t={t} /> )} - + ) } diff --git a/packages/client/ui-trajectory/src/client/layout.ts b/packages/client/ui-trajectory/src/client/layout.ts index 7691b7f74a..e99b96a962 100644 --- a/packages/client/ui-trajectory/src/client/layout.ts +++ b/packages/client/ui-trajectory/src/client/layout.ts @@ -12,7 +12,7 @@ import type { ToolCallBlock, ToolResultNode, } from '@deepseek-ai/dsh-client-ui-conversation/client' -import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { TrajectoryCellProps, TrajectorySourceBlock, @@ -122,15 +122,13 @@ function inputCellDetail(node: InputNode, t: TrajectoryTranslate): Pick< | 'timeSeconds' | 'startedAt' > { - // An empty text block yields an empty preview; treat it as absent so an - // image-bearing record still labels its row instead of rendering blank. const preview = previewContent(node.content) const previewMarkdown = preview === '' ? undefined : preview const images = imageBlockCount(node.content) const files = fileBlockCount(node.content) const attachmentSummary = [ - previewMarkdown === undefined && images > 0 - ? t('layout.imageOnly', { count: images }) + images > 0 + ? t('layout.imageCount', { count: images }) : undefined, files > 0 ? t('layout.fileAttachments', { count: files }) : undefined, ].filter((value): value is string => value !== undefined).join(' · ') @@ -808,7 +806,7 @@ function summarizeAssistantActivity( return t('layout.toolCallOnly') } const images = blocks.filter(block => block.kind === 'image').length - if (images > 0) return t('layout.imageOnly', { count: images }) + if (images > 0) return t('layout.imageCount', { count: images }) return '' } @@ -829,7 +827,7 @@ function assistantSourceBlock(block: AssistantBlock): TrajectorySourceBlock { callId: block.callId, toolName: block.name, } - case 'image': return { type: 'image', content: '', attachment: block.attachment } + case 'image': return sourceBlock({ type: 'image', attachment: block.attachment }) case 'other': return sourceBlock(block.block) } } @@ -844,14 +842,20 @@ function sourceBlock(value: unknown): TrajectorySourceBlock { return { type: type === 'reasoning' ? 'thinking' : type, content: block.text } } if ( - type === 'image' + (type === 'image' || type === 'file') && typeof block.attachment === 'object' && block.attachment !== null && typeof (block.attachment as Record).attachmentId === 'string' ) { // Session-log content is validated into core ContentBlocks by the // Conversation node assembly; the `attachmentId` guard only keeps // wire-shaped 'other' blocks with an unrelated `attachment` member out. - return { type, content: '', attachment: block.attachment as ImageAttachmentRef } + return { + type, + content: stringifySourceValue(value), + ...(type === 'image' + ? { attachment: block.attachment as ImageAttachmentRef } + : { file: block.attachment as FileAttachmentRef }), + } } return { type, content: stringifySourceValue(value) } } @@ -1083,7 +1087,7 @@ function summarizeResult( } } const images = imageBlockCount(node.content) - if (images > 0) return { result: t('layout.imageOnly', { count: images }) } + if (images > 0) return { result: t('layout.imageCount', { count: images }) } return { result: t('record.noOutput') } } @@ -1110,7 +1114,7 @@ function detailResult(node: ToolResultNode, t: TrajectoryTranslate): string { .join('\n') if (text !== '') return text const images = imageBlockCount(node.content) - if (images > 0) return t('layout.imageOnly', { count: images }) + if (images > 0) return t('layout.imageCount', { count: images }) if ( node.content.length === 0 || node.content.every(block => diff --git a/packages/client/ui-trajectory/src/client/locales.ts b/packages/client/ui-trajectory/src/client/locales.ts index 7e76444ca1..2e891ff981 100644 --- a/packages/client/ui-trajectory/src/client/locales.ts +++ b/packages/client/ui-trajectory/src/client/locales.ts @@ -180,7 +180,9 @@ export const zh = { 'layout.compactionFailed': '上下文压缩失败', 'layout.compacted': '上下文已压缩', 'layout.toolCallOnly': '仅工具调用', - 'layout.imageOnly': '图片 ×{count}', + 'attachment.list': '附件', + 'attachment.imageName': '图片 {index}', + 'layout.imageCount': '图片 ×{count}', 'layout.fileAttachments': '文件 ×{count}', 'layout.initialSystemPrompt': '初始系统提示词', 'layout.systemPromptUpdated': '系统提示词已更新', @@ -380,7 +382,9 @@ export const en: Record = { 'layout.compactionFailed': 'Compaction failed', 'layout.compacted': 'Context compacted', 'layout.toolCallOnly': 'Tool call only', - 'layout.imageOnly': 'Images ×{count}', + 'attachment.list': 'Attachments', + 'attachment.imageName': 'Image {index}', + 'layout.imageCount': 'Images ×{count}', 'layout.fileAttachments': 'Files ×{count}', 'layout.initialSystemPrompt': 'Initial System Prompt', 'layout.systemPromptUpdated': 'System Prompt Updated', diff --git a/packages/client/ui-trajectory/src/client/trajectory-record.ts b/packages/client/ui-trajectory/src/client/trajectory-record.ts index 017143c487..bd37189fa4 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-record.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-record.ts @@ -1,7 +1,7 @@ /** Shared trajectory record data and formatting contracts. */ import type { HTMLAttributes } from 'react' -import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { ConversationPromptSnapshot } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { TrajectoryTranslate } from './locales.ts' @@ -30,6 +30,8 @@ export interface TrajectorySourceBlock { type: string content: string attachment?: ImageAttachmentRef + /** Ordinary file metadata; never passed to the image loader. */ + file?: FileAttachmentRef callId?: string toolName?: string } diff --git a/packages/client/ui-trajectory/tests/layout.client.spec.tsx b/packages/client/ui-trajectory/tests/layout.client.spec.tsx index f9130ab4f7..df27f38e65 100644 --- a/packages/client/ui-trajectory/tests/layout.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/layout.client.spec.tsx @@ -592,8 +592,8 @@ describe('durable image attachments', () => { expect(user?.text).toBe('Images ×2') expect(user?.previewMarkdown).toBeUndefined() expect(user?.sourceBlocks).toEqual([ - { type: 'image', content: '', attachment }, - { type: 'image', content: '', attachment }, + { type: 'image', content: JSON.stringify({ type: 'image', attachment }, null, 2), attachment }, + { type: 'image', content: JSON.stringify({ type: 'image', attachment }, null, 2), attachment }, ]) }) @@ -619,9 +619,9 @@ describe('durable image attachments', () => { ] as unknown as LegacyConversationSlice['nodes'] const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) const user = turns[0]?.groups[0]?.cells[0] - expect(user?.text).toBe('') + expect(user?.text).toBe('Images ×1') expect(user?.previewMarkdown).toBe('look at this') - expect(user?.sourceBlocks?.[1]).toEqual({ type: 'image', content: '', attachment }) + expect(user?.sourceBlocks?.[1]).toEqual({ type: 'image', content: JSON.stringify({ type: 'image', attachment }, null, 2), attachment }) }) it('maps assistant image blocks to attachment source blocks and labels image-only output', () => { @@ -634,7 +634,7 @@ describe('durable image attachments', () => { const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) const message = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'message') expect(message?.text).toBe('Images ×1') - expect(message?.sourceBlocks).toEqual([{ type: 'image', content: '', attachment }]) + expect(message?.sourceBlocks).toEqual([{ type: 'image', content: JSON.stringify({ type: 'image', attachment }, null, 2), attachment }]) }) it('carries tool-result image refs into outputBlocks and labels the result', () => { @@ -653,7 +653,7 @@ describe('durable image attachments', () => { const tool = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'tool') expect(tool?.result).toBe('Images ×1') expect(tool?.outputDetail).toBe('Images ×1') - expect(tool?.outputBlocks).toEqual([{ type: 'image', content: '', attachment }]) + expect(tool?.outputBlocks).toEqual([{ type: 'image', content: JSON.stringify({ type: 'image', attachment }, null, 2), attachment }]) }) it('shows wire-shaped blocks without an attachment as JSON, not as an image', () => { @@ -677,7 +677,7 @@ describe('durable file attachments', () => { bytes: 2447 * 1024 * 1024, } - it('labels file-only and mixed-text user records without rendering file cards', () => { + it('labels file-only and mixed-text user records', () => { const nodes = [ { kind: 'user', seq: 1, time: 1_000, source: null, @@ -696,7 +696,7 @@ describe('durable file attachments', () => { expect(users[1]).toMatchObject({ text: 'Files ×1', previewMarkdown: 'review these' }) }) - it('keeps image and file counts in a file-only attachment summary', () => { + it.each([undefined, '', 'review these'])('keeps both counts and repeated attachments with text %j', (text) => { const image = { attachmentId: `sha256:${'e'.repeat(64)}`, mediaType: 'image/png', @@ -706,9 +706,22 @@ describe('durable file attachments', () => { } const nodes = [{ kind: 'user', seq: 1, time: 1_000, source: null, - content: [{ type: 'image', attachment: image }, { type: 'file', attachment }], + content: [ + ...(text === undefined ? [] : [{ type: 'text', text }]), + { type: 'image', attachment: image }, + { type: 'file', attachment }, + { type: 'image', attachment: image }, + ], }] as unknown as LegacyConversationSlice['nodes'] const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) - expect(turns[0]?.groups[0]?.cells[0]?.text).toBe('Images ×1 · Files ×1') + const cell = turns[0]?.groups[0]?.cells[0] + expect(cell?.text).toBe('Images ×2 · Files ×1') + const blocks = cell?.sourceBlocks?.filter(block => block.type !== 'text') + expect(blocks?.map(block => block.attachment ?? block.file)).toEqual([image, attachment, image]) + expect(blocks?.map((block): unknown => JSON.parse(block.content))).toEqual([ + { type: 'image', attachment: image }, + { type: 'file', attachment }, + { type: 'image', attachment: image }, + ]) }) }) diff --git a/packages/client/ui-trajectory/tests/table.client.spec.tsx b/packages/client/ui-trajectory/tests/table.client.spec.tsx index 503e19eab7..fd9042729a 100644 --- a/packages/client/ui-trajectory/tests/table.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/table.client.spec.tsx @@ -2,7 +2,8 @@ /** Trajectory ledger selection, details, status, and fold behavior. */ import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' import type { ComponentProps } from 'react' import type { RenderMessageImages } from '@deepseek-ai/dsh-client-ui-conversation/client' import { TrajectoryTable as LocalizedTrajectoryTable } from '../src/client/TrajectoryTable.tsx' @@ -1038,47 +1039,60 @@ describe('TrajectoryTable', () => { expect(screen.getByText('value:')).toBeTruthy() }) - it('renders user image attachments through the shared gallery in the details panel', () => { - const attachment = { - attachmentId: `sha256:${'a'.repeat(64)}`, - mediaType: 'image/png', - bytes: 68, - width: 640, - height: 320, - name: 'screenshot.png', - } as unknown as NonNullable< - NonNullable[number]['attachment'] - > - const turns: readonly TrajectoryTurnModel[] = [{ - turn: 1, - groups: [{ - title: 'Message', - cells: [{ - index: 1, - kind: 'user', - text: 'Images ×2', - sourceBlocks: [ - { type: 'image', content: '', attachment }, - { type: 'image', content: '', attachment }, - ], - timeSeconds: 0, - }], - }], - }] - - render() - fireEvent.click(screen.getByRole('row', { name: /USER/ })) - - const preview = screen.getAllByTestId('record-images') - expect(preview.length).toBeGreaterThan(0) - expect(preview[0]?.getAttribute('data-count')).toBe('2') - - fireEvent.click(screen.getByRole('tab', { name: 'Raw' })) - const rawGalleries = screen.getAllByTestId('record-images') - expect(rawGalleries).toHaveLength(2) - expect(rawGalleries[0]?.querySelector('[data-attachment-id]')?.getAttribute('data-attachment-id')) - .toBe(String(attachment.attachmentId)) - }) + it.each([['English', t, 'Attachments', 'Image 1', 'Summary', 'Preview', 'Raw'], + ['Chinese', tZh, '附件', '图片 1', '概述', '预览', '原始内容']] as const)( + 'keeps mixed attachments ordered and raw fields complete in %s', + (_locale, translate, listLabel, imageLabel, summaryTab, previewTab, rawTab) => { + const attachment = { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType: 'image/png' as const, + bytes: 68, + width: 40, + height: 800, + originalDimensions: { width: 400, height: 8000 }, + } + const file = { + attachmentId: AttachmentId(`sha256:${'b'.repeat(64)}`), + name: `${'long-filename-'.repeat(12)}.txt`, + bytes: 0, + } + const content = [ + { type: 'text' as const, text: '**before**' }, + { type: 'image' as const, attachment }, + { type: 'text' as const, text: 'after' }, + { type: 'file' as const, attachment: file }, + { type: 'image' as const, attachment }, + ] + const turns = deriveTrajectoryLayout({ + nodes: [{ kind: 'user', seq: 1, time: 1000, source: { kind: 'user' }, content }], + partial: null, + runningCalls: [], + }, translate) + const renderImages = vi.fn(renderImagesStub) + const view = render() + fireEvent.click(view.container.querySelector('[data-trajectory-row-key]')!) + for (const tab of [summaryTab, previewTab]) { + fireEvent.click(screen.getByRole('tab', { name: tab })) + const list = screen.getByRole('list', { name: listLabel }) + const names = [...list.querySelectorAll('[title]')].map(el => el.getAttribute('title')) + expect(names).toEqual([imageLabel, file.name, imageLabel.replace('1', '2')]) + expect(within(list).getByText('TXT · 0B')).toBeTruthy() + expect(within(list).getAllByText('image/png · 68B · 40 × 800')).toHaveLength(2) + expect(renderImages).toHaveBeenCalledWith({ images: [{ attachment, label: imageLabel }], align: 'start', thumbnail: true }) + } + fireEvent.click(screen.getByRole('tab', { name: rawTab })) + expect(screen.queryByTestId('record-images')).toBeNull() + const disclosures = [...view.container.querySelectorAll('details')] + expect(disclosures).toHaveLength(3) + expect(disclosures.every(el => !el.open)).toBe(true) + expect(disclosures.map((el): unknown => JSON.parse(el.querySelector('pre')!.textContent))) + .toEqual([content[1], content[3], content[4]]) + const blocks = [...disclosures[0]!.parentElement!.children] + expect(blocks.map(el => el.tagName)).toEqual(['SECTION', 'DETAILS', 'SECTION', 'DETAILS', 'DETAILS']) + expect(blocks[0]!.querySelector('pre')!.textContent).toBe('**before**') + expect(blocks[2]!.querySelector('pre')!.textContent).toBe('after') + }, + ) it('renders a tool-result image through the shared gallery in the Result tab', () => { const attachment = { diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index f7d1c83933..d6005d86c1 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -406,7 +406,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer\', () => ctx.slots.register(\n { name: \'conversation.composer\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:170', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:176', }, { key: 'conversation.composer.bar', @@ -445,7 +445,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.bar\', () => ctx.slots.register(\n { name: \'conversation.composer.bar\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:188', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:194', }, { key: 'conversation.composer.dock', @@ -501,7 +501,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.dock\', () => ctx.slots.register(\n { name: \'conversation.composer.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:182', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:188', }, { key: 'conversation.hero.agentPreset', @@ -538,7 +538,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.agentPreset\', () => ctx.slots.register(\n { name: \'conversation.hero.agentPreset\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:176', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:182', }, { key: 'conversation.hero.brand.mark', @@ -567,7 +567,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.brand.mark\', () => ctx.slots.register(\n { name: \'conversation.hero.brand.mark\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:174', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:180', }, { key: 'conversation.hero.workspace', @@ -600,7 +600,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.workspace\', () => ctx.slots.register(\n { name: \'conversation.hero.workspace\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:172', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:178', }, { key: 'conversation.hero.workspace.directoryFlow', @@ -673,7 +673,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.attachments\', () => ctx.slots.register(\n { name: \'conversation.input.attachments\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:190', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:196', }, { key: 'conversation.input.dock', @@ -736,7 +736,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.dock\', () => ctx.slots.register(\n { name: \'conversation.input.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:178', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:184', }, { key: 'conversation.input.left', @@ -790,7 +790,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.left\', () => ctx.slots.register(\n { name: \'conversation.input.left\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:184', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:190', }, { key: 'conversation.input.model', @@ -829,7 +829,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.model\', () => ctx.slots.register(\n { name: \'conversation.input.model\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:200', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:206', }, { key: 'conversation.input.overlay', @@ -887,7 +887,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.overlay\', () => ctx.slots.register(\n { name: \'conversation.input.overlay\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:180', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:186', }, { key: 'conversation.input.permission', @@ -926,7 +926,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.permission\', () => ctx.slots.register(\n { name: \'conversation.input.permission\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:198', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:204', }, { key: 'conversation.input.plan', @@ -965,7 +965,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.plan\', () => ctx.slots.register(\n { name: \'conversation.input.plan\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:196', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:202', }, { key: 'conversation.input.right', @@ -1019,7 +1019,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.right\', () => ctx.slots.register(\n { name: \'conversation.input.right\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:186', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:192', }, { key: 'conversation.message.images', @@ -1029,7 +1029,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ doc: 'Renderer for one consecutive group of durable message images. The owner\nsupplies image references, an authorized loader, and alignment. A\nregistration replaces the shipped gallery; without one, images are omitted.', registerOptions: [], ownerProps: [ - '/** Message image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n /** Durable references or submission-echo previews in source order. */\n images: readonly MessageImageSource[]\n /** Session-authorized image URL loader for the durable arm. */\n loadImage: MessageImageLoader\n /** Horizontal placement inside the owning record. */\n align: \'start\' | \'end\'\n /** Force every image into the compact message-attachment tile size. */\n compact?: boolean\n}', + '/** Message image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n /** Durable references or submission-echo previews in source order. */\n images: readonly MessageImageSource[]\n /** Session-authorized image URL loader for the durable arm. */\n loadImage: MessageImageLoader\n /** Horizontal placement inside the owning record. */\n align: \'start\' | \'end\'\n /** Force every image into the compact message-attachment tile size. */\n compact?: boolean\n /** Fixed, uncropped thumbnail for an attachment list row. */\n thumbnail?: boolean\n}', ], ownerPropsReferences: [ 'Message', @@ -1157,7 +1157,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session\', () => ctx.slots.register(\n { name: \'conversation.session\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:123', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:129', }, { key: 'conversation.session.header', @@ -1194,7 +1194,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header\', () => ctx.slots.register(\n { name: \'conversation.session.header\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:125', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:131', }, { key: 'conversation.session.header.actions', @@ -1256,7 +1256,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.actions\', () => ctx.slots.register(\n { name: \'conversation.session.header.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:133', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:139', }, { key: 'conversation.session.header.corner', @@ -1295,7 +1295,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.corner\', () => ctx.slots.register(\n { name: \'conversation.session.header.corner\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:162', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:168', }, { key: 'conversation.session.header.leading', @@ -1334,7 +1334,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.leading\', () => ctx.slots.register(\n { name: \'conversation.session.header.leading\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:151', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:157', }, { key: 'conversation.session.header.lineage', @@ -1375,7 +1375,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.lineage\', () => ctx.slots.register(\n { name: \'conversation.session.header.lineage\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:127', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:133', }, { key: 'conversation.session.header.utilities', @@ -1434,7 +1434,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.utilities\', () => ctx.slots.register(\n { name: \'conversation.session.header.utilities\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:139', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:145', }, { key: 'conversation.trajectory.images', @@ -1444,7 +1444,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ doc: 'Renderer for one group of durable record images in the Trajectory\nledger. The owner supplies image references, an authorized loader, and\nalignment. A registration replaces the shipped gallery; without one,\nimages are omitted.', registerOptions: [], ownerProps: [ - '/** Message image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n /** Durable references or submission-echo previews in source order. */\n images: readonly MessageImageSource[]\n /** Session-authorized image URL loader for the durable arm. */\n loadImage: MessageImageLoader\n /** Horizontal placement inside the owning record. */\n align: \'start\' | \'end\'\n /** Force every image into the compact message-attachment tile size. */\n compact?: boolean\n}', + '/** Message image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n /** Durable references or submission-echo previews in source order. */\n images: readonly MessageImageSource[]\n /** Session-authorized image URL loader for the durable arm. */\n loadImage: MessageImageLoader\n /** Horizontal placement inside the owning record. */\n align: \'start\' | \'end\'\n /** Force every image into the compact message-attachment tile size. */\n compact?: boolean\n /** Fixed, uncropped thumbnail for an attachment list row. */\n thumbnail?: boolean\n}', ], ownerPropsReferences: [ 'Message', @@ -1538,7 +1538,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.view\', () => ctx.slots.register(\n { name: \'conversation.view\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:168', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:174', }, { key: 'main', @@ -1610,7 +1610,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'main.conversation\', () => ctx.slots.register(\n { name: \'main.conversation\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:121', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:127', }, { key: 'plugins.bundle.config', diff --git a/snapshots/web/file-upload-round/trajectory-status.expected.md b/snapshots/web/file-upload-round/trajectory-status.expected.md new file mode 100644 index 0000000000..585455d2e2 --- /dev/null +++ b/snapshots/web/file-upload-round/trajectory-status.expected.md @@ -0,0 +1,15 @@ +# Loading + +- list "Attachments": + - listitem: poem.txt TXT · 16B + - listitem: + - button "Loading image…" + - text: reference-1.png image/png · 69B · 1 × 1 + +# Failed + +- list "Attachments": + - listitem: poem.txt TXT · 16B + - listitem: + - button "Image failed to load; click to retry" + - text: reference-1.png image/png · 69B · 1 × 1 diff --git a/snapshots/web/file-upload-round/trajectory.expected.md b/snapshots/web/file-upload-round/trajectory.expected.md index 54cde508ec..855ff5429c 100644 --- a/snapshots/web/file-upload-round/trajectory.expected.md +++ b/snapshots/web/file-upload-round/trajectory.expected.md @@ -1,3 +1,39 @@ -- row "USER, Files ×1 · Read the attached file with the read tool, reply with exactly the single word it contains, and stop.": +# Ledger + +- row "USER, Images ×1 · Files ×1 · Read the attached file with the read tool, reply with exactly the single word it contains, and stop.": - cell "Turn 1 USER": USER - - cell "Files ×1 · Read the attached file with the read tool, reply with exactly the single word it contains, and stop." + - cell "Images ×1 · Files ×1 · Read the attached file with the read tool, reply with exactly the single word it contains, and stop." + +# Summary attachments + +- list "Attachments": + - listitem: poem.txt TXT · 16B + - listitem: + - button "reference-1.png, click to view original": + - img "reference-1.png" + - text: reference-1.png image/png · 69B · 1 × 1 + +# Preview + +- tabpanel "Preview": + - paragraph: Read the attached file with the read tool, reply with exactly the single word it contains, and stop. + - list "Attachments": + - listitem: poem.txt TXT · 16B + - listitem: + - button "reference-1.png, click to view original": + - img "reference-1.png" + - text: reference-1.png image/png · 69B · 1 × 1 + +# Raw (collapsed) + +- tabpanel "Raw": + - group: "Block #1 file poem.txt" + - group: "Block #2 image reference-1.png" + - text: "Block #3 text Read the attached file with the read tool, reply with exactly the single word it contains, and stop." + +# Raw (expanded) + +- tabpanel "Raw": + - group: "Block #1 file poem.txt { \"type\": \"file\", \"attachment\": { \"attachmentId\": \"sha256:29a0837077f6f3cbddd98c0605897cc6f2b4e5833fb8e1ca4a8045d89b2c8c60\", \"name\": \"poem.txt\", \"bytes\": 16 } }" + - group: "Block #2 image reference-1.png { \"type\": \"image\", \"attachment\": { \"attachmentId\": \"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640\", \"mediaType\": \"image/png\", \"width\": 1, \"height\": 1, \"bytes\": 69, \"name\": \"reference-1.png\" } }" + - text: "Block #3 text Read the attached file with the read tool, reply with exactly the single word it contains, and stop."