From 17dd9f0e29511c7e3e9f942b6de4b864cf47065f Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Wed, 16 Sep 2026 19:41:08 +0800 Subject: [PATCH 1/9] docs(web): propose consistent trajectory attachment presentation Refs #4356 --- .../2026-08-26-generic-file-upload.i18n.yaml | 4 +- .../feature/2026-08-26-generic-file-upload.md | 4 + .../2026-08-26-generic-file-upload.zh.md | 4 + ...ajectory-attachment-presentation.i18n.yaml | 6 ++ ...9-15-trajectory-attachment-presentation.md | 89 +++++++++++++++++++ ...5-trajectory-attachment-presentation.zh.md | 89 +++++++++++++++++++ 6 files changed, 194 insertions(+), 2 deletions(-) create mode 100644 .agents/notes/proposed/bug-fix/2026-09-15-trajectory-attachment-presentation.i18n.yaml create mode 100644 .agents/notes/proposed/bug-fix/2026-09-15-trajectory-attachment-presentation.md create mode 100644 .agents/notes/proposed/bug-fix/2026-09-15-trajectory-attachment-presentation.zh.md 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..93cf5d2d20 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: bda96caa36f27a2270971127b5e3635a7fec9fcd +2026-08-26-generic-file-upload.zh.md: 2ddfd48f1ea04ba6cfbff10224444de8d37d7a8a 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..bda96caa36 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 proposal](../../proposed/bug-fix/2026-09-15-trajectory-attachment-presentation.md) covers 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..2ddfd48f1e 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 附件展示提案](../../proposed/bug-fix/2026-09-15-trajectory-attachment-presentation.zh.md)涵盖列表计数与详情面板附件布局。 diff --git a/.agents/notes/proposed/bug-fix/2026-09-15-trajectory-attachment-presentation.i18n.yaml b/.agents/notes/proposed/bug-fix/2026-09-15-trajectory-attachment-presentation.i18n.yaml new file mode 100644 index 0000000000..f7fafc2b16 --- /dev/null +++ b/.agents/notes/proposed/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/proposed/bug-fix/2026-09-15-trajectory-attachment-presentation.md +2026-09-15-trajectory-attachment-presentation.md: 23ce4145f0a90e0e03a90ca4c335bb2cb3598785 +2026-09-15-trajectory-attachment-presentation.zh.md: cbd507235e28a523360bd47a105acbb59963418f diff --git a/.agents/notes/proposed/bug-fix/2026-09-15-trajectory-attachment-presentation.md b/.agents/notes/proposed/bug-fix/2026-09-15-trajectory-attachment-presentation.md new file mode 100644 index 0000000000..23ce4145f0 --- /dev/null +++ b/.agents/notes/proposed/bug-fix/2026-09-15-trajectory-attachment-presentation.md @@ -0,0 +1,89 @@ +# Agent Note: Consistent Trajectory attachment presentation + +Status: proposed + +English | [中文](2026-09-15-trajectory-attachment-presentation.zh.md) + +## Problem + +A user message can contain text, images, and ordinary files, but Trajectory exposes different subsets of that message across its ledger and inspector. The [ledger projection](../../../../packages/client/ui-trajectory/src/client/layout.ts) omits the image count when text is present. The [inspector renderer](../../../../packages/client/ui-trajectory/src/client/TrajectoryTable.tsx) displays images without ordinary file cards in Preview and mixes file JSON with rendered images in Raw. Readers cannot reliably identify the complete attachment set without switching views. + +The Summary tab embeds `MarkdownRecordContent` with compact presentation, while Preview uses the same component without that option. Attachment changes must preserve that shared renderer so both entry points expose the same content and actions. + +## Proposal + +Deliver one Trajectory presentation change covering the ledger, Summary, Preview, and Raw. The proposed behavior is not implemented by this document; Issue #4356 and its linked PR track execution status and completed evidence. + +### Display behavior + +| Location | Proposed behavior | +|---|---| +| Ledger | Show separate nonzero image and ordinary-file counts beside the text excerpt, including messages that contain text. Keep each record on one compact line. | +| Summary and Preview | Render the message text followed by one ordered attachment list. Use the same attachment renderer in both tabs; Summary may retain its compact spacing and scroll container. | +| Attachment list | Use equal-width compact rows with consistent height, spacing, and corners. Place a file-type icon or contained image thumbnail on the left and the recorded name and available metadata on the right. Preserve attachment occurrence order, including repeated references. | +| Image interaction | Fit the whole image inside its thumbnail without cropping. Open the existing large-image viewer on activation; preserve keyboard access, loading feedback, and retry after a failed read. | +| Raw | Preserve original content-block order and boundaries. Give image and file blocks consistent, initially collapsed metadata disclosures, with complete recorded fields available on expansion. Keep text blocks as unrendered text; generated display labels do not become raw fields. | + +Use the recorded filename when available and a localized numbered image label otherwise. Show type and size only when supported by the recorded data; preserve zero-byte sizes. Long names may truncate visually, but the full name must remain available through accessible text and a tooltip. A message without text still exposes every attachment. Multiple attachments remain reachable inside the inspector's existing scroll area. + +### Implementation ownership + +[ui-trajectory](../../../../packages/client/ui-trajectory/README.md) owns the counts, ordered attachment projection, and inspector composition. Carry typed image and file references through its view data rather than recovering file metadata by parsing JSON prepared for display. Keep complete recorded attachment fields available to Raw. + +[ui-attachment](../../../../packages/client/ui-attachment/README.md) retains image loading and viewer behavior, using the Conversation-owned per-session cache. Compose cross-package UI through slots and reuse suitable static controls from [ui-primitives](../../../../packages/client/ui-primitives/README.md), following the [client rules](../../../../packages/client/AGENTS.md). Update all consumers of any changed shared props. Keep the containment treatment specific to the requested attachment presentation so existing Chat and tool-image sizing remains covered by regression checks. + +This scope uses existing durable attachment references. It introduces no upload, storage, model-request, or Session-format change, and no ordinary-file content viewer or download action. Styles follow [Web styling](../../../../docs/web-styling.md); display text, accessibility labels, and tooltips use typed locale dictionaries. + +### Existing decisions + +The [generic file upload decision](../../implemented/feature/2026-08-26-generic-file-upload.md) remains authoritative for upload, storage, admission, and ordered message attachments. This proposal revises only its Trajectory presentation and does not fully supersede that record. The [unreadable attachment quarantine proposal](2026-08-20-attachment-read-quarantine.md) concerns model-request recovery and remains independent of thumbnail loading feedback. + +## Implementation plan + +The steps below define the delivery sequence and completion evidence. They are not a record of commands already run. Use the current [development guide](../../../../docs/development.md), [testing policy](../../../../docs/testing.md), and linked workflows when executing them. + +| Step | Work | Completion evidence | +|---|---|---| +| 1. Establish the baseline | Verify the internal repository, live base, clean worktree, dependencies, and applicable rules; use an isolated feature branch. | The repository, branch, and base are identified and unrelated changes are excluded. | +| 2. Associate an Issue | Find the existing task or create its Issue with reproduction, scope, native Type, applicable Project fields, and the acceptance criteria below. | A real Issue owns progress and provides the PR reference. | +| 3. Map consumers | Trace Trajectory attachment data, Summary/Preview composition, Raw serialization, image slots, and any shared props. | Every affected consumer and test owner is identified before interfaces change. | +| 4. Implement presentation | Preserve typed attachment data, fix counts, introduce the shared attachment list, and align Raw disclosures in that order. | All four locations expose the specified information without changing durable message data. | +| 5. Add behavior evidence | Extend projection and component tests, then the recorded mixed-upload browser scenario; cover both inspector entry points and image interactions. | Assertions reject the missing-file, missing-count, ordering, and layout regressions. | +| 6. Update documentation | Update affected README pairs, JSDoc, and the owning decision records with the implementation; record bilingual consistency. | Documentation describes the implemented behavior, and this proposal is rewritten for its final lifecycle when implementation is complete. | +| 7. Validate the outgoing diff | Apply the [pre-push check workflow](../../../skills/dsh-pre-push-checks/SKILL.md) and the client check ladder; inspect the complete diff and changed expected outputs. | Relevant local checks pass, with commands and limitations recorded accurately. | +| 8. Commit and demonstrate | Review hook changes, commit normally, build the clean committed tree, and record one isolated real-server, real-model flow under the [GIF workflow](../../../skills/record-browser-gif/SKILL.md). Push normally and verify the remote head. | The reviewed GIF names the demonstrated commit and serving conditions, and the remote branch contains that commit. | +| 9. Publish and review | Fill the [PR template](../../../../.github/pull_request_template.md), link the Issue, select live kind/area labels, and attach evidence. Check the PR preview and CI; address review on the same branch. | The current head has reviewable evidence; fixes update affected checks and the GIF when its demonstrated commit changes. | +| 10. Merge and follow up | After review, satisfy live approval and merge rules, then follow mainline checks and Issue state. | The authorized merge is verified and any remaining work has an owner; release publication follows its separate workflow. | + +### Verification coverage + +| Layer | Planned coverage | +|---|---| +| Projection | Text with one image and one file; images only; files only; attachment-only mixed messages; empty text; multiple and repeated attachment occurrences. Counts and raw fields match the input. | +| Components | Summary and Preview show the same attachment names, order, metadata, and activation behavior. Raw preserves block order and complete fields. Cover absent image names, zero-byte files, long filenames, loading, failure, and retry. | +| Assembled application | Extend [file-upload-round](../../../../apps/web/tests/file-upload-round.e2e.ts) and its [recorded scenario](../../../../snapshots/web/file-upload-round/snapshot.yml) to inspect Summary, Preview, and Raw after the mixed upload and after reopening history. Keep the [shared image-cache check](../../../../apps/web/tests/trajectory-image-display.expected.e2e.ts). | +| Browser layout and accessibility | Verify narrow and wide inspector widths, light and dark themes, both locales, complete thumbnail containment, reachable final attachments, and keyboard activation and viewer dismissal. Keep geometry expectations beside their test owner under the [snapshot placement rules](../../../../snapshots/AGENTS.md). | +| Local checks | Run focused tests during implementation, then the required GUI and browser replay checks from the client rules, locale verification, documentation checks, and lint. Normal pre-push runs typecheck; CI owns exhaustive coverage and platform checks. | +| Demonstration | Record mixed submission, the ledger counts, Summary, Preview with image activation, and expanded Raw metadata from one real model session. Keep media outside the feature branch and attach it according to the GIF workflow. | + +## 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 is the proposed default. + +**Keep file JSON and image previews together in Raw.** This preserves the existing rendering, but assigns different meanings to the same tab for two attachment kinds. Consistent metadata disclosures let readers inspect recorded fields while Preview owns the visual image presentation. + +## Acceptance criteria + +- A message containing text, one image, and one ordinary file shows both counts in the ledger and both attachments in Summary and Preview. +- Summary and Preview share one attachment renderer; each provides the same ordered attachments and image actions despite their different spacing and containers. +- Attachment-only messages, repeated references, unnamed images, zero-byte files, and long filenames remain identifiable without invented metadata. +- Images fit completely inside compact thumbnails. Keyboard and pointer users can open and dismiss the existing image viewer; a failed image load remains visible and retryable. +- Raw preserves every original content block and its order. Image and file disclosures expose all recorded attachment fields without replacing them with generated labels or downloaded file contents. +- Narrow panels, both themes, and both locales remain usable. Existing Chat/tool-image behavior and per-session image cache reuse retain regression coverage. +- The implementation includes corresponding tests, reviewed keyless browser expectations, paired documentation, and a real-model GIF tied to the demonstrated PR head. + +## Risks + +Adding file references to view data can accidentally route them through image-only consumers or lose raw fields. Trace every affected discriminant and verify mixed and repeated occurrences. Compact thumbnails reduce immediate readability of diagrams, so full-image activation must remain discoverable. Shared renderer changes can affect Chat and tool results, while Summary's scroll container can hide final rows; both need assembled-browser checks. Missing recorded metadata must remain visibly absent, and image-read failures must not hide unrelated files. Unavailable model credentials block the real-model demonstration and must be reported rather than replaced with fixture footage. diff --git a/.agents/notes/proposed/bug-fix/2026-09-15-trajectory-attachment-presentation.zh.md b/.agents/notes/proposed/bug-fix/2026-09-15-trajectory-attachment-presentation.zh.md new file mode 100644 index 0000000000..cbd507235e --- /dev/null +++ b/.agents/notes/proposed/bug-fix/2026-09-15-trajectory-attachment-presentation.zh.md @@ -0,0 +1,89 @@ +# Agent Note: 统一 Trajectory 附件展示 + +Status: proposed + +[English](2026-09-15-trajectory-attachment-presentation.md) | 中文 + +## 问题 + +用户消息可以包含文本、图片和普通文件,但 Trajectory 列表与详情面板展示的消息内容并不完整一致。[列表投影](../../../../packages/client/ui-trajectory/src/client/layout.ts)在消息含有文本时省略图片数量。[详情渲染器](../../../../packages/client/ui-trajectory/src/client/TrajectoryTable.tsx)在预览中展示图片而不展示普通文件卡片,在原始内容中又把文件 JSON 与渲染后的图片混排。读者需要切换视图才能确认完整的附件集合。 + +概述标签页通过紧凑展示选项嵌入 `MarkdownRecordContent`,预览标签页使用同一组件,但不启用该选项。附件调整必须保留这一共享渲染器,使两个入口展示相同的内容和操作。 + +## 提案 + +交付一项覆盖列表、概述、预览和原始内容的 Trajectory 展示调整。本文不实现所提议的行为;执行状态和已完成的验证证据由 Issue #4356 及其关联 PR(Pull Request)记录。 + +### 展示行为 + +| 位置 | 提议的行为 | +|---|---| +| 列表 | 在文本摘要旁分别显示非零的图片数量与普通文件数量,包含有文本的消息。每条记录保持为紧凑单行。 | +| 概述与预览 | 消息正文之后展示一个有序附件列表。两个标签页使用相同的附件渲染器;概述可以保留紧凑间距和滚动容器。 | +| 附件列表 | 使用等宽的紧凑行,统一行高、间距和圆角。左侧放文件类型图标或完整缩放的图片缩略图,右侧放已记录的名称与可用元数据。保留每次附件出现的顺序,包括重复引用。 | +| 图片交互 | 图片完整缩放到缩略图内,不裁剪。激活时打开现有大图查看器;保留键盘访问、加载反馈和读取失败后的重试。 | +| 原始内容 | 保留原始内容块顺序与边界。图片块和文件块使用一致、默认收起的元数据折叠区,展开后显示完整记录字段。文本块保持未渲染文本;生成的显示标签不进入原始字段。 | + +有已记录文件名时使用文件名,否则为图片使用本地化的编号标签。类型与大小只在已记录数据支持时显示;保留零字节大小。长名称可以在视觉上截断,但完整名称必须能通过无障碍文本和工具提示获取。没有正文的消息仍须展示每个附件。多个附件在详情面板现有滚动区域内保持可达。 + +### 实现职责 + +[ui-trajectory](../../../../packages/client/ui-trajectory/README.zh.md)负责计数、有序附件投影和详情面板组合。通过其视图数据传递带类型的图片与文件引用,不从为展示准备的 JSON 中反向解析文件元数据。为原始内容保留完整的已记录附件字段。 + +[ui-attachment](../../../../packages/client/ui-attachment/README.zh.md)继续负责图片加载和查看器行为,使用 Conversation 拥有的会话级缓存。通过 slot 组合跨包 UI,并复用 [ui-primitives](../../../../packages/client/ui-primitives/README.zh.md) 中适用的静态控件,遵循[客户端规则](../../../../packages/client/AGENTS.md)。共享属性发生变化时,更新全部消费方。完整缩放处理限定于本次附件展示,并通过回归检查覆盖现有 Chat 和工具图片尺寸行为。 + +本次范围使用现有持久附件引用,不改变上传、存储、模型请求或会话格式,也不增加普通文件内容查看器或下载操作。样式遵循 [Web 样式规范](../../../../docs/web-styling.zh.md);显示文字、无障碍标签和工具提示使用带类型的本地化字典。 + +### 现有决定 + +[通用文件上传决定](../../implemented/feature/2026-08-26-generic-file-upload.zh.md)继续负责上传、存储、准入和有序消息附件。本提案只调整其中的 Trajectory 展示,并不完整取代该记录。[不可读附件隔离提案](2026-08-20-attachment-read-quarantine.zh.md)处理模型请求恢复,与缩略图加载反馈相互独立。 + +## 实施计划 + +以下步骤定义交付顺序与完成证据,不代表命令已经执行。执行时使用当前[开发指南](../../../../docs/development.zh.md)、[测试策略](../../../../docs/testing.zh.md)和所链接的工作流。 + +| 步骤 | 工作 | 完成证据 | +|---|---|---| +| 1. 确定基线 | 核对内部仓库、实时基线、干净的 worktree、依赖和适用规则;使用隔离的功能分支。 | 仓库、分支和基线明确,未混入无关修改。 | +| 2. 关联 Issue | 查找现有任务或创建 Issue,写明复现、范围、原生 Type、适用的 Project 字段和下列验收标准。 | 真实 Issue 负责进度,并作为 PR 的引用。 | +| 3. 梳理消费方 | 跟踪 Trajectory 附件数据、概述与预览组合、原始内容序列化、图片 slot 和共享属性。 | 接口修改前,已明确全部受影响消费方与测试归属。 | +| 4. 实现展示 | 依次保留带类型的附件数据、修正计数、引入共享附件列表、统一原始内容折叠区。 | 四处位置均展示规定信息,持久消息数据不变。 | +| 5. 补行为证据 | 扩展投影与组件测试,再扩展已录制的混合上传浏览器场景;覆盖两个详情入口和图片交互。 | 断言能够检出文件遗漏、计数遗漏、顺序及布局回归。 | +| 6. 更新文档 | 随实现更新受影响的 README 双语文件、JSDoc 和所属决定记录,并记录双语一致性。 | 文档描述已实现行为,完整实现后按最终生命周期改写本提案。 | +| 7. 验证待提交差异 | 应用[推送前检查工作流](../../../skills/dsh-pre-push-checks/SKILL.md)和客户端检查层级;检查完整差异与变化的预期输出。 | 相关本地检查通过,准确记录命令与限制。 | +| 8. 提交与演示 | 检查 hook 修改,正常提交,构建干净的已提交代码树,并按 [GIF 工作流](../../../skills/record-browser-gif/SKILL.md)录制一次隔离的真实服务、真实模型流程。正常推送并核对远端提交。 | 已检查的 GIF 标明演示提交和服务条件,远端分支包含该提交。 | +| 9. 发布与评审 | 填写 [PR 模板](../../../../.github/pull_request_template.md),关联 Issue,选择当前 kind/area 标签并附证据。检查 PR 预览和 CI;在同一分支处理评审。 | 当前提交具有可评审证据;修复后更新受影响检查,演示提交变化时更新 GIF。 | +| 10. 合并与跟进 | 评审后满足实时审批与合并规则,再跟进主线检查和 Issue 状态。 | 已核实获授权的合并,剩余工作有明确归属;发行走独立流程。 | + +### 验证覆盖 + +| 层级 | 计划覆盖 | +|---|---| +| 投影 | 文本加一张图片与一个文件、只有图片、只有文件、无正文的混合附件、空文本、多个附件与重复附件。计数和原始字段与输入一致。 | +| 组件 | 概述与预览显示相同的附件名称、顺序、元数据与激活行为。原始内容保留块顺序和完整字段。覆盖图片无名称、零字节文件、长文件名、加载、失败与重试。 | +| 组装应用 | 扩展 [file-upload-round](../../../../apps/web/tests/file-upload-round.e2e.ts) 及其[录制场景](../../../../snapshots/web/file-upload-round/snapshot.yml),在混合上传后和重新打开历史后检查概述、预览与原始内容。保留[共享图片缓存检查](../../../../apps/web/tests/trajectory-image-display.expected.e2e.ts)。 | +| 浏览器布局与无障碍 | 验证窄版与宽版详情面板、浅色与深色主题、两种语言、缩略图完整缩放、末尾附件可达,以及键盘激活和关闭查看器。按[快照存放规则](../../../../snapshots/AGENTS.md),把几何预期留在所属测试旁。 | +| 本地检查 | 实现期间运行针对性测试,然后执行客户端规则要求的 GUI 与浏览器回放检查、本地化验证、文档检查和 lint。正常 pre-push 执行类型检查;CI 负责全量覆盖率与平台检查。 | +| 演示 | 在同一个真实模型会话中录制混合提交、列表计数、概述、预览中的图片激活,以及展开的原始元数据。媒体保存在功能分支之外,按 GIF 工作流附到 PR。 | + +## 考虑过的替代方案 + +**默认在详情面板中内联展示大图。** 这样可以立即阅读单张图片,但高图片会在窄面板中挤走附件名称和消息正文。紧凑行加显式图片激活入口能一致地概览混合附件。 + +**使用双列附件卡片。** 网格能在宽面板上突出缩略图,但会压缩长文件名空间,并要求窄面板采用不同排布。提案默认使用单列等宽列表。 + +**在原始内容中继续混排文件 JSON 和图片预览。** 这保留了现有渲染,却让同一个标签页对两种附件具有不同含义。一致的元数据折叠区用于检查已记录字段,图片视觉展示由预览负责。 + +## 验收标准 + +- 同时包含文本、一张图片和一个普通文件的消息,在列表显示两种数量,在概述和预览显示两个附件。 +- 概述与预览共用一个附件渲染器;即使间距和容器不同,也提供相同的有序附件和图片操作。 +- 纯附件消息、重复引用、无名称图片、零字节文件和长文件名保持可识别,不编造元数据。 +- 图片完整缩放到紧凑缩略图内。键盘和指针用户均可打开并关闭现有图片查看器;图片加载失败时仍有可见反馈并可重试。 +- 原始内容保留每个原始内容块及其顺序。图片与文件折叠区展示全部已记录附件字段,不以生成标签或下载的文件内容替代。 +- 窄面板、两种主题和两种语言下均可使用。现有 Chat/工具图片行为和会话级图片缓存复用保留回归覆盖。 +- 实现包含相应测试、经审阅的无密钥浏览器预期输出、双语文档,以及与演示 PR 当前提交对应的真实模型 GIF。 + +## 风险 + +在视图数据中加入文件引用,可能误将文件传给只接受图片的消费方,或丢失原始字段。需要跟踪每个受影响的类型标记,并验证混合与重复附件。紧凑缩略图降低了图表的直接可读性,因此大图激活入口必须容易发现。共享渲染器改动可能影响 Chat 和工具结果,概述滚动容器也可能遮挡末尾行;两者都需要组装浏览器检查。缺失的记录元数据必须保持明确缺失,图片读取失败不能隐藏其他文件。模型凭证不可用时,真实模型演示会受阻,必须如实说明,不能用 fixture(测试前置数据)录屏替代。 From 1da97f30f815fecb95dc3f7ee5a078931d154f3d Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 17 Sep 2026 11:03:04 +0800 Subject: [PATCH 2/9] fix(web): unify trajectory attachment presentation Refs #4356 --- .../client/ui-attachment/README.i18n.yaml | 4 +- packages/client/ui-attachment/README.md | 2 + packages/client/ui-attachment/README.zh.md | 2 + .../ui-attachment/src/MessageImage.module.css | 22 +++ .../client/ui-attachment/src/MessageImage.tsx | 20 ++- .../src/client/MessageImages.tsx | 3 +- .../tests/message-image.client.spec.tsx | 24 ++- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 + packages/client/ui-conversation/README.zh.md | 2 + .../src/client/contract/slots.ts | 8 +- .../client/ui-trajectory/README.i18n.yaml | 4 +- packages/client/ui-trajectory/README.md | 4 +- packages/client/ui-trajectory/README.zh.md | 4 +- .../src/client/TrajectoryTable.module.css | 81 +++++++++- .../src/client/TrajectoryTable.tsx | 151 ++++++++++++------ .../client/ui-trajectory/src/client/layout.ts | 16 +- .../ui-trajectory/src/client/locales.ts | 4 + .../src/client/trajectory-record.ts | 4 +- .../tests/layout.client.spec.tsx | 33 ++-- .../ui-trajectory/tests/table.client.spec.tsx | 98 +++++++----- .../src/client/slot-catalog.ts | 50 +++--- 22 files changed, 385 insertions(+), 157 deletions(-) diff --git a/packages/client/ui-attachment/README.i18n.yaml b/packages/client/ui-attachment/README.i18n.yaml index 8b9ac24565..a8548bfafa 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: 8272703a56d05295e3cb14f3441bce1eeafe1629 +README.zh.md: ae8d687448f947306830982a770f7f54e019361f diff --git a/packages/client/ui-attachment/README.md b/packages/client/ui-attachment/README.md index 3c69b38791..8272703a56 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 controls keep the same box, and 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..ae8d687448 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..2ed6fbe507 100644 --- a/packages/client/ui-attachment/src/MessageImage.module.css +++ b/packages/client/ui-attachment/src/MessageImage.module.css @@ -67,3 +67,25 @@ overflow: hidden; border-radius: 16px; } + +.frame[data-variant='thumbnail'], +.error[data-variant='thumbnail'] { + box-sizing: border-box; + 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; +} + +.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..3efa5737fa 100644 --- a/packages/client/ui-attachment/src/MessageImage.tsx +++ b/packages/client/ui-attachment/src/MessageImage.tsx @@ -11,7 +11,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 @@ -72,14 +76,14 @@ function dimensionsOf(image: MessageImageSpec): { readonly width: number; readon * * @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,7 +120,8 @@ export function MessageImage({ image, load, variant, labels }: { }, [attachment, load, attempt]) const src = preview?.url ?? loaded - const label = (preview?.name ?? attachment?.name) ?? labels.image + const label = ('attachment' in image ? image.label : undefined) + ?? preview?.name ?? attachment?.name ?? labels.image if (error) return return ( <> @@ -139,16 +144,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 3e86094c74..a733012356 100644 --- a/packages/client/ui-attachment/tests/message-image.client.spec.tsx +++ b/packages/client/ui-attachment/tests/message-image.client.spec.tsx @@ -144,11 +144,29 @@ 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('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 9ca39b0338..efd218a56a 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: 3c2697487ffe5b79bc8209eb9be953cc70977d79 -README.zh.md: 137357eb133a42debbce3232ca7b0858a26ee8c1 +README.md: 9ba86daebc31bb7e3fa63b21b3770d2998439ee6 +README.zh.md: 889b414057391139c41559fd9075d1a3334822e3 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 3c2697487f..9ba86daebc 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 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. `SessionInputShell` owns one Lexical editor per Session through its private [DraftEditorRuntime](src/client/input/editor/runtime.ts), while retaining submission, attachment selection, and recovery decisions. [DraftEditor](src/client/input/editor/DraftEditor.tsx) renders the borrowed editor; InputBar retains its Hooks and refs and installs DOM behavior through [view-binding](src/client/input/editor/view-binding.ts). Editor-facing types live in [draft-editor.ts](src/client/contract/draft-editor.ts), with shared input and submission types in [input.ts](src/client/contract/input.ts). This separation does not support simultaneous editable roots for one Session; [the two-stage isolation proposal](../../../.agents/notes/proposed/architecture/2026-09-14-composer-model-and-draft-editor.md) defines the remaining work. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 137357eb13..889b414057 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) 负责渲染与灯箱。 + 输入框注册「文件」命令动作,负责其标题、可用性和原生文件选择器回调。菜单可用性与实际调用都读取已挂载输入框当前的附件接收策略。输入框卸载或锁定后该动作不可用,插件 dispose(资源释放)时移除注册。回调绑定留在输入模块内部。 `SessionInputShell` 通过私有 [DraftEditorRuntime](src/client/input/editor/runtime.ts) 为每个 Session 持有一个 Lexical editor,同时保留提交、附件选择和恢复决策。[DraftEditor](src/client/input/editor/DraftEditor.tsx) 呈现借用的 editor;InputBar 保留钩子与 refs,并通过 [view-binding](src/client/input/editor/view-binding.ts) 安装 DOM 行为。编辑器类型位于 [draft-editor.ts](src/client/contract/draft-editor.ts),共享输入和提交类型位于 [input.ts](src/client/contract/input.ts)。这一拆分不支持同一 Session 同时挂载多个可编辑 root;[两阶段隔离提案](../../../.agents/notes/proposed/architecture/2026-09-14-composer-model-and-draft-editor.zh.md) 定义剩余工作。 diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index b9bafadb4e..f0c7470431 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -76,7 +76,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). */ @@ -104,6 +108,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 1dae344665..1834635f5a 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: 63bd664b902d8d40f8a05153770306d3b03dbe4e -README.zh.md: 89db03a99ac68065211f92ff7e47307813c1ab76 +README.md: f11bfa3188ce616e392158f9f2cf33920edea8ba +README.zh.md: 53672cc8e4c8891ff6ea9b985b4efafc0368e1bf diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md index 63bd664b90..f11bfa3188 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. ### The timing overview diff --git a/packages/client/ui-trajectory/README.zh.md b/packages/client/ui-trajectory/README.zh.md index 89db03a99a..53672cc8e4 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` 区段中,而带编号的压缩仍位于其所属轮次内。 + +概述与预览共用正文之后的有序附件列表,保留重复引用。每行展示完整缩放的图片缩略图或文件类型图标、已记录的文件名(未命名图片使用本地化的编号名称),以及可用的已记录大小、类型和图片尺寸。零字节文件保留大小显示,截断的文件名通过提示显示完整名称。图片打开现有灯箱。原始内容保留内容块顺序和未渲染文本,图片与文件使用默认折叠的展开项,包含完整的已记录字段。 ### 时间概览 diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css index c743e0e503..6c1287fca7 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css @@ -1708,11 +1708,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 6babf88c4e..53fac3a647 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' @@ -1155,75 +1157,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} +
    +
  • + ))} +
) } @@ -1486,7 +1533,6 @@ function MarkdownRecordContent({ ) @@ -1541,25 +1587,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 ( @@ -1573,7 +1620,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..432ba0653d 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, @@ -129,7 +129,7 @@ function inputCellDetail(node: InputNode, t: TrajectoryTranslate): Pick< const images = imageBlockCount(node.content) const files = fileBlockCount(node.content) const attachmentSummary = [ - previewMarkdown === undefined && images > 0 + images > 0 ? t('layout.imageOnly', { count: images }) : undefined, files > 0 ? t('layout.fileAttachments', { count: files }) : undefined, @@ -829,7 +829,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 +844,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) } } diff --git a/packages/client/ui-trajectory/src/client/locales.ts b/packages/client/ui-trajectory/src/client/locales.ts index 7e76444ca1..80ddf8fe7d 100644 --- a/packages/client/ui-trajectory/src/client/locales.ts +++ b/packages/client/ui-trajectory/src/client/locales.ts @@ -180,6 +180,8 @@ export const zh = { 'layout.compactionFailed': '上下文压缩失败', 'layout.compacted': '上下文已压缩', 'layout.toolCallOnly': '仅工具调用', + 'attachment.list': '附件', + 'attachment.imageName': '图片 {index}', 'layout.imageOnly': '图片 ×{count}', 'layout.fileAttachments': '文件 ×{count}', 'layout.initialSystemPrompt': '初始系统提示词', @@ -380,6 +382,8 @@ export const en: Record = { 'layout.compactionFailed': 'Compaction failed', 'layout.compacted': 'Context compacted', 'layout.toolCallOnly': 'Tool call only', + 'attachment.list': 'Attachments', + 'attachment.imageName': 'Image {index}', 'layout.imageOnly': 'Images ×{count}', 'layout.fileAttachments': 'Files ×{count}', 'layout.initialSystemPrompt': 'Initial System Prompt', 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 5a882141bb..32771e3565 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' @@ -1008,47 +1009,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 e080b567cd..0ea2684d65 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -387,7 +387,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:169', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:175', }, { key: 'conversation.composer.bar', @@ -425,7 +425,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:187', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:193', }, { key: 'conversation.composer.dock', @@ -480,7 +480,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:181', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:187', }, { key: 'conversation.hero.agentPreset', @@ -510,7 +510,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:175', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:181', }, { key: 'conversation.hero.brand.mark', @@ -538,7 +538,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:173', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:179', }, { key: 'conversation.hero.workspace', @@ -570,7 +570,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:171', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:177', }, { key: 'conversation.hero.workspace.directoryFlow', @@ -641,7 +641,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:189', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:195', }, { key: 'conversation.input.dock', @@ -703,7 +703,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:177', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:183', }, { key: 'conversation.input.left', @@ -756,7 +756,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:183', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:189', }, { key: 'conversation.input.model', @@ -794,7 +794,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:199', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:205', }, { key: 'conversation.input.overlay', @@ -851,7 +851,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:179', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:185', }, { key: 'conversation.input.permission', @@ -889,7 +889,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:197', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:203', }, { key: 'conversation.input.plan', @@ -927,7 +927,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:195', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:201', }, { key: 'conversation.input.right', @@ -980,7 +980,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:185', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:191', }, { key: 'conversation.message.images', @@ -990,7 +990,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', @@ -1058,7 +1058,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:122', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:128', }, { key: 'conversation.session.header', @@ -1094,7 +1094,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:124', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:130', }, { key: 'conversation.session.header.actions', @@ -1155,7 +1155,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:132', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:138', }, { key: 'conversation.session.header.corner', @@ -1193,7 +1193,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:161', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:167', }, { key: 'conversation.session.header.leading', @@ -1231,7 +1231,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:150', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:156', }, { key: 'conversation.session.header.lineage', @@ -1271,7 +1271,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:126', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:132', }, { key: 'conversation.session.header.utilities', @@ -1329,7 +1329,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:138', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:144', }, { key: 'conversation.trajectory.images', @@ -1339,7 +1339,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', @@ -1431,7 +1431,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:167', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:173', }, { key: 'main', @@ -1501,7 +1501,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:120', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:126', }, { key: 'rightbar', From 212f5f9ccc7e18d70de56698dfc217c7ec3a27e5 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 17 Sep 2026 11:33:29 +0800 Subject: [PATCH 3/9] test(web): cover trajectory attachment presentation Refs #4356 --- apps/web/tests/file-upload-round.e2e.ts | 112 ++++++++++++++++-- .../file-upload-round/trajectory.expected.md | 40 ++++++- 2 files changed, 139 insertions(+), 13 deletions(-) diff --git a/apps/web/tests/file-upload-round.e2e.ts b/apps/web/tests/file-upload-round.e2e.ts index 5df27af9d9..bedab5ea9d 100644 --- a/apps/web/tests/file-upload-round.e2e.ts +++ b/apps/web/tests/file-upload-round.e2e.ts @@ -15,7 +15,7 @@ import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } 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' @@ -108,6 +108,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,16 +370,19 @@ 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')('stayed clean and kept the exact fixture inventory', async () => { 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." From 9119f145224d8239e9db564e28d55ff632fbaebe Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 17 Sep 2026 12:34:49 +0800 Subject: [PATCH 4/9] fix(web): reset raw attachment disclosures on record selection Refs #4356 --- .../src/client/TrajectoryTable.tsx | 1 + .../ui-trajectory/tests/table.client.spec.tsx | 55 +++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 53fac3a647..991c5bbb82 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -1531,6 +1531,7 @@ function MarkdownRecordContent({ if (!rendered && record.cell.sourceBlocks && record.cell.sourceBlocks.length > 0) { return ( { }, ) + it('resets raw attachment disclosures on record selection but keeps them through history updates', () => { + const content = [ + { + type: 'image' as const, + attachment: { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType: 'image/png' as const, + width: 40, + height: 80, + bytes: 68, + name: 'shared.png', + }, + }, + { + type: 'file' as const, + attachment: { + attachmentId: AttachmentId(`sha256:${'b'.repeat(64)}`), + name: 'shared.txt', + bytes: 1, + }, + }, + ] + const nodes = ['First message', 'Second message'].map((text, index) => ({ + kind: 'user' as const, + seq: index + 2, + time: (index + 2) * 1000, + source: { kind: 'user' as const }, + content: [{ type: 'text' as const, text }, ...content], + })) + const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }, t) + const view = render() + fireEvent.click(screen.getByRole('row', { name: /First message/ })) + fireEvent.click(screen.getByRole('tab', { name: 'Raw' })) + const disclosures = () => [...screen.getByRole('tabpanel').querySelectorAll('details')] + expect(disclosures()).toHaveLength(2) + expect(disclosures().every(disclosure => !disclosure.open)).toBe(true) + for (const disclosure of disclosures()) fireEvent.click(disclosure.querySelector('summary')!) + expect(disclosures().every(disclosure => disclosure.open)).toBe(true) + + const older = { ...nodes[0]!, seq: 1, time: 1000, content: [{ type: 'text' as const, text: 'Older message' }] } + const expandedTurns = deriveTrajectoryLayout({ nodes: [older, ...nodes], partial: null, runningCalls: [] }, t) + view.rerender() + expect(within(screen.getByRole('tabpanel')).getByText('First message', { exact: true })).toBeTruthy() + expect(disclosures().every(disclosure => disclosure.open)).toBe(true) + + fireEvent.click(screen.getByRole('row', { name: /Second message/ })) + expect(screen.getByRole('tab', { name: 'Raw' }).getAttribute('aria-selected')).toBe('true') + expect(within(screen.getByRole('tabpanel')).getByText('Second message', { exact: true })).toBeTruthy() + expect(disclosures()).toHaveLength(2) + expect(disclosures().every(disclosure => !disclosure.open)).toBe(true) + for (const disclosure of disclosures()) fireEvent.click(disclosure.querySelector('summary')!) + fireEvent.click(screen.getByRole('row', { name: /First message/ })) + expect(disclosures().every(disclosure => !disclosure.open)).toBe(true) + }) + it('renders a tool-result image through the shared gallery in the Result tab', () => { const attachment = { attachmentId: `sha256:${'b'.repeat(64)}`, From f176327afc2f97a56cd3b1d9647b4502c243d3f7 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 17 Sep 2026 12:35:14 +0800 Subject: [PATCH 5/9] docs(web): record implemented trajectory attachment presentation Refs #4356 --- ...ajectory-attachment-presentation.i18n.yaml | 6 ++ ...9-15-trajectory-attachment-presentation.md | 55 ++++++++++++ ...5-trajectory-attachment-presentation.zh.md | 55 ++++++++++++ .../2026-08-26-generic-file-upload.i18n.yaml | 4 +- .../feature/2026-08-26-generic-file-upload.md | 2 +- .../2026-08-26-generic-file-upload.zh.md | 2 +- ...ajectory-attachment-presentation.i18n.yaml | 6 -- ...9-15-trajectory-attachment-presentation.md | 89 ------------------- ...5-trajectory-attachment-presentation.zh.md | 89 ------------------- .../client/ui-trajectory/README.i18n.yaml | 4 +- packages/client/ui-trajectory/README.md | 2 + packages/client/ui-trajectory/README.zh.md | 2 + 12 files changed, 126 insertions(+), 190 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.md create mode 100644 .agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.zh.md delete mode 100644 .agents/notes/proposed/bug-fix/2026-09-15-trajectory-attachment-presentation.i18n.yaml delete mode 100644 .agents/notes/proposed/bug-fix/2026-09-15-trajectory-attachment-presentation.md delete mode 100644 .agents/notes/proposed/bug-fix/2026-09-15-trajectory-attachment-presentation.zh.md 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..0254ff4b08 --- /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: 4a1528a230c0ded38d26d0859a181ebaa208180d +2026-09-15-trajectory-attachment-presentation.zh.md: e6accddb292613d9688b66847b6d6834ad6636c6 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..4a1528a230 --- /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 feedback, and retry after a failed read. | +| 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. Selecting another record resets Raw attachment disclosures; updates to the selected record, including loading older history, preserve their state. + +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, complete Raw fields, and disclosure state across record selection and history updates. [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..e6accddb29 --- /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 93cf5d2d20..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: bda96caa36f27a2270971127b5e3635a7fec9fcd -2026-08-26-generic-file-upload.zh.md: 2ddfd48f1ea04ba6cfbff10224444de8d37d7a8a +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 bda96caa36..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 @@ -51,4 +51,4 @@ Unit and integration coverage pins verbatim and streamed storage, name sanitizat ## Related -The [Trajectory attachment presentation proposal](../../proposed/bug-fix/2026-09-15-trajectory-attachment-presentation.md) covers ledger counts and inspector attachment layouts. +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 2ddfd48f1e..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 @@ -51,4 +51,4 @@ Status: implemented ## 相关记录 -[Trajectory 附件展示提案](../../proposed/bug-fix/2026-09-15-trajectory-attachment-presentation.zh.md)涵盖列表计数与详情面板附件布局。 +[Trajectory 附件展示决定](../bug-fix/2026-09-15-trajectory-attachment-presentation.zh.md)负责列表计数与详情面板附件布局。 diff --git a/.agents/notes/proposed/bug-fix/2026-09-15-trajectory-attachment-presentation.i18n.yaml b/.agents/notes/proposed/bug-fix/2026-09-15-trajectory-attachment-presentation.i18n.yaml deleted file mode 100644 index f7fafc2b16..0000000000 --- a/.agents/notes/proposed/bug-fix/2026-09-15-trajectory-attachment-presentation.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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/proposed/bug-fix/2026-09-15-trajectory-attachment-presentation.md -2026-09-15-trajectory-attachment-presentation.md: 23ce4145f0a90e0e03a90ca4c335bb2cb3598785 -2026-09-15-trajectory-attachment-presentation.zh.md: cbd507235e28a523360bd47a105acbb59963418f diff --git a/.agents/notes/proposed/bug-fix/2026-09-15-trajectory-attachment-presentation.md b/.agents/notes/proposed/bug-fix/2026-09-15-trajectory-attachment-presentation.md deleted file mode 100644 index 23ce4145f0..0000000000 --- a/.agents/notes/proposed/bug-fix/2026-09-15-trajectory-attachment-presentation.md +++ /dev/null @@ -1,89 +0,0 @@ -# Agent Note: Consistent Trajectory attachment presentation - -Status: proposed - -English | [中文](2026-09-15-trajectory-attachment-presentation.zh.md) - -## Problem - -A user message can contain text, images, and ordinary files, but Trajectory exposes different subsets of that message across its ledger and inspector. The [ledger projection](../../../../packages/client/ui-trajectory/src/client/layout.ts) omits the image count when text is present. The [inspector renderer](../../../../packages/client/ui-trajectory/src/client/TrajectoryTable.tsx) displays images without ordinary file cards in Preview and mixes file JSON with rendered images in Raw. Readers cannot reliably identify the complete attachment set without switching views. - -The Summary tab embeds `MarkdownRecordContent` with compact presentation, while Preview uses the same component without that option. Attachment changes must preserve that shared renderer so both entry points expose the same content and actions. - -## Proposal - -Deliver one Trajectory presentation change covering the ledger, Summary, Preview, and Raw. The proposed behavior is not implemented by this document; Issue #4356 and its linked PR track execution status and completed evidence. - -### Display behavior - -| Location | Proposed behavior | -|---|---| -| Ledger | Show separate nonzero image and ordinary-file counts beside the text excerpt, including messages that contain text. Keep each record on one compact line. | -| Summary and Preview | Render the message text followed by one ordered attachment list. Use the same attachment renderer in both tabs; Summary may retain its compact spacing and scroll container. | -| Attachment list | Use equal-width compact rows with consistent height, spacing, and corners. Place a file-type icon or contained image thumbnail on the left and the recorded name and available metadata on the right. Preserve attachment occurrence order, including repeated references. | -| Image interaction | Fit the whole image inside its thumbnail without cropping. Open the existing large-image viewer on activation; preserve keyboard access, loading feedback, and retry after a failed read. | -| Raw | Preserve original content-block order and boundaries. Give image and file blocks consistent, initially collapsed metadata disclosures, with complete recorded fields available on expansion. Keep text blocks as unrendered text; generated display labels do not become raw fields. | - -Use the recorded filename when available and a localized numbered image label otherwise. Show type and size only when supported by the recorded data; preserve zero-byte sizes. Long names may truncate visually, but the full name must remain available through accessible text and a tooltip. A message without text still exposes every attachment. Multiple attachments remain reachable inside the inspector's existing scroll area. - -### Implementation ownership - -[ui-trajectory](../../../../packages/client/ui-trajectory/README.md) owns the counts, ordered attachment projection, and inspector composition. Carry typed image and file references through its view data rather than recovering file metadata by parsing JSON prepared for display. Keep complete recorded attachment fields available to Raw. - -[ui-attachment](../../../../packages/client/ui-attachment/README.md) retains image loading and viewer behavior, using the Conversation-owned per-session cache. Compose cross-package UI through slots and reuse suitable static controls from [ui-primitives](../../../../packages/client/ui-primitives/README.md), following the [client rules](../../../../packages/client/AGENTS.md). Update all consumers of any changed shared props. Keep the containment treatment specific to the requested attachment presentation so existing Chat and tool-image sizing remains covered by regression checks. - -This scope uses existing durable attachment references. It introduces no upload, storage, model-request, or Session-format change, and no ordinary-file content viewer or download action. Styles follow [Web styling](../../../../docs/web-styling.md); display text, accessibility labels, and tooltips use typed locale dictionaries. - -### Existing decisions - -The [generic file upload decision](../../implemented/feature/2026-08-26-generic-file-upload.md) remains authoritative for upload, storage, admission, and ordered message attachments. This proposal revises only its Trajectory presentation and does not fully supersede that record. The [unreadable attachment quarantine proposal](2026-08-20-attachment-read-quarantine.md) concerns model-request recovery and remains independent of thumbnail loading feedback. - -## Implementation plan - -The steps below define the delivery sequence and completion evidence. They are not a record of commands already run. Use the current [development guide](../../../../docs/development.md), [testing policy](../../../../docs/testing.md), and linked workflows when executing them. - -| Step | Work | Completion evidence | -|---|---|---| -| 1. Establish the baseline | Verify the internal repository, live base, clean worktree, dependencies, and applicable rules; use an isolated feature branch. | The repository, branch, and base are identified and unrelated changes are excluded. | -| 2. Associate an Issue | Find the existing task or create its Issue with reproduction, scope, native Type, applicable Project fields, and the acceptance criteria below. | A real Issue owns progress and provides the PR reference. | -| 3. Map consumers | Trace Trajectory attachment data, Summary/Preview composition, Raw serialization, image slots, and any shared props. | Every affected consumer and test owner is identified before interfaces change. | -| 4. Implement presentation | Preserve typed attachment data, fix counts, introduce the shared attachment list, and align Raw disclosures in that order. | All four locations expose the specified information without changing durable message data. | -| 5. Add behavior evidence | Extend projection and component tests, then the recorded mixed-upload browser scenario; cover both inspector entry points and image interactions. | Assertions reject the missing-file, missing-count, ordering, and layout regressions. | -| 6. Update documentation | Update affected README pairs, JSDoc, and the owning decision records with the implementation; record bilingual consistency. | Documentation describes the implemented behavior, and this proposal is rewritten for its final lifecycle when implementation is complete. | -| 7. Validate the outgoing diff | Apply the [pre-push check workflow](../../../skills/dsh-pre-push-checks/SKILL.md) and the client check ladder; inspect the complete diff and changed expected outputs. | Relevant local checks pass, with commands and limitations recorded accurately. | -| 8. Commit and demonstrate | Review hook changes, commit normally, build the clean committed tree, and record one isolated real-server, real-model flow under the [GIF workflow](../../../skills/record-browser-gif/SKILL.md). Push normally and verify the remote head. | The reviewed GIF names the demonstrated commit and serving conditions, and the remote branch contains that commit. | -| 9. Publish and review | Fill the [PR template](../../../../.github/pull_request_template.md), link the Issue, select live kind/area labels, and attach evidence. Check the PR preview and CI; address review on the same branch. | The current head has reviewable evidence; fixes update affected checks and the GIF when its demonstrated commit changes. | -| 10. Merge and follow up | After review, satisfy live approval and merge rules, then follow mainline checks and Issue state. | The authorized merge is verified and any remaining work has an owner; release publication follows its separate workflow. | - -### Verification coverage - -| Layer | Planned coverage | -|---|---| -| Projection | Text with one image and one file; images only; files only; attachment-only mixed messages; empty text; multiple and repeated attachment occurrences. Counts and raw fields match the input. | -| Components | Summary and Preview show the same attachment names, order, metadata, and activation behavior. Raw preserves block order and complete fields. Cover absent image names, zero-byte files, long filenames, loading, failure, and retry. | -| Assembled application | Extend [file-upload-round](../../../../apps/web/tests/file-upload-round.e2e.ts) and its [recorded scenario](../../../../snapshots/web/file-upload-round/snapshot.yml) to inspect Summary, Preview, and Raw after the mixed upload and after reopening history. Keep the [shared image-cache check](../../../../apps/web/tests/trajectory-image-display.expected.e2e.ts). | -| Browser layout and accessibility | Verify narrow and wide inspector widths, light and dark themes, both locales, complete thumbnail containment, reachable final attachments, and keyboard activation and viewer dismissal. Keep geometry expectations beside their test owner under the [snapshot placement rules](../../../../snapshots/AGENTS.md). | -| Local checks | Run focused tests during implementation, then the required GUI and browser replay checks from the client rules, locale verification, documentation checks, and lint. Normal pre-push runs typecheck; CI owns exhaustive coverage and platform checks. | -| Demonstration | Record mixed submission, the ledger counts, Summary, Preview with image activation, and expanded Raw metadata from one real model session. Keep media outside the feature branch and attach it according to the GIF workflow. | - -## 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 is the proposed default. - -**Keep file JSON and image previews together in Raw.** This preserves the existing rendering, but assigns different meanings to the same tab for two attachment kinds. Consistent metadata disclosures let readers inspect recorded fields while Preview owns the visual image presentation. - -## Acceptance criteria - -- A message containing text, one image, and one ordinary file shows both counts in the ledger and both attachments in Summary and Preview. -- Summary and Preview share one attachment renderer; each provides the same ordered attachments and image actions despite their different spacing and containers. -- Attachment-only messages, repeated references, unnamed images, zero-byte files, and long filenames remain identifiable without invented metadata. -- Images fit completely inside compact thumbnails. Keyboard and pointer users can open and dismiss the existing image viewer; a failed image load remains visible and retryable. -- Raw preserves every original content block and its order. Image and file disclosures expose all recorded attachment fields without replacing them with generated labels or downloaded file contents. -- Narrow panels, both themes, and both locales remain usable. Existing Chat/tool-image behavior and per-session image cache reuse retain regression coverage. -- The implementation includes corresponding tests, reviewed keyless browser expectations, paired documentation, and a real-model GIF tied to the demonstrated PR head. - -## Risks - -Adding file references to view data can accidentally route them through image-only consumers or lose raw fields. Trace every affected discriminant and verify mixed and repeated occurrences. Compact thumbnails reduce immediate readability of diagrams, so full-image activation must remain discoverable. Shared renderer changes can affect Chat and tool results, while Summary's scroll container can hide final rows; both need assembled-browser checks. Missing recorded metadata must remain visibly absent, and image-read failures must not hide unrelated files. Unavailable model credentials block the real-model demonstration and must be reported rather than replaced with fixture footage. diff --git a/.agents/notes/proposed/bug-fix/2026-09-15-trajectory-attachment-presentation.zh.md b/.agents/notes/proposed/bug-fix/2026-09-15-trajectory-attachment-presentation.zh.md deleted file mode 100644 index cbd507235e..0000000000 --- a/.agents/notes/proposed/bug-fix/2026-09-15-trajectory-attachment-presentation.zh.md +++ /dev/null @@ -1,89 +0,0 @@ -# Agent Note: 统一 Trajectory 附件展示 - -Status: proposed - -[English](2026-09-15-trajectory-attachment-presentation.md) | 中文 - -## 问题 - -用户消息可以包含文本、图片和普通文件,但 Trajectory 列表与详情面板展示的消息内容并不完整一致。[列表投影](../../../../packages/client/ui-trajectory/src/client/layout.ts)在消息含有文本时省略图片数量。[详情渲染器](../../../../packages/client/ui-trajectory/src/client/TrajectoryTable.tsx)在预览中展示图片而不展示普通文件卡片,在原始内容中又把文件 JSON 与渲染后的图片混排。读者需要切换视图才能确认完整的附件集合。 - -概述标签页通过紧凑展示选项嵌入 `MarkdownRecordContent`,预览标签页使用同一组件,但不启用该选项。附件调整必须保留这一共享渲染器,使两个入口展示相同的内容和操作。 - -## 提案 - -交付一项覆盖列表、概述、预览和原始内容的 Trajectory 展示调整。本文不实现所提议的行为;执行状态和已完成的验证证据由 Issue #4356 及其关联 PR(Pull Request)记录。 - -### 展示行为 - -| 位置 | 提议的行为 | -|---|---| -| 列表 | 在文本摘要旁分别显示非零的图片数量与普通文件数量,包含有文本的消息。每条记录保持为紧凑单行。 | -| 概述与预览 | 消息正文之后展示一个有序附件列表。两个标签页使用相同的附件渲染器;概述可以保留紧凑间距和滚动容器。 | -| 附件列表 | 使用等宽的紧凑行,统一行高、间距和圆角。左侧放文件类型图标或完整缩放的图片缩略图,右侧放已记录的名称与可用元数据。保留每次附件出现的顺序,包括重复引用。 | -| 图片交互 | 图片完整缩放到缩略图内,不裁剪。激活时打开现有大图查看器;保留键盘访问、加载反馈和读取失败后的重试。 | -| 原始内容 | 保留原始内容块顺序与边界。图片块和文件块使用一致、默认收起的元数据折叠区,展开后显示完整记录字段。文本块保持未渲染文本;生成的显示标签不进入原始字段。 | - -有已记录文件名时使用文件名,否则为图片使用本地化的编号标签。类型与大小只在已记录数据支持时显示;保留零字节大小。长名称可以在视觉上截断,但完整名称必须能通过无障碍文本和工具提示获取。没有正文的消息仍须展示每个附件。多个附件在详情面板现有滚动区域内保持可达。 - -### 实现职责 - -[ui-trajectory](../../../../packages/client/ui-trajectory/README.zh.md)负责计数、有序附件投影和详情面板组合。通过其视图数据传递带类型的图片与文件引用,不从为展示准备的 JSON 中反向解析文件元数据。为原始内容保留完整的已记录附件字段。 - -[ui-attachment](../../../../packages/client/ui-attachment/README.zh.md)继续负责图片加载和查看器行为,使用 Conversation 拥有的会话级缓存。通过 slot 组合跨包 UI,并复用 [ui-primitives](../../../../packages/client/ui-primitives/README.zh.md) 中适用的静态控件,遵循[客户端规则](../../../../packages/client/AGENTS.md)。共享属性发生变化时,更新全部消费方。完整缩放处理限定于本次附件展示,并通过回归检查覆盖现有 Chat 和工具图片尺寸行为。 - -本次范围使用现有持久附件引用,不改变上传、存储、模型请求或会话格式,也不增加普通文件内容查看器或下载操作。样式遵循 [Web 样式规范](../../../../docs/web-styling.zh.md);显示文字、无障碍标签和工具提示使用带类型的本地化字典。 - -### 现有决定 - -[通用文件上传决定](../../implemented/feature/2026-08-26-generic-file-upload.zh.md)继续负责上传、存储、准入和有序消息附件。本提案只调整其中的 Trajectory 展示,并不完整取代该记录。[不可读附件隔离提案](2026-08-20-attachment-read-quarantine.zh.md)处理模型请求恢复,与缩略图加载反馈相互独立。 - -## 实施计划 - -以下步骤定义交付顺序与完成证据,不代表命令已经执行。执行时使用当前[开发指南](../../../../docs/development.zh.md)、[测试策略](../../../../docs/testing.zh.md)和所链接的工作流。 - -| 步骤 | 工作 | 完成证据 | -|---|---|---| -| 1. 确定基线 | 核对内部仓库、实时基线、干净的 worktree、依赖和适用规则;使用隔离的功能分支。 | 仓库、分支和基线明确,未混入无关修改。 | -| 2. 关联 Issue | 查找现有任务或创建 Issue,写明复现、范围、原生 Type、适用的 Project 字段和下列验收标准。 | 真实 Issue 负责进度,并作为 PR 的引用。 | -| 3. 梳理消费方 | 跟踪 Trajectory 附件数据、概述与预览组合、原始内容序列化、图片 slot 和共享属性。 | 接口修改前,已明确全部受影响消费方与测试归属。 | -| 4. 实现展示 | 依次保留带类型的附件数据、修正计数、引入共享附件列表、统一原始内容折叠区。 | 四处位置均展示规定信息,持久消息数据不变。 | -| 5. 补行为证据 | 扩展投影与组件测试,再扩展已录制的混合上传浏览器场景;覆盖两个详情入口和图片交互。 | 断言能够检出文件遗漏、计数遗漏、顺序及布局回归。 | -| 6. 更新文档 | 随实现更新受影响的 README 双语文件、JSDoc 和所属决定记录,并记录双语一致性。 | 文档描述已实现行为,完整实现后按最终生命周期改写本提案。 | -| 7. 验证待提交差异 | 应用[推送前检查工作流](../../../skills/dsh-pre-push-checks/SKILL.md)和客户端检查层级;检查完整差异与变化的预期输出。 | 相关本地检查通过,准确记录命令与限制。 | -| 8. 提交与演示 | 检查 hook 修改,正常提交,构建干净的已提交代码树,并按 [GIF 工作流](../../../skills/record-browser-gif/SKILL.md)录制一次隔离的真实服务、真实模型流程。正常推送并核对远端提交。 | 已检查的 GIF 标明演示提交和服务条件,远端分支包含该提交。 | -| 9. 发布与评审 | 填写 [PR 模板](../../../../.github/pull_request_template.md),关联 Issue,选择当前 kind/area 标签并附证据。检查 PR 预览和 CI;在同一分支处理评审。 | 当前提交具有可评审证据;修复后更新受影响检查,演示提交变化时更新 GIF。 | -| 10. 合并与跟进 | 评审后满足实时审批与合并规则,再跟进主线检查和 Issue 状态。 | 已核实获授权的合并,剩余工作有明确归属;发行走独立流程。 | - -### 验证覆盖 - -| 层级 | 计划覆盖 | -|---|---| -| 投影 | 文本加一张图片与一个文件、只有图片、只有文件、无正文的混合附件、空文本、多个附件与重复附件。计数和原始字段与输入一致。 | -| 组件 | 概述与预览显示相同的附件名称、顺序、元数据与激活行为。原始内容保留块顺序和完整字段。覆盖图片无名称、零字节文件、长文件名、加载、失败与重试。 | -| 组装应用 | 扩展 [file-upload-round](../../../../apps/web/tests/file-upload-round.e2e.ts) 及其[录制场景](../../../../snapshots/web/file-upload-round/snapshot.yml),在混合上传后和重新打开历史后检查概述、预览与原始内容。保留[共享图片缓存检查](../../../../apps/web/tests/trajectory-image-display.expected.e2e.ts)。 | -| 浏览器布局与无障碍 | 验证窄版与宽版详情面板、浅色与深色主题、两种语言、缩略图完整缩放、末尾附件可达,以及键盘激活和关闭查看器。按[快照存放规则](../../../../snapshots/AGENTS.md),把几何预期留在所属测试旁。 | -| 本地检查 | 实现期间运行针对性测试,然后执行客户端规则要求的 GUI 与浏览器回放检查、本地化验证、文档检查和 lint。正常 pre-push 执行类型检查;CI 负责全量覆盖率与平台检查。 | -| 演示 | 在同一个真实模型会话中录制混合提交、列表计数、概述、预览中的图片激活,以及展开的原始元数据。媒体保存在功能分支之外,按 GIF 工作流附到 PR。 | - -## 考虑过的替代方案 - -**默认在详情面板中内联展示大图。** 这样可以立即阅读单张图片,但高图片会在窄面板中挤走附件名称和消息正文。紧凑行加显式图片激活入口能一致地概览混合附件。 - -**使用双列附件卡片。** 网格能在宽面板上突出缩略图,但会压缩长文件名空间,并要求窄面板采用不同排布。提案默认使用单列等宽列表。 - -**在原始内容中继续混排文件 JSON 和图片预览。** 这保留了现有渲染,却让同一个标签页对两种附件具有不同含义。一致的元数据折叠区用于检查已记录字段,图片视觉展示由预览负责。 - -## 验收标准 - -- 同时包含文本、一张图片和一个普通文件的消息,在列表显示两种数量,在概述和预览显示两个附件。 -- 概述与预览共用一个附件渲染器;即使间距和容器不同,也提供相同的有序附件和图片操作。 -- 纯附件消息、重复引用、无名称图片、零字节文件和长文件名保持可识别,不编造元数据。 -- 图片完整缩放到紧凑缩略图内。键盘和指针用户均可打开并关闭现有图片查看器;图片加载失败时仍有可见反馈并可重试。 -- 原始内容保留每个原始内容块及其顺序。图片与文件折叠区展示全部已记录附件字段,不以生成标签或下载的文件内容替代。 -- 窄面板、两种主题和两种语言下均可使用。现有 Chat/工具图片行为和会话级图片缓存复用保留回归覆盖。 -- 实现包含相应测试、经审阅的无密钥浏览器预期输出、双语文档,以及与演示 PR 当前提交对应的真实模型 GIF。 - -## 风险 - -在视图数据中加入文件引用,可能误将文件传给只接受图片的消费方,或丢失原始字段。需要跟踪每个受影响的类型标记,并验证混合与重复附件。紧凑缩略图降低了图表的直接可读性,因此大图激活入口必须容易发现。共享渲染器改动可能影响 Chat 和工具结果,概述滚动容器也可能遮挡末尾行;两者都需要组装浏览器检查。缺失的记录元数据必须保持明确缺失,图片读取失败不能隐藏其他文件。模型凭证不可用时,真实模型演示会受阻,必须如实说明,不能用 fixture(测试前置数据)录屏替代。 diff --git a/packages/client/ui-trajectory/README.i18n.yaml b/packages/client/ui-trajectory/README.i18n.yaml index 1834635f5a..a41ed11a2c 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: f11bfa3188ce616e392158f9f2cf33920edea8ba -README.zh.md: 53672cc8e4c8891ff6ea9b985b4efafc0368e1bf +README.md: 1ddfe6198d37b8e61b44507ab02cbe5a08cafc40 +README.zh.md: 68ea4d7fa8711296407b3881300c7343514a62cc diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md index f11bfa3188..1ddfe6198d 100644 --- a/packages/client/ui-trajectory/README.md +++ b/packages/client/ui-trajectory/README.md @@ -35,6 +35,8 @@ Selection, timeline navigation, folding, and search cover the React-visible wind 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. +Selecting another record resets its Raw attachment disclosures to collapsed. Updates to the selected record, including loading older history, preserve its disclosure state. + ### The timing overview Historical replies retain TTFT, generation duration, and throughput when their recorded streams contain token timestamps. TTFT measures from the Step start to its first token, including output from an earlier retry attempt; an unloaded Step start or a stream without tokens leaves the corresponding metric unavailable. diff --git a/packages/client/ui-trajectory/README.zh.md b/packages/client/ui-trajectory/README.zh.md index 53672cc8e4..68ea4d7fa8 100644 --- a/packages/client/ui-trajectory/README.zh.md +++ b/packages/client/ui-trajectory/README.zh.md @@ -35,6 +35,8 @@ Trajectory 标签页让你以按轮次组织的事件记录表和交互式时间 概述与预览共用正文之后的有序附件列表,保留重复引用。每行展示完整缩放的图片缩略图或文件类型图标、已记录的文件名(未命名图片使用本地化的编号名称),以及可用的已记录大小、类型和图片尺寸。零字节文件保留大小显示,截断的文件名通过提示显示完整名称。图片打开现有灯箱。原始内容保留内容块顺序和未渲染文本,图片与文件使用默认折叠的展开项,包含完整的已记录字段。 +选择另一条记录时,其原始内容中的附件折叠区恢复收起状态。当前记录更新时,包括加载更早历史时,折叠状态保持不变。 + ### 时间概览 当记录的流包含 token 时间戳时,历史回复会保留 TTFT、生成耗时与吞吐量。TTFT 从步骤开始计量到首个 token,包括更早重试尝试中的输出;步骤起点尚未加载或流中没有 token 时,对应指标保持不可用。 From 70b4237c00fe4b807ab4a261e34bd35f43cd9568 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 17 Sep 2026 15:44:43 +0800 Subject: [PATCH 6/9] fix(web): keep trajectory thumbnail status readable Refs #4356 --- apps/web/tests/file-upload-round.e2e.ts | 47 ++++++++++++++++++- .../ui-attachment/src/MessageImage.module.css | 16 +++++++ .../client/ui-attachment/src/MessageImage.tsx | 29 ++++++++++-- .../tests/message-image.client.spec.tsx | 29 +++++++++++- .../client/ui-trajectory/src/client/layout.ts | 10 ++-- .../ui-trajectory/src/client/locales.ts | 4 +- .../trajectory-status.expected.md | 15 ++++++ 7 files changed, 135 insertions(+), 15 deletions(-) create mode 100644 snapshots/web/file-upload-round/trajectory-status.expected.md diff --git a/apps/web/tests/file-upload-round.e2e.ts b/apps/web/tests/file-upload-round.e2e.ts index bedab5ea9d..c25211dd13 100644 --- a/apps/web/tests/file-upload-round.e2e.ts +++ b/apps/web/tests/file-upload-round.e2e.ts @@ -12,7 +12,7 @@ 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 { acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, @@ -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)) @@ -385,11 +386,53 @@ describe('web e2e: generic file upload through the real assembly', () => { 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/src/MessageImage.module.css b/packages/client/ui-attachment/src/MessageImage.module.css index 2ed6fbe507..f551605a4a 100644 --- a/packages/client/ui-attachment/src/MessageImage.module.css +++ b/packages/client/ui-attachment/src/MessageImage.module.css @@ -71,6 +71,8 @@ .frame[data-variant='thumbnail'], .error[data-variant='thumbnail'] { box-sizing: border-box; + display: grid; + place-items: center; width: 48px; height: 48px; min-width: 48px; @@ -84,6 +86,20 @@ 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); diff --git a/packages/client/ui-attachment/src/MessageImage.tsx b/packages/client/ui-attachment/src/MessageImage.tsx index 3efa5737fa..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' @@ -73,6 +74,7 @@ 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. @@ -122,7 +124,21 @@ export function MessageImage({ image, load, variant, labels }: { const src = preview?.url ?? loaded const label = ('attachment' in image ? image.label : undefined) ?? preview?.name ?? attachment?.name ?? labels.image - if (error) return + const loadingThumbnail = variant === 'thumbnail' && src === null + if (error) return ( + + ) return ( <> {open && src !== null && } 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 e0fec746aa..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' @@ -151,6 +151,33 @@ describe('MessageImage', () => { 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') diff --git a/packages/client/ui-trajectory/src/client/layout.ts b/packages/client/ui-trajectory/src/client/layout.ts index 432ba0653d..e99b96a962 100644 --- a/packages/client/ui-trajectory/src/client/layout.ts +++ b/packages/client/ui-trajectory/src/client/layout.ts @@ -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 = [ images > 0 - ? t('layout.imageOnly', { count: images }) + ? 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 '' } @@ -1089,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') } } @@ -1116,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 80ddf8fe7d..2e891ff981 100644 --- a/packages/client/ui-trajectory/src/client/locales.ts +++ b/packages/client/ui-trajectory/src/client/locales.ts @@ -182,7 +182,7 @@ export const zh = { 'layout.toolCallOnly': '仅工具调用', 'attachment.list': '附件', 'attachment.imageName': '图片 {index}', - 'layout.imageOnly': '图片 ×{count}', + 'layout.imageCount': '图片 ×{count}', 'layout.fileAttachments': '文件 ×{count}', 'layout.initialSystemPrompt': '初始系统提示词', 'layout.systemPromptUpdated': '系统提示词已更新', @@ -384,7 +384,7 @@ export const en: Record = { 'layout.toolCallOnly': 'Tool call only', 'attachment.list': 'Attachments', 'attachment.imageName': 'Image {index}', - 'layout.imageOnly': 'Images ×{count}', + 'layout.imageCount': 'Images ×{count}', 'layout.fileAttachments': 'Files ×{count}', 'layout.initialSystemPrompt': 'Initial System Prompt', 'layout.systemPromptUpdated': 'System Prompt Updated', 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 From 9836c4daafcb3643b520c72c31b2db8a213dd3b0 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 17 Sep 2026 15:45:33 +0800 Subject: [PATCH 7/9] docs(web): describe trajectory thumbnail status feedback Refs #4356 --- .../2026-09-15-trajectory-attachment-presentation.i18n.yaml | 4 ++-- .../bug-fix/2026-09-15-trajectory-attachment-presentation.md | 2 +- .../2026-09-15-trajectory-attachment-presentation.zh.md | 2 +- packages/client/ui-attachment/README.i18n.yaml | 4 ++-- packages/client/ui-attachment/README.md | 2 +- packages/client/ui-attachment/README.zh.md | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) 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 index 0254ff4b08..0a3a425300 100644 --- 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 @@ -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/bug-fix/2026-09-15-trajectory-attachment-presentation.md -2026-09-15-trajectory-attachment-presentation.md: 4a1528a230c0ded38d26d0859a181ebaa208180d -2026-09-15-trajectory-attachment-presentation.zh.md: e6accddb292613d9688b66847b6d6834ad6636c6 +2026-09-15-trajectory-attachment-presentation.md: fdf22a74ed2794cdea08a9c98a0eb384f992c447 +2026-09-15-trajectory-attachment-presentation.zh.md: d7088cd80dcf3eccde2abcccfff3eaaf89393ac2 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 index 4a1528a230..fdf22a74ed 100644 --- 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 @@ -17,7 +17,7 @@ Trajectory presents every attachment occurrence while keeping visual inspection | 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 feedback, and retry after a failed read. | +| 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. 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 index e6accddb29..d7088cd80d 100644 --- 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 @@ -17,7 +17,7 @@ Trajectory 展示每次附件出现的记录,并分别提供视觉预览与已 | 列表 | 文本摘要旁分别显示非零的图片数量与普通文件数量,无正文的消息也显示计数。每条记录保持为紧凑单行。 | | 概述与预览 | 消息正文之后展示一个有序附件列表。两个标签页使用相同的渲染器;概述保留紧凑间距和滚动容器。 | | 附件列表 | 等宽的紧凑行使用一致的间距和圆角。文件类型图标或完整缩放的图片缩略图旁展示已记录的名称与可用元数据。重复引用保留各次出现的顺序。 | -| 图片交互 | 图片完整缩放到 48px 方形缩略图内,不裁剪。激活时打开现有图片查看器,保留键盘访问、加载反馈和读取失败后的重试。 | +| 图片交互 | 图片完整缩放到 48px 方形缩略图内,不裁剪。激活时打开现有图片查看器,保留键盘访问。加载与重试图标适配缩略图尺寸,并提供本地化的工具提示和可访问名称。 | | 原始内容 | 图片块和文件块使用默认收起的元数据折叠区,包含完整记录字段。原始内容块顺序与未渲染文本保持完整;生成的显示标签不进入原始字段。 | 已记录文件名优先于本地化的编号图片标签。元数据来自记录字段或文件扩展名,零字节大小仍然可见。视觉上截断的名称保留完整的无障碍文本和工具提示。无正文的消息仍在详情面板滚动区域内展示全部附件。 diff --git a/packages/client/ui-attachment/README.i18n.yaml b/packages/client/ui-attachment/README.i18n.yaml index a8548bfafa..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: 8272703a56d05295e3cb14f3441bce1eeafe1629 -README.zh.md: ae8d687448f947306830982a770f7f54e019361f +README.md: 0765068c73c8ca3cf79b8b23a54629669625ca2e +README.zh.md: 5976acfda5b605dd85ba3d43e18a83af4d9d58e0 diff --git a/packages/client/ui-attachment/README.md b/packages/client/ui-attachment/README.md index 8272703a56..0765068c73 100644 --- a/packages/client/ui-attachment/README.md +++ b/packages/client/ui-attachment/README.md @@ -35,7 +35,7 @@ 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 controls keep the same box, and 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. +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 diff --git a/packages/client/ui-attachment/README.zh.md b/packages/client/ui-attachment/README.zh.md index ae8d687448..5976acfda5 100644 --- a/packages/client/ui-attachment/README.zh.md +++ b/packages/client/ui-attachment/README.zh.md @@ -35,7 +35,7 @@ kind: "package-reference" Chat 中的一条用户消息把文件与图片放在同一个靠右、可换行的排列中,并保持来源顺序。消息仅有一张图片且没有其他附件时,图片按长边 240px 渲染(宽高比钳制在 [0.25, 4],从不放大);消息有多个附件时,每张图片显示为固定 64px 方块,与 240×64px 文件卡同排。加载完成的图片单击打开文档级灯箱;加载失败则显示重试控件。灯箱按 Escape、按下遮罩或点关闭按钮关闭,并把焦点还给打开者。 -Trajectory 附件行使用 48px 方形缩略图,完整缩放图片而不裁剪。加载与重试控件保持相同尺寸,图片打开同一个灯箱。插槽持有方可以为缩略图与灯箱提供仅用于展示的图片名称,而不改变持久化引用或缓存查询。 +Trajectory 附件行使用 48px 方形缩略图,完整缩放图片而不裁剪。加载与重试图标保持相同尺寸,提供本地化的工具提示和可访问名称;图片打开同一个灯箱。插槽持有方可以为缩略图与灯箱提供仅用于展示的图片名称,而不改变持久化引用或缓存查询。 ### 拖放遮罩 From 03f93dc2a56b2a4d12f8350f5226dcf5b00d3e49 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 17 Sep 2026 16:52:41 +0800 Subject: [PATCH 8/9] revert(web): remove raw disclosure reset on record selection Refs #4356 --- .../src/client/TrajectoryTable.tsx | 1 - .../ui-trajectory/tests/table.client.spec.tsx | 55 ------------------- 2 files changed, 56 deletions(-) diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index e1918d62cd..5308e04d76 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -1533,7 +1533,6 @@ function MarkdownRecordContent({ if (!rendered && record.cell.sourceBlocks && record.cell.sourceBlocks.length > 0) { return ( { }, ) - it('resets raw attachment disclosures on record selection but keeps them through history updates', () => { - const content = [ - { - type: 'image' as const, - attachment: { - attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), - mediaType: 'image/png' as const, - width: 40, - height: 80, - bytes: 68, - name: 'shared.png', - }, - }, - { - type: 'file' as const, - attachment: { - attachmentId: AttachmentId(`sha256:${'b'.repeat(64)}`), - name: 'shared.txt', - bytes: 1, - }, - }, - ] - const nodes = ['First message', 'Second message'].map((text, index) => ({ - kind: 'user' as const, - seq: index + 2, - time: (index + 2) * 1000, - source: { kind: 'user' as const }, - content: [{ type: 'text' as const, text }, ...content], - })) - const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }, t) - const view = render() - fireEvent.click(screen.getByRole('row', { name: /First message/ })) - fireEvent.click(screen.getByRole('tab', { name: 'Raw' })) - const disclosures = () => [...screen.getByRole('tabpanel').querySelectorAll('details')] - expect(disclosures()).toHaveLength(2) - expect(disclosures().every(disclosure => !disclosure.open)).toBe(true) - for (const disclosure of disclosures()) fireEvent.click(disclosure.querySelector('summary')!) - expect(disclosures().every(disclosure => disclosure.open)).toBe(true) - - const older = { ...nodes[0]!, seq: 1, time: 1000, content: [{ type: 'text' as const, text: 'Older message' }] } - const expandedTurns = deriveTrajectoryLayout({ nodes: [older, ...nodes], partial: null, runningCalls: [] }, t) - view.rerender() - expect(within(screen.getByRole('tabpanel')).getByText('First message', { exact: true })).toBeTruthy() - expect(disclosures().every(disclosure => disclosure.open)).toBe(true) - - fireEvent.click(screen.getByRole('row', { name: /Second message/ })) - expect(screen.getByRole('tab', { name: 'Raw' }).getAttribute('aria-selected')).toBe('true') - expect(within(screen.getByRole('tabpanel')).getByText('Second message', { exact: true })).toBeTruthy() - expect(disclosures()).toHaveLength(2) - expect(disclosures().every(disclosure => !disclosure.open)).toBe(true) - for (const disclosure of disclosures()) fireEvent.click(disclosure.querySelector('summary')!) - fireEvent.click(screen.getByRole('row', { name: /First message/ })) - expect(disclosures().every(disclosure => !disclosure.open)).toBe(true) - }) - it('renders a tool-result image through the shared gallery in the Result tab', () => { const attachment = { attachmentId: `sha256:${'b'.repeat(64)}`, From 70ba425ba2cdaac6b411de94a3587973b6282b0e Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 17 Sep 2026 16:53:50 +0800 Subject: [PATCH 9/9] docs(web): remove raw disclosure reset guarantees Refs #4356 --- .../2026-09-15-trajectory-attachment-presentation.i18n.yaml | 4 ++-- .../bug-fix/2026-09-15-trajectory-attachment-presentation.md | 4 ++-- .../2026-09-15-trajectory-attachment-presentation.zh.md | 4 ++-- packages/client/ui-trajectory/README.i18n.yaml | 4 ++-- packages/client/ui-trajectory/README.md | 2 -- packages/client/ui-trajectory/README.zh.md | 2 -- 6 files changed, 8 insertions(+), 12 deletions(-) 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 index 0a3a425300..cc6eb7ff26 100644 --- 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 @@ -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/bug-fix/2026-09-15-trajectory-attachment-presentation.md -2026-09-15-trajectory-attachment-presentation.md: fdf22a74ed2794cdea08a9c98a0eb384f992c447 -2026-09-15-trajectory-attachment-presentation.zh.md: d7088cd80dcf3eccde2abcccfff3eaaf89393ac2 +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 index fdf22a74ed..03d737d67e 100644 --- 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 @@ -42,13 +42,13 @@ The [generic file upload decision](../feature/2026-08-26-generic-file-upload.md) ## 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. Selecting another record resets Raw attachment disclosures; updates to the selected record, including loading older history, preserve their state. +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, complete Raw fields, and disclosure state across record selection and history updates. [Image tests](../../../../packages/client/ui-attachment/tests/message-image.client.spec.tsx) cover thumbnail labels, loading, retry, and the existing viewer. +[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. 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 index d7088cd80d..55c63bf3d9 100644 --- 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 @@ -42,13 +42,13 @@ Trajectory 展示每次附件出现的记录,并分别提供视觉预览与已 ## 影响 -共享附件渲染器使概述与预览中的名称、顺序、元数据和图片操作保持一致。将附件集中在消息正文之后便于浏览完整集合;需要原始块顺序时,原始内容保留交错关系。即使图片加载共享缓存项,重复引用仍各占一行。选择另一条记录时,原始内容中的附件折叠区恢复收起状态;当前记录更新时,包括加载更早历史时,折叠状态保持不变。 +共享附件渲染器使概述与预览中的名称、顺序、元数据和图片操作保持一致。将附件集中在消息正文之后便于浏览完整集合;需要原始块顺序时,原始内容保留交错关系。即使图片加载共享缓存项,重复引用仍各占一行。 紧凑缩略图需要打开查看器才能阅读详细图表。缺失的元数据保持缺失,图片读取失败保留重试控件,并且不隐藏普通文件。共享图片 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)覆盖缩略图标签、加载、重试和现有查看器。 +[投影测试](../../../../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 之间的复用。 diff --git a/packages/client/ui-trajectory/README.i18n.yaml b/packages/client/ui-trajectory/README.i18n.yaml index e8cac5bdb5..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: 14a227fbab69032ba25aa6773e8663d12404b965 -README.zh.md: 492b37983489740d163a500a493c2c76528683cd +README.md: b58466f0c38efef89ff3aa2b5d6da49b6c85ac86 +README.zh.md: ab9ecbfd26478c30c9078a25f92bd121245b7bf9 diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md index 14a227fbab..b58466f0c3 100644 --- a/packages/client/ui-trajectory/README.md +++ b/packages/client/ui-trajectory/README.md @@ -35,8 +35,6 @@ Selection, timeline navigation, folding, and search cover the React-visible wind 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. -Selecting another record resets its Raw attachment disclosures to collapsed. Updates to the selected record, including loading older history, preserve its disclosure state. - 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. ### The timing overview diff --git a/packages/client/ui-trajectory/README.zh.md b/packages/client/ui-trajectory/README.zh.md index 492b379834..ab9ecbfd26 100644 --- a/packages/client/ui-trajectory/README.zh.md +++ b/packages/client/ui-trajectory/README.zh.md @@ -35,8 +35,6 @@ Trajectory 标签页让你以按轮次组织的事件记录表和交互式时间 概述与预览共用正文之后的有序附件列表,保留重复引用。每行展示完整缩放的图片缩略图或文件类型图标、已记录的文件名(未命名图片使用本地化的编号名称),以及可用的已记录大小、类型和图片尺寸。零字节文件保留大小显示,截断的文件名通过提示显示完整名称。图片打开现有灯箱。原始内容保留内容块顺序和未渲染文本,图片与文件使用默认折叠的展开项,包含完整的已记录字段。 -选择另一条记录时,其原始内容中的附件折叠区恢复收起状态。当前记录更新时,包括加载更早历史时,折叠状态保持不变。 - 检查器中的思考内容使用紧凑 Markdown,沿用检查器固定的 13px 字号和 20px 行高,不受内容字号设置影响。标题只加粗,不放大字号或行高。助手输出保留常规 Markdown 排版。 ### 时间概览