diff --git a/docs/design/web-shell-export-artifact.md b/docs/design/web-shell-export-artifact.md new file mode 100644 index 00000000000..8631bf16fb6 --- /dev/null +++ b/docs/design/web-shell-export-artifact.md @@ -0,0 +1,98 @@ +# Session artifacts for `/export` in the Web Shell + +[English](web-shell-export-artifact.md) | [简体中文](web-shell-export-artifact.zh-CN.md) + +Status: implemented with repository regression tests. Browser verification records are local working evidence, not included in this repository. + +## Goal and compatibility + +After `/export md|html|json|jsonl`, the Web Shell must show the exported file in the command's turn, with the existing artifact preview and download controls. Keep the CLI's argument parsing, path validation, formatting, file permissions, output location, and success/error text unchanged. Interactive and non-interactive CLI invocations return the original result without artifact metadata; only ACP invocations add descriptors after a successful write. + +The sidebar export action and HTTP export routes remain unchanged. No new storage kind, filesystem writer, command implementation, daemon route, or dependency is needed. + +## Reassessment of the initial implementation + +The original approach correctly reused workspace artifacts and carried structured descriptors rather than parsing success text. However, registering an artifact did not make it discoverable: the turn-output selector only associated artifacts with tool calls, while `/export` has no tool call. A real local daemon reproduced this: its catalog contained an available HTML artifact, but the browser offered no card or preview entry. + +The original text-pagination design was also incorrect. A capped text read can return `truncated: true`, `hasMore: true`, and no cursor, including for a long HTML line. Text windows are not a lossless byte stream; concatenating them cannot promise the original document. Treating the missing cursor as completion silently truncated the preview. The fake paging test did not model that contract. + +A browser E2E check exposed a third incompatibility: exported HTML loads its renderer and stylesheet from a versioned CDN, while the generic artifact sandbox intentionally blocks external resources. A visible card alone therefore did not make a real chat export previewable. + +Other gaps were missing metadata in recorded command results, registration mounted only in the main view, and failed registration only becoming eligible again after a session change; the current implementation also permits another attempt after reconnecting. + +## Design + +### Export and transport + +The existing export command writes exactly the same UTF-8 file. In ACP mode, it additionally returns its title, format kind, canonical workspace-relative path, MIME type, and byte size. Markdown, JSON, and JSONL are explicit exports, so they do not use the narrower extension allowlist intended for automatic tool artifacts. + +The non-interactive command dispatcher forwards the descriptor. The ACP message emitter includes it under `_meta.sessionArtifacts` with `source: slash_command`. The key is separate from `_meta.artifacts`, which the bridge reserves for ingestion from terminal tool updates. No fake tool call is emitted and the existing ingestion or publication trust rules are not widened. + +The ACP result recorder stores `sessionArtifacts` in the existing command output-history item. The shared replay projector restores the same metadata, so the client can associate the saved artifact with its command after history reload, including daemon restart. Other command records continue to replay unchanged. Only the ordinary message-result path is required: `/export` does not return streamed command messages. + +### Registration and visible output + +A shared parser accepts only slash-command descriptors for workspace files of kind `file` or `html`. It rejects malformed descriptors, absolute paths, parent traversal in either slash convention, and alternative locators. Literal backslashes in POSIX directory names are preserved. Drive-like prefixes such as a POSIX directory named `c:` are conservatively rejected by the cross-platform descriptor parser. The daemon's artifact API remains authoritative for path validation, workspace ownership, quotas, and persistence. + +Registration runs with the existing session-artifact hook, including split panes. It waits for the session catalog and transcript catch-up, skips paths already registered, and binds asynchronous results to the captured session owner. This avoids rewriting an existing artifact's client ownership when another browser opens the session. Concurrent registration by another client can return `403 session_artifact_forbidden`. Refresh the current owner's catalog and treat the conflict as success only when it contains the exported workspace path; all other failures retain a localized error toast. Overlapping successful refreshes can confirm registration without overwriting a newer catalog response. StrictMode effect replay does not submit the same path twice. A failed path remains marked attempted for the current owner/session/connection, so subsequent transcript updates do not repeatedly submit it or show repeated error toasts. Reconnecting or remounting allows another attempt. There is no polling loop or background retry service. + +The transcript presentation adapter retains reported artifact references on assistant messages. Output carrying valid export descriptors stays literal instead of entering insight-JSON segmentation, including when an export directory name resembles insight metadata. Other commands such as `/insight` retain their existing progress/report rendering. The turn-output selector matches those workspace paths against the actual catalog and attaches the existing `TurnOutputs` cards to the slash-command turn. An advisory descriptor alone cannot create a clickable card: the catalog must contain the artifact. Preview, download, workspace selection, and sandboxed HTML rendering continue through the shared artifact components. + +### Complete previews + +Keep the daemon text reader for small files and its encoding detection. Request an explicit 256 KiB window. If the result is truncated, reuse `readWorkspaceFileAsBlob`, the existing byte-download reader, and decode the complete bytes with the encoding reported by the daemon. + +This preserves long lines, Unicode, BOM handling, and newline boundaries. It reuses the download reader's 100 MiB ceiling, cancellation checks, workspace-owner guard, and file size/mtime consistency checks. Oversized or changing files show an error rather than a partial HTML document presented as complete; failed reads stop showing the loading placeholder. Download continues to return the original bytes. No text cursors are concatenated. + +Preview and download share sequential 256 KiB byte windows, matching the existing endpoint limit (previously the client requested 100 KiB). No server limit or concurrency change is required. + +### HTML chat-export compatibility + +All HTML previews, including chat exports, use the original document in an opaque sandboxed iframe. There is no host transcript reconstruction. The trusted outer frame continues to block child navigation; neither frame receives `allow-same-origin`. + +For documents with `script#transcript-document[type="application/json"]`, the trusted Web Shell loader reads only the expected renderer/stylesheet elements. Their URLs must exactly match `https://unpkg.com/@qwen-code/qwen-code@/export-transcript-document.js` and `.css`, without queries or fragments, and include SHA-384 integrity metadata. The build version is trusted application configuration, not document input: arbitrary version paths cannot carry document data to the CDN. Exports targeting another version do not automatically render; original files and downloads remain unchanged. Large exports retain the default source preview. + +The parent page fetches these two resources using the original SRI values, omitted credentials, no referrer, and redirect rejection. Browser integrity verification must succeed before bytes are embedded as data URLs. The iframe receives no CDN allowlist: its policy permits inline/data scripts and styles while network requests remain blocked. A second external element or dynamically created request therefore gains no network permission. Loading failures produce an error rather than a perpetually loading preview. The original export and downloaded bytes are not rewritten. + +Production Web Shell headers permit data scripts/styles and parent fetches to the Qwen package namespace. The child policy does not inherit that network allowance as an effective permission because its own `default-src 'none'` further restricts it. Vite retains its original CSP. Fixing chat-export HTML rendering in development is deferred; this version does not claim support for it. Embedded hosts need compatible parent policies. The header change affects Web Shell serving, not interactive CLI export logic. + +The added parent-page permissions apply to the entire shell, not only this loader, and require explicit maintainer acceptance. The child sandbox does not remove that parent-page tradeoff. This implementation reuses the original export asset URLs and SRI; same-origin resources are an alternative, not ruled out by the library build. Although the package ships renderer files beside the Web Shell directory, the current static middleware does not expose them. A same-origin approach needs an explicit resource-serving contract for daemon and embedded hosts, and must still account for exported integrity/renderer identity; a matching package version alone does not guarantee matching bytes. Inlining verified assets could remove the added data-script/style permissions, but would not remove CDN fetch permission while assets still come from the CDN. + +Source builds can reference unpublished renderer hashes despite using a published package version. Such exports fail SRI both in an iframe and when downloaded and opened directly: the package version alone does not identify the local build's bytes. The existing export build supports explicit delegation via `QWEN_EXPORT_RENDERER_IDENTITY`, `QWEN_EXPORT_RENDERER_INTEGRITY`, and `QWEN_EXPORT_RENDERER_CSS_INTEGRITY`. Changing renderer publication or build selection is outside this artifact integration. No new build command, automatic delegation, SRI bypass, or rewriting of old files is included. Download returns original bytes. A running daemon/session process can retain its imported template after disk outputs change; frontend reload alone does not reload that backend template. + +Markdown renders directly with the existing Markdown component and styles in the host page, preserving image handling, footnote preview cards, and interactive controls. It does not serialize React markup or use a Markdown iframe. HTML retains its sandboxed iframe. Large Markdown files default to source before the Markdown component mounts; explicit full rendering still runs on the main thread. + +### Large-file rendering + +Chunked reads do not make rendering incremental: after reading the complete file, the old preview still parsed and mounted the entire document. The actual 1.9 MiB chat export mounted 757 message rows and roughly 40,000 DOM nodes; the actual 4.5 MB Markdown blocked browser interaction. + +HTML and Markdown use one byte-size rule: files above 1 MiB (1,048,576 bytes) default to complete read-only source. Files exactly at the limit retain rendered preview. Workspace files use the daemon's reported file size, or the full downloaded blob's size; attachments retain the original blob size before decoding, and content-only previews without file metadata use their UTF-8 byte size. Character and line-count heuristics are removed. + +The guard applies before HTML parsing, including CSP wrapping and Markdown conversion. A button at the right edge of the localized notice allows full rendered preview and switching back to source. Ordinary HTML retains its existing sandbox. Chat-export HTML retains the existing document renderer when rendering is requested; no new chat virtualizer or transcript scrolling behavior is introduced. The source preview reuses the existing CodeMirror editor in a bounded viewport. This rendering rule applies to HTML/Markdown from ordinary artifacts, attachments, and slash-command exports. JSON, JSONL, and other text formats already use the same read-only source editor; they have no separate rendered view to switch off. Download is unchanged. + +## Constraints and risks + +- Registration still requires a connected Web Shell client. The file is written by the CLI regardless; other ACP clients can ignore the advisory metadata. This is intentionally a Web Shell integration, not a new daemon artifact producer. +- Files outside the bound workspace cannot be represented as workspace artifacts. The CLI export behavior remains unchanged in that case, and no descriptor is advertised. Worktree/runtime mapping must not fall back to the primary workspace. +- Existing artifact retention, capacity, deletion, and missing-file behavior remain authoritative. Export descriptors can be replayed, so an unregistered path can be registered again when the history is reopened; explicit durable removal semantics are not added here. +- Complete byte fallback applies to all workspace text previews, including JSON/JSONL and ordinary source files. This changes large-file behavior from a rejected unwindowed text read to automatic complete loading up to the existing 100 MiB download ceiling. The 1 MiB rendering rule runs after loading and is not a read, decode, or memory budget, including for HTML/Markdown. A source/render toggle would not limit this cost. Any future automatic-read limit must be enforced before full byte loading. Unsupported encodings and file changes surface as errors; byte download remains independent of text rendering. +- The 1 MiB threshold is a product rule, not a rendering budget. Smaller complex documents and explicit full previews can still block the page; iframe sandboxing is a security boundary, not CPU isolation. +- Reading both a text prefix and the full bytes costs one extra request for large files. This reuses existing encoding detection rather than adding another decoder or endpoint. + +## Validation and acceptance + +1. On a real local daemon, `/export html` creates one file and one visible card in the same turn. Opening it renders the exported session; download bytes match the workspace file. +2. Repeat for Markdown, JSON, and JSONL, using each format's existing preview and download behavior. +3. Test a large export above 256 KiB, including a long line, Unicode, and content near the document end. Verify the preview is complete and the download matches the original. +4. Refresh and reload persisted history: no duplicate artifact, and the card still belongs to its original turn. +5. Verify subdirectory exports and rejection of paths outside cwd. Verify secondary workspace routing and split-pane registration through the shared owner-bound paths. +6. Verify ordinary interactive and non-interactive CLI exports retain their original result and file-writing behavior. +7. Run package-scoped regressions, build, and typecheck. Record actual browser results and screenshots separately from mocked unit coverage; do not claim untested behavior as verified. +8. With a template targeting published renderer assets, export through a real daemon and verify both the artifact iframe and the unchanged downloaded file opened directly against genuine CDN responses. Record the renderer configuration; do not present this as proof that unpublished source builds are publicly available. +9. Verify HTML and Markdown render at exactly 1 MiB and default to source above it, including constructed large chat-export HTML. Verify source content reaches both ends, the localized toggle sits at the right edge, HTML renders inside its sandboxed iframe while Markdown uses the existing host component with working same-origin images and footnotes, and downloads remain byte-identical. + +The local E2E report and screenshots are under `.qwen/e2e-tests/web-shell-export-artifact/`. The 1 MiB source-default policy and direct Markdown rendering supersede the earlier 10 MiB Markdown iframe checks; retain historical reports as evidence of their original scope only. Published-renderer verification used explicit build-time delegation and genuine CDN responses. The development-build helper used in that experiment was subsequently removed at the user's request; those results demonstrate preview/download compatibility under matching asset configuration, not an automatic source-build fix. Older mismatched exports remain unchanged. Split-pane and secondary-workspace browser flows were not exercised in this run. + +Follow-up fix verification is recorded in the local `final-read-race/` report: actual UI with a mock daemon covers HTML/Markdown above 1 MiB with truncated text responses, complete byte reads, source tails, byte-identical downloads, and failed chunks without residual loading placeholders. Actual hook/store integration covers duplicate-client registration, catalog convergence, and other 403 errors remaining visible; this does not claim real browser split-pane or real daemon filesystem verification. + +Security follow-up: repository tests cover exact resource selection and dispatcher metadata forwarding. Local browser evidence under `export-artifact-csp-probe/fixed/` verifies SRI failures, offline child frames, blocked duplicate/dynamic external scripts, and actual preview success/error UI using simulated CDN responses and the production parent CSP. These local records are not repository-accessible evidence; attach reviewable evidence to a future PR. diff --git a/docs/design/web-shell-export-artifact.zh-CN.md b/docs/design/web-shell-export-artifact.zh-CN.md new file mode 100644 index 00000000000..c9cba702931 --- /dev/null +++ b/docs/design/web-shell-export-artifact.zh-CN.md @@ -0,0 +1,98 @@ +# Web Shell 中 `/export` 的会话产物 + +[English](web-shell-export-artifact.md) | [简体中文](web-shell-export-artifact.zh-CN.md) + +状态:已实现,并有仓库内回归测试。浏览器验证记录属于本地工作证据,不包含在仓库中。 + +## 目标与兼容性 + +执行 `/export md|html|json|jsonl` 后,Web Shell 必须在命令所属回合显示导出文件,并提供已有的 artifact 预览和下载控件。保持 CLI 的参数解析、路径校验、格式化、文件权限、输出位置以及成功/错误文字不变。交互式和非交互式 CLI 调用返回原有结果,不带 artifact 元数据;只有 ACP 调用在成功写入后附带描述。 + +侧边栏导出操作和 HTTP 导出路由保持不变。不新增存储类型、文件写入器、命令实现、daemon 路由或依赖。 + +## 对初版实现的重新评估 + +初版复用 workspace artifact、传递结构化描述而非解析成功文字,这个方向合理。但注册产物不等于用户能够找到它:回合输出选择器仅关联工具调用,而 `/export` 没有工具调用。真实本地 daemon 已复现:目录中存在可用 HTML artifact,但浏览器没有卡片或预览入口。 + +初版文本分页设计也不成立。限量文本读取可能返回 `truncated: true`、`hasMore: true`,却没有游标,HTML 超长行就是一种情况。文本窗口不是无损字节流,拼接不能保证还原原始文档。将没有游标当作读取完成会静默截断预览。原来的模拟分页测试没有反映这一契约。 + +浏览器 E2E 又暴露了第三处不兼容:导出 HTML 从带版本的 CDN 加载渲染器和样式表,而通用 artifact 沙箱刻意禁止外部资源。因此只有可见卡片,仍无法预览真实会话导出。 + +另外还缺少命令结果元数据的持久化,仅在主视图挂载注册逻辑,且注册失败后原先只能在切换会话后重新尝试;当前实现也允许重连后再次尝试。 + +## 设计 + +### 导出与传输 + +现有导出命令仍写入完全相同的 UTF-8 文件。ACP 模式额外返回标题、格式种类、规范化的 workspace 相对路径、MIME 类型和字节数。Markdown、JSON、JSONL 属于明确请求的导出,不使用面向工具自动产物的较窄扩展名白名单。 + +非交互命令分发器转发描述。ACP 消息发送器将其放入 `_meta.sessionArtifacts`,并标注 `source: slash_command`。使用独立字段,是因为 bridge 将 `_meta.artifacts` 保留给终态工具更新的产物摄入。不伪造工具调用,不扩大现有摄入或发布信任规则。 + +ACP 结果记录器将 `sessionArtifacts` 存入已有命令输出历史项。共享回放投影器恢复相同元数据,使客户端在历史重载、包括 daemon 重启后,仍可将已保存产物关联到原命令。其他命令记录保持原有回放行为。只需要普通消息结果路径:`/export` 不返回流式命令消息。 + +### 注册与可见输出 + +共享解析器仅接受来自斜杠命令、种类为 `file` 或 `html` 的 workspace 文件描述。拒绝畸形描述、绝对路径、两种斜杠形式的父目录跳转和其他定位方式。保留 POSIX 目录名中的字面反斜杠。类似盘符的路径前缀(例如 POSIX 下名为 `c:` 的目录)仍由跨平台描述符解析器保守拒绝。路径校验、workspace 所有权、配额和持久化仍由 daemon artifact API 最终决定。 + +注册通过现有会话 artifact hook 执行,也覆盖分栏视图。等待会话目录加载和历史追赶完成,跳过已注册路径,并将异步结果绑定到捕获的会话 owner。另一个浏览器打开会话时,不会因此改写已有产物的客户端所有权。其他客户端并发注册可能返回 `403 session_artifact_forbidden`。此时刷新当前 owner 的目录,只有确认目录包含导出的 workspace 路径才视为成功;其他失败保留多语言错误 toast。重叠的成功刷新请求可以确认注册结果,但不会覆盖更新请求的目录缓存。StrictMode 重放 effect 不会重复提交同一路径。失败路径在当前 owner、会话和连接内仍标记为已尝试,后续消息更新不会反复提交或重复弹错;重连或重新挂载后可再次尝试。不增加轮询或后台重试服务。 + +会话展示适配器在 assistant 消息上保留上报的产物引用。带有有效导出描述符的输出作为普通文本处理,不进入 insight JSON 分段;导出目录名类似 insight 元数据时也不会丢失引用。`/insight` 等其他命令保留原有进度和报告展示。回合输出选择器按 workspace 路径匹配真实产物目录,将已有 `TurnOutputs` 卡片挂到斜杠命令所属回合。单独的建议性描述不会生成可点击卡片,目录中必须有实际 artifact。预览、下载、workspace 选择以及沙箱 HTML 渲染继续复用共享产物组件。 + +### 完整预览 + +小文件继续使用 daemon 文本读取器及其编码检测,明确请求 256 KiB 窗口。如果结果被截断,则复用已有字节下载读取器 `readWorkspaceFileAsBlob`,再按 daemon 报告的编码解码完整字节。 + +这样可保留超长行、Unicode、BOM 处理和换行边界,同时复用下载读取器的 100 MiB 上限、取消检查、workspace owner 守卫以及文件大小/mtime 一致性检查。超限或读取中变化的文件显示错误,不将不完整 HTML 当作完整预览;读取失败后不再显示加载占位。下载仍返回原始字节。不拼接文本游标页面。 + +预览和下载共用串行的 256 KiB 字节窗口,与现有接口上限一致(客户端此前请求 100 KiB)。不改变服务端限制,也不增加并发。 + +### HTML 会话导出的兼容预览 + +所有 HTML 预览(包括聊天导出)均在不透明来源的沙箱 iframe 内加载原文档,不在宿主重建会话。可信外层 frame 继续阻止内层导航,两层都不启用 `allow-same-origin`。 + +对于带有 `script#transcript-document[type="application/json"]` 的文档,可信 Web Shell 加载器仅读取指定 renderer/stylesheet 元素。URL 必须精确等于 `https://unpkg.com/@qwen-code/qwen-code@<当前 Web Shell 版本>/export-transcript-document.js` 或 `.css`,不能携带 query 或 fragment,且必须带有 SHA-384 integrity 元数据。版本来自可信构建配置,不取自文档,因此任意版本路径也不能携带文档数据发送给 CDN。其他版本的导出不自动排版;原文件和下载保持不变,大文件仍默认显示源码。 + +父页面使用原 SRI 值请求这两个资源,省略凭据、不发送 referrer,并拒绝重定向。只有浏览器完整性校验成功的字节才会作为 data URL 嵌入预览。iframe 不获得 CDN 白名单,只允许内联/data 脚本和样式,网络请求仍被阻止;第二个外部元素或动态创建的请求不会获得出网权限。加载失败显示错误,不会一直停在加载状态。原始导出和下载字节不被改写。 + +生产 Web Shell 响应头允许 data 脚本/样式,以及父页面向 Qwen 包命名空间发起 fetch。子文档自身的 `default-src 'none'` 继续收紧限制,不会因此获得可用的网络权限。Vite 保留原有 CSP,开发环境的聊天导出 HTML 渲染问题延后处理,本版本不声明支持该场景。嵌入宿主需提供兼容策略。响应头修改仅影响 Web Shell 服务,不改变交互式 CLI 导出逻辑。 + +父页面新增权限影响整个 Web Shell,而不仅是这个加载器,需要 maintainer 明确接受;子帧沙箱不会消除父页面策略的取舍。当前实现复用原导出资源 URL 和 SRI;同源资源是另一种可选方案,并未因库构建而被排除。虽然包内 renderer 文件位于 Web Shell 目录旁边,当前静态中间件并未暴露它们。同源方案需要为 daemon 和嵌入宿主明确资源服务契约,并仍需考虑导出的完整性值及 renderer 身份;包版本相同不保证字节相同。内联已校验资源可去掉新增的 data 脚本/样式权限,但只要资源仍来自 CDN,就不能去掉 CDN fetch 权限。 + +源码构建可能使用已发布的包版本,却引用未发布的 renderer 哈希。这样的导出无论在 iframe 内还是下载后直接打开,都会因 SRI 不匹配而失败:仅包版本号相同不代表本地构建字节相同。现有导出构建支持通过 `QWEN_EXPORT_RENDERER_IDENTITY`、`QWEN_EXPORT_RENDERER_INTEGRITY` 和 `QWEN_EXPORT_RENDERER_CSS_INTEGRITY` 显式委托到已发布资源。修改 renderer 发布或构建选择机制不属于本次 artifact 接入范围。不新增构建命令、自动委托、SRI 绕过或旧文件改写,下载仍返回原始字节。磁盘产物变化后,运行中的 daemon/会话进程仍可能保留已导入的旧模板,仅刷新前端不会重载后端模板。 + +Markdown 在宿主页面中直接使用现有 Markdown 组件及样式,保留图片处理、脚注预览卡片和交互控件。不再序列化 React 标记,也不使用 Markdown iframe。HTML 继续使用沙箱 iframe。大 Markdown 文件在挂载 Markdown 组件前默认展示源码;用户主动选择完整排版时仍在主线程渲染。 + +### 大文件渲染 + +分片读取不等于增量渲染:完整读取后,旧预览仍一次性解析并挂载整个文档。实际 1.9 MiB 会话导出挂载了 757 行消息、约 4 万个 DOM 节点;实际 4.5 MB Markdown 导致浏览器操作阻塞。 + +HTML 和 Markdown 使用统一的字节大小规则:超过 1 MiB(1,048,576 字节)默认显示完整只读源码,恰好等于上限仍默认排版预览。workspace 文件使用 daemon 报告的文件大小,或完整下载 blob 的大小;附件保留解码前的原始 blob 大小;没有文件元数据的纯内容预览使用 UTF-8 字节数。不再使用字符数或行数判断。 + +保护逻辑在 HTML 解析之前生效,包括 CSP 包装和 Markdown 转换。多语言提示栏最右侧的按钮允许切换完整排版或返回源码。普通 HTML 保持现有沙箱;聊天导出 HTML 在请求排版时使用原有 document renderer,不新增聊天虚拟滚动或修改会话滚动行为。源码预览继续复用现有 CodeMirror 编辑器,并限定视口。这项排版规则覆盖普通 artifact、附件和斜杠命令导出中的 HTML/Markdown。JSON、JSONL 和其他文本格式本来就使用同一个只读源码编辑器,没有另一个可关闭的排版视图。下载不变。 + +## 约束与风险 + +- 注册仍需已连接的 Web Shell 客户端。CLI 无论如何都会写出文件,其他 ACP 客户端可忽略建议性元数据。这是一项 Web Shell 集成,不新增 daemon 产物生产者。 +- 位于绑定 workspace 外的文件不能表示为 workspace artifact。此时 CLI 导出行为不变,不上报描述。worktree/runtime 映射不得回退到主 workspace。 +- 现有产物的保留、容量、删除和文件缺失行为仍由原机制决定。导出描述可被回放,因此重新打开历史时,未注册路径可能再次注册;本次不新增明确的持久删除语义。 +- 完整字节回退适用于所有 workspace 文本预览,包括 JSON/JSONL 和普通源码文件。这将大文件行为从拒绝无窗口文本读取,改为自动完整加载至现有 100 MiB 下载上限。1 MiB 排版规则在加载之后生效,对 HTML/Markdown 也不是读取、解码或内存预算;增加源码/排版切换不能限制这些成本。后续若限制自动读取,必须在完整字节加载之前执行。不支持的编码和文件变化显示错误;字节下载仍独立于文本渲染。 +- 1 MiB 阈值是产品规则,不是渲染耗时上限。较小的复杂文档和手动完整预览仍可能阻塞页面;iframe 沙箱是安全边界,不是 CPU 隔离。 +- 大文件先读取文本前缀,再读取完整字节,会增加一次请求。这样可以复用现有编码检测,而不增加解码器或端点。 + +## 验证与验收 + +1. 在真实本地 daemon 上执行 `/export html`,生成一个文件和同一回合中的一张可见卡片。打开后渲染导出的会话,下载字节与 workspace 文件一致。 +2. 对 Markdown、JSON、JSONL 重复验证,使用各格式已有的预览和下载行为。 +3. 测试超过 256 KiB 的大导出,包括超长行、Unicode 和文档尾部内容。确认预览完整,下载与原文件一致。 +4. 刷新并重载持久化历史:不重复注册,卡片仍属于原回合。 +5. 验证子目录导出和 cwd 外路径拒绝。通过共享 owner 绑定路径验证次级 workspace 路由和分栏注册。 +6. 验证普通交互式和非交互式 CLI 导出保持原结果和文件写入行为。 +7. 运行包内回归测试、构建和类型检查。浏览器实际结果与截图应独立于模拟单测证据记录,不将未测行为宣称为已验证。 +8. 使用指向已发布 renderer 资源的模板,通过真实 daemon 导出,验证 artifact iframe 和下载后未经修改直接打开都能通过真实 CDN 响应完成渲染。记录 renderer 配置,不将这一结果当作未发布源码构建已能公开访问的证据。 +9. 验证 HTML 和 Markdown 恰好 1 MiB 时默认排版、超出时默认源码,包括构造的大聊天导出 HTML。验证源码首尾可达,多语言切换按钮位于最右侧,HTML 在沙箱 iframe 内排版,Markdown 使用现有宿主组件并保留同源图片和脚注行为,下载仍与原文件字节一致。 + +本地 E2E 报告和截图位于 `.qwen/e2e-tests/web-shell-export-artifact/`,1 MiB 默认源码和直接 Markdown 渲染方案替代此前 10 MiB Markdown iframe 检查;历史报告仅证明各自原始范围内的行为。已发布 renderer 验证使用显式构建期委托和真实 CDN 响应。该实验使用的开发构建辅助入口随后按用户要求撤回;结果证明资源配置匹配时预览和下载兼容,不代表源码构建问题会自动修复。旧的不匹配导出保持原样。本轮未实际运行分栏和次级 workspace 的浏览器流程。 + +后续修复验证见本地 `final-read-race/` 报告:实际 UI 配合模拟 daemon 验证 HTML/Markdown 超过 1 MiB 且文本响应截断时的完整分片读取、源码尾部、下载字节一致,以及分片失败后不再显示加载占位。双客户端注册使用实际 hook 与 store 集成测试验证,包括目录收敛和其他 403 仍报错;这不代表已验证真实浏览器分屏或真实 daemon 文件读取。 + +安全后续验证:仓库内测试覆盖精确资源选择和调度器元数据转发。本地 `export-artifact-csp-probe/fixed/` 浏览器记录使用模拟 CDN 响应及生产父页面 CSP,验证 SRI 失败、子页面断网策略、重复/动态外部脚本被阻止,以及实际预览成功/错误 UI。这些本地记录不能作为仓库内可访问证据;后续 PR 需附上可评审的验证材料。 diff --git a/integration-tests/export-artifact-contract.test.ts b/integration-tests/export-artifact-contract.test.ts new file mode 100644 index 00000000000..d3b9f62998e --- /dev/null +++ b/integration-tests/export-artifact-contract.test.ts @@ -0,0 +1,223 @@ +// @vitest-environment jsdom + +/** + * Cross-package contract for `/export` artifacts in the Web Shell. + * + * The descriptor a slash command produces crosses a JSON `_meta` hop between + * the CLI's nominal `ToolArtifact` type and the Web Shell's independently + * written reader; neither side's own tests see the other. Likewise, the + * export HTML document's renderer asset URLs embed the root package version + * while the Web Shell preview's allow-list injects the web-shell package + * version — the two only agree because the release script bumps them in + * lockstep. These tests run the real producer and the real consumer against + * each other so a drift on either side turns red here. + */ + +import { readFileSync } from 'node:fs'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { EXPORT_TRANSCRIPT_RENDERER_VERSION } from '@qwen-code/web-templates'; +import type { SessionEmitterContext } from '../packages/cli/src/acp-integration/session/types.js'; +import type { CommandContext } from '../packages/cli/src/ui/commands/types.js'; +import type { ExportSessionData } from '../packages/cli/src/ui/utils/export/types.js'; +import type { ToolArtifact } from '@qwen-code/qwen-code-core'; + +// The contract under test is the descriptor shape and the wire key, not the +// session log or the formatters — those are replaced, while the descriptor +// construction, path canonicalization, validation, and emission all run for +// real. +vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + SessionService: class { + constructor(_cwd: string) {} + async loadSession(_sessionId: string) { + return { + conversation: { + sessionId: 'contract-session', + startTime: '2026-09-18T00:00:00.000Z', + messages: [], + }, + }; + } + }, + }; +}); + +vi.mock('../packages/cli/src/ui/utils/export/index.js', () => ({ + collectSessionData: vi.fn(async () => ({ + sessionId: 'contract-session', + startTime: '2026-09-18T00:00:00.000Z', + messages: [], + })), + normalizeSessionData: vi.fn((data: unknown) => data), + toMarkdown: vi.fn(() => '# Contract export'), + toHtml: vi.fn(() => 'contract'), + toJson: vi.fn(() => '{"messages":[]}'), + toJsonl: vi.fn(() => '{"type":"session_metadata"}'), + generateExportFilename: vi.fn((ext: string) => `contract-export.${ext}`), +})); + +const { exportCommand } = await import( + '../packages/cli/src/ui/commands/exportCommand.js' +); +const { MessageEmitter } = await import( + '../packages/cli/src/acp-integration/session/emitters/MessageEmitter.js' +); +const { readReportedArtifacts } = await import( + '../packages/web-shell/client/adapters/reported-artifacts.js' +); +const { loadArtifactPreviewDocument } = await import( + '../packages/web-shell/client/components/artifacts/artifactUtils.js' +); +const { toHtml } = await import( + '../packages/cli/src/ui/utils/export/formatters/html.js' +); + +declare global { + // Mirrors the vite define in packages/web-shell/vite.config.ts; the + // integration program never sees packages/web-shell/client/vite-env.d.ts. + var __WEB_SHELL_VERSION__: string; +} + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('export artifact wire contract', () => { + let workDir: string | undefined; + + afterEach(async () => { + if (workDir) await rm(workDir, { recursive: true, force: true }); + workDir = undefined; + }); + + it.each(['md', 'html'] as const)( + 'a produced %s descriptor survives the _meta hop into the Web Shell reader', + async (format) => { + workDir = await mkdtemp(join(tmpdir(), 'export-artifact-contract-')); + const context = { + executionMode: 'acp', + services: { + config: { + getWorkingDir: () => workDir, + getTargetDir: () => workDir, + getProjectRoot: () => workDir, + getSessionId: () => 'contract-session', + }, + }, + } as unknown as CommandContext; + const command = exportCommand.subCommands?.find( + (subCommand) => subCommand.name === format, + ); + const result = await command?.action?.(context, ''); + expect(result).toMatchObject({ type: 'message', messageType: 'info' }); + const artifacts = (result as { artifacts?: ToolArtifact[] }).artifacts; + expect(artifacts).toHaveLength(1); + + const sendUpdate = vi.fn(async (_update: unknown) => undefined); + const emitter = new MessageEmitter({ + sessionId: 'contract-session', + sendUpdate, + } as unknown as SessionEmitterContext); + await emitter.emitSlashCommandOutput('Exported.', undefined, artifacts); + const frame = sendUpdate.mock.calls[0]?.[0] as { + _meta?: Record; + }; + + // The wire is JSON: the frame reaches the Web Shell as plain data. + const wireMeta = JSON.parse(JSON.stringify(frame._meta)) as Record< + string, + unknown + >; + expect(readReportedArtifacts(wireMeta)).toEqual(artifacts); + }, + ); +}); + +describe('export preview resource contract', () => { + it('loads a real export document under the Web Shell resource allow-list', async () => { + // The consumer side injects its version from packages/web-shell's own + // manifest (packages/web-shell/vite.config.ts), while the producer embeds + // the root version in the asset URLs; the release script keeps the two in + // lockstep, and this test is where a drift fails. + const webShellVersion = ( + JSON.parse( + readFileSync( + resolve(repoRoot, 'packages/web-shell/package.json'), + 'utf8', + ), + ) as { version: string } + ).version; + vi.stubGlobal('__WEB_SHELL_VERSION__', webShellVersion); + expect(EXPORT_TRANSCRIPT_RENDERER_VERSION.split('+')[0]).toBe( + webShellVersion, + ); + + const sessionData: ExportSessionData = { + sessionId: 'contract-session', + startTime: '2026-09-18T00:00:00.000Z', + messages: [], + metadata: { + sessionId: 'contract-session', + startTime: '2026-09-18T00:00:00.000Z', + exportTime: '2026-09-18T01:00:00.000Z', + cwd: '/tmp/export-contract', + gitRepo: 'qwen-code', + gitBranch: 'main', + model: 'qwen-test', + channel: 'cli', + promptCount: 1, + contextUsagePercent: 25, + contextWindowSize: 128_000, + totalTokens: 32_000, + filesWritten: 0, + linesAdded: 0, + linesRemoved: 0, + uniqueFiles: [], + }, + }; + const records = [ + { + uuid: 'user-record', + parentUuid: null, + sessionId: 'contract-session', + timestamp: '2026-09-18T00:00:00.000Z', + cwd: '/tmp/export-contract', + type: 'user', + message: { + role: 'user', + parts: [{ text: 'Hello from the export contract test.' }], + }, + }, + ]; + const html = toHtml(sessionData, records); + const fetchMock = vi.fn(async (input: unknown) => + String(input).endsWith('.css') + ? new Response('/* contract */') + : new Response('// contract'), + ); + vi.stubGlobal('fetch', fetchMock); + + const previewDocument = await loadArtifactPreviewDocument( + html, + 'Export', + new AbortController().signal, + ); + + const rendererBase = `https://unpkg.com/@qwen-code/qwen-code@${EXPORT_TRANSCRIPT_RENDERER_VERSION.split('+')[0]}`; + expect(fetchMock.mock.calls.map(([input]) => String(input))).toEqual([ + `${rendererBase}/export-transcript-document.js`, + `${rendererBase}/export-transcript-document.css`, + ]); + expect(previewDocument).toContain('data:text/javascript;base64,'); + expect(previewDocument).toContain('data:text/css;base64,'); + }); +}); diff --git a/packages/acp-bridge/src/transcript-replay.test.ts b/packages/acp-bridge/src/transcript-replay.test.ts index c66dd2ad54f..f078678f093 100644 --- a/packages/acp-bridge/src/transcript-replay.test.ts +++ b/packages/acp-bridge/src/transcript-replay.test.ts @@ -81,6 +81,34 @@ function goalCardRecord( } describe('createTranscriptReplayMachine', () => { + it('replays exported artifact descriptors with the slash command result', () => { + const sessionArtifacts = [ + { + kind: 'html', + storage: 'workspace', + title: 'export.html', + workspacePath: 'export.html', + }, + ]; + const projected = updates( + createTranscriptReplayMachine(), + goalCardRecord('export-result', { + type: 'assistant', + text: 'Session exported to HTML: export.html', + sessionArtifacts, + }), + ); + expect(projected).toEqual([ + expect.objectContaining({ + sessionUpdate: 'agent_message_chunk', + _meta: expect.objectContaining({ + source: 'slash_command', + sessionArtifacts, + }), + }), + ]); + }); + it('projects the daemon identity on every user block before a turn result', () => { const projected = updates( createTranscriptReplayMachine(), diff --git a/packages/acp-bridge/src/transcript-replay.ts b/packages/acp-bridge/src/transcript-replay.ts index ca9dbc31893..e8813e89813 100644 --- a/packages/acp-bridge/src/transcript-replay.ts +++ b/packages/acp-bridge/src/transcript-replay.ts @@ -1203,6 +1203,9 @@ class DefaultTranscriptReplayMachine implements TranscriptReplayMachine { // Replayed on its own key, exactly as it was recorded: the folded // block keeps both, so the note survives beside the result. ...(contextCompressionNotice ? { contextCompressionNotice } : {}), + ...(Array.isArray(item['sessionArtifacts']) + ? { sessionArtifacts: item['sessionArtifacts'] } + : {}), }, }), ); diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 143a60c94cd..001ac43e084 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -26770,6 +26770,54 @@ describe('Session', () => { ); }); + it('forwards export artifacts to live output and persisted command history', async () => { + const artifacts = [ + { + kind: 'file' as const, + storage: 'workspace' as const, + title: 'export.md', + workspacePath: 'export.md', + mimeType: 'text/markdown; charset=utf-8', + sizeBytes: 42, + }, + ]; + vi.mocked( + nonInteractiveCliCommands.handleSlashCommand, + ).mockResolvedValueOnce({ + type: 'message', + messageType: 'info', + content: 'Exported.', + artifacts, + }); + await session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: '/export md' }], + }); + expect(mockClient.sessionUpdate).toHaveBeenCalledWith({ + sessionId: 'test-session-id', + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Exported.' }, + _meta: { source: 'slash_command', sessionArtifacts: artifacts }, + }, + }); + expect( + mockChatRecordingService.recordSlashCommand, + ).toHaveBeenCalledWith( + expect.objectContaining({ + phase: 'result', + rawCommand: '/export md', + outputHistoryItems: [ + { + type: 'assistant', + text: 'Exported.', + sessionArtifacts: artifacts, + }, + ], + }), + ); + }); + it('returns a structured standalone-policy error for a blocked slash command', async () => { session.dispose(); vi.mocked(mockConfig.getSessionSourceType).mockReturnValue( diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index bd5dc9b0f50..1b92a99ceb6 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -15417,7 +15417,11 @@ export class Session implements SessionContext { // Replace bare \n with Markdown hard line-breaks (two trailing spaces) // so Zed's Markdown renderer preserves the line structure. const rendered = (result.content || '').replace(/\n/g, ' \n'); - await this.messageEmitter.emitSlashCommandOutput(rendered); + await this.messageEmitter.emitSlashCommandOutput( + rendered, + undefined, + result.artifacts, + ); // Write a system/slash_command record so history replay on restart can // re-emit this message. system records are skipped by // buildApiHistoryFromConversation, so this won't pollute model context. @@ -15428,7 +15432,13 @@ export class Session implements SessionContext { .map((b) => (b.type === 'text' ? b.text : '')) .join(' '), outputHistoryItems: [ - { type: 'assistant', text: result.content || '' }, + { + type: 'assistant', + text: result.content || '', + ...(result.artifacts?.length + ? { sessionArtifacts: result.artifacts } + : {}), + }, ], }); return null; @@ -15457,6 +15467,7 @@ export class Session implements SessionContext { await this.messageEmitter.emitSlashCommandOutput( content.replace(/\n/g, ' \n'), undefined, + undefined, notice ? { contextCompressionNotice: notice } : msg.contextCompression diff --git a/packages/cli/src/acp-integration/session/emitters/MessageEmitter.test.ts b/packages/cli/src/acp-integration/session/emitters/MessageEmitter.test.ts index 889c5e740d9..d79f28a74a4 100644 --- a/packages/cli/src/acp-integration/session/emitters/MessageEmitter.test.ts +++ b/packages/cli/src/acp-integration/session/emitters/MessageEmitter.test.ts @@ -121,10 +121,51 @@ describe('MessageEmitter', () => { }); }); + it('should carry the files the command wrote beside the message', async () => { + const artifacts = [ + { + kind: 'file' as const, + storage: 'workspace' as const, + title: 'qwen-code-export-2026-01-01T00-00-00-000Z.md', + workspacePath: 'qwen-code-export-2026-01-01T00-00-00-000Z.md', + mimeType: 'text/markdown; charset=utf-8', + sizeBytes: 42, + }, + ]; + + await emitter.emitSlashCommandOutput( + 'Session exported to markdown: qwen-code-export-2026-01-01T00-00-00-000Z.md', + undefined, + artifacts, + ); + + // Not `artifacts`: the bridge strips that key from published frames and + // only ingests it on tool-call frames, so the payload must ride its own. + expect(sendUpdateSpy).toHaveBeenCalledWith({ + sessionUpdate: 'agent_message_chunk', + content: { + type: 'text', + text: 'Session exported to markdown: qwen-code-export-2026-01-01T00-00-00-000Z.md', + }, + _meta: { source: 'slash_command', sessionArtifacts: artifacts }, + }); + }); + + it('should omit the artifact key when the command wrote nothing', async () => { + await emitter.emitSlashCommandOutput('No active session found.', 0, []); + + expect(sendUpdateSpy).toHaveBeenCalledWith({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'No active session found.' }, + _meta: { source: 'slash_command', timestamp: 0 }, + }); + }); + it('should carry structured payloads without letting them displace its own keys', async () => { await emitter.emitSlashCommandOutput( 'Compressing context...', undefined, + undefined, { contextCompression: { phase: 'progress' }, // A payload must never be able to re-label the frame. diff --git a/packages/cli/src/acp-integration/session/emitters/MessageEmitter.ts b/packages/cli/src/acp-integration/session/emitters/MessageEmitter.ts index 8246f4d8fc2..b0ae7a8cbcf 100644 --- a/packages/cli/src/acp-integration/session/emitters/MessageEmitter.ts +++ b/packages/cli/src/acp-integration/session/emitters/MessageEmitter.ts @@ -16,6 +16,7 @@ import { type GoalRecord, type GoalSnapshotV2, type GoalStateCause, + type ToolArtifact, type VisionBridgeResult, } from '@qwen-code/qwen-code-core'; import { BaseEmitter } from './base-emitter.js'; @@ -213,10 +214,12 @@ export class MessageEmitter extends BaseEmitter { async emitSlashCommandOutput( text: string, timestamp?: string | number, + artifacts?: readonly ToolArtifact[], /** * Extra `_meta` keys for clients that render slash-command output - * themselves. Spread first, so a payload can never displace `source`, nor - * the `timestamp` this emitter adds when the caller supplied one. + * themselves. Spread first, so a payload can never displace `source`, the + * typed `sessionArtifacts`, nor the `timestamp` this emitter adds when the + * caller supplied one. */ extra?: Record, ): Promise { @@ -227,6 +230,10 @@ export class MessageEmitter extends BaseEmitter { _meta: { ...extra, source: 'slash_command', + // Deliberately not `artifacts`: the bridge's sanitizer strips that key + // from every published frame and only ingests it on tool_call_update + // frames, so a slash-command payload under that name would be dropped. + ...(artifacts?.length ? { sessionArtifacts: artifacts } : {}), ...(epochMs != null ? { timestamp: epochMs } : {}), }, }); diff --git a/packages/cli/src/nonInteractiveCliCommands.test.ts b/packages/cli/src/nonInteractiveCliCommands.test.ts index 9c3361c9d06..f4281de77a6 100644 --- a/packages/cli/src/nonInteractiveCliCommands.test.ts +++ b/packages/cli/src/nonInteractiveCliCommands.test.ts @@ -208,6 +208,45 @@ describe('handleSlashCommand', () => { } }); + it('forwards artifact descriptors from a message command result', async () => { + const artifacts = [ + { + kind: 'file', + storage: 'workspace', + title: 'export.md', + workspacePath: 'export.md', + sizeBytes: 42, + }, + ]; + mockGetCommands.mockReturnValue([ + { + name: 'export', + description: 'Export', + kind: CommandKind.BUILT_IN, + supportedModes: ['acp'], + action: vi.fn().mockResolvedValue({ + type: 'message', + messageType: 'info', + content: 'Exported.', + artifacts, + }), + }, + ]); + vi.mocked(mockConfig.getExperimentalZedIntegration).mockReturnValue(true); + const result = await handleSlashCommand( + '/export md', + abortController, + mockConfig, + mockSettings, + ); + expect(result).toMatchObject({ + type: 'message', + messageType: 'info', + content: 'Exported.', + artifacts, + }); + }); + it('should execute local commands with non_interactive supportedModes', async () => { const mockInitCommand = { name: 'init', diff --git a/packages/cli/src/nonInteractiveCliCommands.ts b/packages/cli/src/nonInteractiveCliCommands.ts index 0b52833d97e..b0462611c3c 100644 --- a/packages/cli/src/nonInteractiveCliCommands.ts +++ b/packages/cli/src/nonInteractiveCliCommands.ts @@ -15,6 +15,7 @@ import { type Config, type GoalStateCause, type GoalStateResponse, + type ToolArtifact, createDebugLogger, recordSkillInvocation, } from '@qwen-code/qwen-code-core'; @@ -94,6 +95,7 @@ export type NonInteractiveSlashCommandResult = ( type: 'message'; messageType: 'info' | 'warning' | 'error'; content: string; + artifacts?: ToolArtifact[]; outputHistoryItems?: HistoryItemWithoutId[]; } | { @@ -178,6 +180,7 @@ function handleCommandResult( type: 'message', messageType: result.messageType, content: result.content, + ...(result.artifacts?.length ? { artifacts: result.artifacts } : {}), ...(outputHistoryItems?.length ? { outputHistoryItems } : {}), }; diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 51c11dd55c8..c88c0714196 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -4232,7 +4232,7 @@ describe('createServeApp', () => { 'evil.example', ); expect(bracketed.headers['content-security-policy']).toContain( - "connect-src 'self';", + "connect-src 'self' https://unpkg.com/@qwen-code/;", ); // The mixed shape is the one that broke functionally, not just by diff --git a/packages/cli/src/serve/web-shell-static.test.ts b/packages/cli/src/serve/web-shell-static.test.ts index ceed08fa8e4..bb13b09b561 100644 --- a/packages/cli/src/serve/web-shell-static.test.ts +++ b/packages/cli/src/serve/web-shell-static.test.ts @@ -30,8 +30,10 @@ describe('Web Shell sandbox framing', () => { expect(csp).toContain("media-src 'self' data:"); expect(csp).toContain("base-uri 'none'"); expect(csp).toContain( - "script-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval'", + "script-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval' data:;", ); + expect(csp).toContain("style-src 'self' 'unsafe-inline' data:;"); + expect(csp).toContain("connect-src 'self' https://unpkg.com/@qwen-code/"); expect(csp).not.toContain('frame-src *'); }); diff --git a/packages/cli/src/serve/web-shell-static.ts b/packages/cli/src/serve/web-shell-static.ts index 796484547c9..a2c30fe0cb1 100644 --- a/packages/cli/src/serve/web-shell-static.ts +++ b/packages/cli/src/serve/web-shell-static.ts @@ -31,8 +31,9 @@ export { resolveWebShellDir } from './web-shell-resolver.js'; */ const WEB_SHELL_CSP_DIRECTIVES = [ "default-src 'self'", - "script-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval'", - "style-src 'self' 'unsafe-inline'", + // Export previews embed SRI-verified assets as data URLs; child frames stay offline. + "script-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval' data:", + "style-src 'self' 'unsafe-inline' data:", "font-src 'self' data:", "img-src 'self' data: blob:", "media-src 'self' data:", @@ -70,7 +71,11 @@ export function buildWebShellCsp( : "frame-ancestors 'none'"; // PDF attachments use blob URLs; live previews pin their own child source. const frameSrc = 'frame-src http: https: blob:'; - const connectSrc = `connect-src 'self' ${connectOrigins.join(' ')}`.trim(); + const connectSrc = [ + "connect-src 'self'", + ...connectOrigins, + 'https://unpkg.com/@qwen-code/', + ].join(' '); return [...WEB_SHELL_CSP_DIRECTIVES, connectSrc, frameSrc, fa].join('; '); } diff --git a/packages/cli/src/ui/commands/exportCommand.test.ts b/packages/cli/src/ui/commands/exportCommand.test.ts index 8986dcda66c..e6cc753184b 100644 --- a/packages/cli/src/ui/commands/exportCommand.test.ts +++ b/packages/cli/src/ui/commands/exportCommand.test.ts @@ -100,6 +100,7 @@ describe('exportCommand', () => { services: { config: { getWorkingDir: vi.fn().mockReturnValue(mockWorkingDir), + getTargetDir: vi.fn().mockReturnValue(mockWorkingDir), getProjectRoot: vi.fn().mockReturnValue(mockProjectRoot), getSessionId: vi.fn().mockReturnValue('test-session-id'), }, @@ -129,6 +130,99 @@ describe('exportCommand', () => { vi.restoreAllMocks(); }); + describe('ACP artifacts', () => { + it.each([ + ['md', 'text/markdown; charset=utf-8', 'file'], + ['html', 'text/html; charset=utf-8', 'html'], + ['json', 'application/json; charset=utf-8', 'file'], + ['jsonl', 'application/jsonl; charset=utf-8', 'file'], + ])( + 'reports the written %s file in the workspace namespace', + async (format, mimeType, kind) => { + mockContext.executionMode = 'acp'; + const filename = `export-2025-01-01T00-00-00-000Z.${format}`; + const result = await exportCommand.subCommands!.find( + (command) => command.name === format, + )!.action!(mockContext, './logs'); + const [writtenPath, content, options] = vi.mocked(fs.writeFile).mock + .calls[0]; + expect(writtenPath).toBe(path.join(mockWorkingDir, 'logs', filename)); + expect(options).toEqual({ encoding: 'utf-8', mode: 0o600 }); + expect(result).toMatchObject({ + type: 'message', + messageType: 'info', + artifacts: [ + { + title: filename, + workspacePath: `logs/${filename}`, + kind, + storage: 'workspace', + mimeType, + sizeBytes: Buffer.byteLength(String(content), 'utf-8'), + }, + ], + }); + }, + ); + + it.each(['interactive', 'non_interactive'] as const)( + 'keeps %s CLI results unchanged', + async (executionMode) => { + mockContext.executionMode = executionMode; + const result = await exportCommand.subCommands!.find( + (command) => command.name === 'md', + )!.action!(mockContext, ''); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: + 'Session exported to markdown: export-2025-01-01T00-00-00-000Z.md', + }); + expect( + mockContext.services.config!.getTargetDir, + ).not.toHaveBeenCalled(); + }, + ); + + it('maps worktree files to the bound workspace root', async () => { + mockContext.executionMode = 'acp'; + const worktree = path.join( + mockProjectRoot, + '.qwen/worktrees/export-check', + ); + vi.mocked(mockContext.services.config!.getWorkingDir).mockReturnValue( + worktree, + ); + vi.mocked(mockContext.services.config!.getTargetDir).mockReturnValue( + worktree, + ); + const result = await exportCommand.subCommands!.find( + (command) => command.name === 'md', + )!.action!(mockContext, ''); + expect(result).toMatchObject({ + artifacts: [ + expect.objectContaining({ + workspacePath: + '.qwen/worktrees/export-check/export-2025-01-01T00-00-00-000Z.md', + }), + ], + }); + }); + + it('does not advertise files outside the bound workspace', async () => { + mockContext.executionMode = 'acp'; + vi.mocked(mockContext.services.config!.getTargetDir).mockReturnValue( + mockProjectRoot, + ); + const result = await exportCommand.subCommands!.find( + (command) => command.name === 'md', + )!.action!(mockContext, ''); + expect(result).toMatchObject({ type: 'message', messageType: 'info' }); + expect(result).not.toHaveProperty('artifacts'); + expect(fs.writeFile).toHaveBeenCalledTimes(1); + }); + }); + describe('command structure', () => { it('should have correct name and description', () => { expect(exportCommand.name).toBe('export'); diff --git a/packages/cli/src/ui/commands/exportCommand.ts b/packages/cli/src/ui/commands/exportCommand.ts index d7f878f296c..72fba579716 100644 --- a/packages/cli/src/ui/commands/exportCommand.ts +++ b/packages/cli/src/ui/commands/exportCommand.ts @@ -16,7 +16,9 @@ import { createDebugLogger, isSubpath, SessionService, + type ToolArtifact, } from '@qwen-code/qwen-code-core'; +import { toCanonicalWorkspaceArtifactPath } from '@qwen-code/qwen-code-core/utils/workspace-artifact-path.js'; import { collectSessionData, normalizeSessionData, @@ -32,6 +34,7 @@ import { t } from '../../i18n/index.js'; type ExportFormat = { extension: string; displayName: string; + mimeType: string; // Required for the same reason as `ExportFormatDefinition.render` in // serve/server/session-export.ts: HTML projects from the original records, // and the remaining formatters ignore this argument. @@ -382,10 +385,30 @@ async function exportSessionAction( format: exportFormat.displayName, filepath: target.filepath, }); + const workspacePath = + context.executionMode === 'acp' + ? toCanonicalWorkspaceArtifactPath( + target.filepath, + config.getTargetDir(), + ) + : null; + const artifacts: ToolArtifact[] = workspacePath + ? [ + { + kind: exportFormat.extension === 'html' ? 'html' : 'file', + storage: 'workspace', + title: path.basename(target.filepath), + workspacePath, + mimeType: exportFormat.mimeType, + sizeBytes: Buffer.byteLength(content, 'utf-8'), + }, + ] + : []; return { type: 'message', messageType: 'info', content: `Session exported to ${exportFormat.displayName}: ${target.displayPath}`, + ...(artifacts.length > 0 ? { artifacts } : {}), }; } catch (error) { debugLogger.debug('Session export failed:', { @@ -413,6 +436,7 @@ async function exportMarkdownAction( return exportSessionAction(context, args, { extension: 'md', displayName: 'markdown', + mimeType: 'text/markdown; charset=utf-8', format: toMarkdown, }); } @@ -427,6 +451,7 @@ async function exportHtmlAction( return exportSessionAction(context, args, { extension: 'html', displayName: 'HTML', + mimeType: 'text/html; charset=utf-8', format: toHtml, }); } @@ -441,6 +466,7 @@ async function exportJsonAction( return exportSessionAction(context, args, { extension: 'json', displayName: 'JSON', + mimeType: 'application/json; charset=utf-8', format: toJson, }); } @@ -455,6 +481,7 @@ async function exportJsonlAction( return exportSessionAction(context, args, { extension: 'jsonl', displayName: 'JSONL', + mimeType: 'application/jsonl; charset=utf-8', format: toJsonl, }); } diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 254dfc4d1a3..35977cfea64 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -12,6 +12,7 @@ import type { GoalStateCause, Logger, SessionListItem, + ToolArtifact, } from '@qwen-code/qwen-code-core'; import type { HistoryItemWithoutId, @@ -157,6 +158,8 @@ export interface MessageActionReturn { type: 'message'; messageType: 'info' | 'warning' | 'error'; content: string; + /** Files already written by the command; advisory metadata for ACP clients. */ + artifacts?: ToolArtifact[]; } export type GoalCommandOperation = diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index 8604fffe5a6..74e2d7c4c0f 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -520,6 +520,7 @@ const { tasks: [], }), loadArtifacts: vi.fn().mockResolvedValue({ artifacts: [] }), + addArtifact: vi.fn().mockResolvedValue({}), loadSession: vi.fn().mockResolvedValue(undefined), reloadSession: vi.fn().mockResolvedValue(undefined), }; @@ -7150,6 +7151,7 @@ describe('artifact panel fullscreen', () => { expect(mockWorkspaceActions.readWorkspaceFile).toHaveBeenCalledWith( 'notes.txt', + { maxBytes: 256 * 1024 }, ); expect(container.textContent).not.toContain( 'This workspace may have been removed', @@ -33345,6 +33347,62 @@ describe('App session callbacks', () => { mockSessionActions.loadArtifacts.mockResolvedValue({ artifacts: [] }); }); + it('localizes a registration failure toast for an export reported on the main view', async () => { + // The main-view registration hook runs in App's own hook body, above the + // I18nProvider App renders — this pins that its toast is still localized. + mockConnection.capabilities = { + ...mockConnection.capabilities, + features: ['session_artifacts'], + }; + const toastRequests: string[] = []; + const listener = (event: Event) => { + toastRequests.push( + (event as CustomEvent<{ message: string }>).detail.message, + ); + }; + window.addEventListener('qwen:toast-request', listener); + try { + // mockClear in the file's beforeEach keeps stale once-queues; reset the + // two mocks this chain depends on so hydration cannot stall on residue. + mockSessionActions.loadArtifacts.mockReset().mockResolvedValue({ + artifacts: [], + }); + mockSessionActions.addArtifact + .mockReset() + .mockRejectedValue(new Error('daemon exploded')); + testState.blocks = [ + { + id: 'export-result', + kind: 'assistant', + text: 'Exported', + meta: { + source: 'slash_command', + sessionArtifacts: [ + { + kind: 'file', + storage: 'workspace', + title: 'qwen-code-export-2026-01-01T00-00-00-000Z.md', + workspacePath: 'qwen-code-export-2026-01-01T00-00-00-000Z.md', + }, + ], + }, + }, + ]; + renderApp(); + await vi.waitFor(async () => { + await flush(); + expect(mockSessionActions.addArtifact).toHaveBeenCalledTimes(1); + }); + await flush(); + expect(toastRequests).toEqual([ + 'Could not add exported artifact: daemon exploded', + ]); + } finally { + window.removeEventListener('qwen:toast-request', listener); + mockSessionActions.addArtifact.mockReset().mockResolvedValue({}); + } + }); + it('opens a split pane monitor in the right panel', async () => { const { container } = renderApp(); await flush(); diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index d9beefbf79d..e0771ed0d85 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -4219,7 +4219,7 @@ export function App({ error: artifactsError, refresh: refreshArtifacts, hydrated: artifactsHydrated, - } = useSessionArtifacts(); + } = useSessionArtifacts(t); const sourcesState = useSessionSources(); const refreshSources = sourcesState.refresh; const [sourceRegistrationRetries, setSourceRegistrationRetries] = useState< diff --git a/packages/web-shell/client/adapters/messageTypes.ts b/packages/web-shell/client/adapters/messageTypes.ts index 1055a2026dd..1fc36b51512 100644 --- a/packages/web-shell/client/adapters/messageTypes.ts +++ b/packages/web-shell/client/adapters/messageTypes.ts @@ -6,6 +6,7 @@ import type { DaemonBackgroundTurn, + DaemonSessionArtifactInput, DaemonInputAnnotation, } from '@qwen-code/sdk/daemon'; @@ -130,6 +131,7 @@ export interface DaemonUserMessage extends DaemonMessageMeta { } export interface DaemonAssistantMessage extends DaemonMessageMeta { + reportedArtifacts?: DaemonSessionArtifactInput[]; id: string; role: 'assistant'; content: string; diff --git a/packages/web-shell/client/adapters/reported-artifacts.test.ts b/packages/web-shell/client/adapters/reported-artifacts.test.ts new file mode 100644 index 00000000000..c50f047711d --- /dev/null +++ b/packages/web-shell/client/adapters/reported-artifacts.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import type { DaemonSessionArtifactInput } from '@qwen-code/sdk/daemon'; +import { readReportedArtifacts } from './reported-artifacts'; + +const exportedArtifact: DaemonSessionArtifactInput = { + kind: 'file', + storage: 'workspace', + title: 'qwen-code-export-2026-01-01T00-00-00-000Z.md', + workspacePath: 'qwen-code-export-2026-01-01T00-00-00-000Z.md', + mimeType: 'text/markdown; charset=utf-8', + sizeBytes: 42, +}; + +function slashCommandMeta(sessionArtifacts: unknown) { + return { source: 'slash_command', sessionArtifacts }; +} + +describe('readReportedArtifacts', () => { + // Each rejecting fixture carries exactly one defect and is otherwise a + // valid export descriptor, so it isolates a single guard clause. The + // accepted vocabulary comes from the producer: `exportCommand` emits + // kind 'file' | 'html' with storage 'workspace'. + it.each([ + { ...exportedArtifact, storage: 'published' }, + { ...exportedArtifact, kind: 'image' }, + { ...exportedArtifact, managedId: 'existing' }, + ])('drops a descriptor whose only defect is %s', (descriptor) => { + expect(readReportedArtifacts(slashCommandMeta([descriptor]))).toEqual([]); + expect( + readReportedArtifacts(slashCommandMeta([exportedArtifact, descriptor])), + ).toEqual([exportedArtifact]); + }); + + it('rebuilds the descriptor instead of forwarding transcript metadata', () => { + // The projection is the only barrier between transcript-carried `_meta` + // and the fields the daemon store honors, so fields the producer never + // emits must not ride along. + const descriptor = { + ...exportedArtifact, + description: 'model-authored summary', + metadata: { 'qwen.workspace.sha256': 'forged' }, + retention: 'pinned', + clientRetained: true, + }; + expect(readReportedArtifacts(slashCommandMeta([descriptor]))).toEqual([ + exportedArtifact, + ]); + }); +}); diff --git a/packages/web-shell/client/adapters/reported-artifacts.ts b/packages/web-shell/client/adapters/reported-artifacts.ts new file mode 100644 index 00000000000..e0b6a7ae93d --- /dev/null +++ b/packages/web-shell/client/adapters/reported-artifacts.ts @@ -0,0 +1,45 @@ +import type { DaemonSessionArtifactInput } from '@qwen-code/sdk/daemon'; + +export function readReportedArtifacts( + meta: Record | undefined, +): DaemonSessionArtifactInput[] { + if ( + meta?.['source'] !== 'slash_command' || + !Array.isArray(meta['sessionArtifacts']) + ) { + return []; + } + return meta['sessionArtifacts'].flatMap((value: unknown) => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return []; + const artifact = value as Record; + const { title, workspacePath, kind, storage, mimeType, sizeBytes } = + artifact; + if ( + storage !== 'workspace' || + (kind !== 'file' && kind !== 'html') || + typeof title !== 'string' || + !title || + typeof workspacePath !== 'string' || + !workspacePath || + /^(?:[\\/]|[a-z]:)/i.test(workspacePath) || + workspacePath.split(/[\\/]/).includes('..') || + artifact['url'] !== undefined || + artifact['managedId'] !== undefined + ) + return []; + return [ + { + title, + workspacePath, + kind, + storage, + ...(typeof mimeType === 'string' ? { mimeType } : {}), + ...(typeof sizeBytes === 'number' && + Number.isSafeInteger(sizeBytes) && + sizeBytes >= 0 + ? { sizeBytes } + : {}), + }, + ]; + }); +} diff --git a/packages/web-shell/client/adapters/transcriptToMessages.test.ts b/packages/web-shell/client/adapters/transcriptToMessages.test.ts index 92b2878c6b9..c8162bfc925 100644 --- a/packages/web-shell/client/adapters/transcriptToMessages.test.ts +++ b/packages/web-shell/client/adapters/transcriptToMessages.test.ts @@ -80,6 +80,47 @@ it.each([true, false])( }, ); +it('preserves literal slash-command output and artifact references when a directory resembles insight JSON', () => { + const workspacePath = '{"insight_ready":{"path":"fixture"}}/export.md'; + const descriptor = { + kind: 'file', + storage: 'workspace', + title: 'export.md', + workspacePath, + }; + const text = `Session exported to Markdown: ${workspacePath}`; + const block = { + ...textBlock('export-result', 'assistant', text, 1), + meta: { source: 'slash_command', sessionArtifacts: [descriptor] }, + }; + const messages = transcriptBlocksToDaemonMessages([block]); + expect(messages).toHaveLength(1); + expect(messages[0]).toMatchObject({ + role: 'assistant', + content: text, + reportedArtifacts: [descriptor], + }); +}); + +it('retains slash-command insight cards when no export descriptor is reported', () => { + const messages = transcriptBlocksToDaemonMessages([ + { + ...textBlock( + 'insight-result', + 'assistant', + '{"insight_ready":{"path":"/tmp/report.md"}}', + 1, + ), + meta: { source: 'slash_command' }, + }, + ]); + expect(messages).toHaveLength(1); + expect(messages[0]).toMatchObject({ + role: 'insight_ready', + path: '/tmp/report.md', + }); +}); + describe('Assistant branch anchors', () => { it('preserves the checkpoint on the rendered Assistant message', () => { const messages = transcriptBlocksToDaemonMessages([ diff --git a/packages/web-shell/client/adapters/transcriptToMessages.ts b/packages/web-shell/client/adapters/transcriptToMessages.ts index 62329d96eff..a852dcb6bb2 100644 --- a/packages/web-shell/client/adapters/transcriptToMessages.ts +++ b/packages/web-shell/client/adapters/transcriptToMessages.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { readReportedArtifacts } from './reported-artifacts.js'; import { isTaskExecutionMode, parseDaemonBackgroundTurn, @@ -740,6 +741,8 @@ export function transcriptBlocksToDaemonMessages( }); break; } + const meta = getRecord(textBlock.meta); + const reportedArtifacts = readReportedArtifacts(meta ?? undefined); if (notice?.source === 'vision_bridge_notice') { messages.push({ id: block.id, @@ -804,7 +807,10 @@ export function transcriptBlocksToDaemonMessages( break; } - const insightSegments = splitInsightSegments(textBlock.text); + const insightSegments = + reportedArtifacts.length > 0 + ? null + : splitInsightSegments(textBlock.text); if (insightSegments) { let lastProgress: ParsedInsight | null = null; let hasTerminal = false; @@ -889,6 +895,14 @@ export function transcriptBlocksToDaemonMessages( messages[currentAssistantIdx!] = { ...target, content: target.content + textBlock.text, + ...(reportedArtifacts.length + ? { + reportedArtifacts: [ + ...(target.reportedArtifacts ?? []), + ...reportedArtifacts, + ], + } + : {}), isStreaming: textBlock.streaming, sourceBlockIds: unionMessageIds(target.sourceBlockIds, block.id), ...(textBlock.branchRecordId @@ -903,6 +917,7 @@ export function transcriptBlocksToDaemonMessages( id: block.id, role: 'assistant', content: textBlock.text, + ...(reportedArtifacts.length ? { reportedArtifacts } : {}), isStreaming: textBlock.streaming, timestamp: blockTime, sourceBlockIds: [block.id], diff --git a/packages/web-shell/client/components/artifacts/ArtifactPanel.module.css b/packages/web-shell/client/components/artifacts/ArtifactPanel.module.css index 2afa978fdf0..88102888cf1 100644 --- a/packages/web-shell/client/components/artifacts/ArtifactPanel.module.css +++ b/packages/web-shell/client/components/artifacts/ArtifactPanel.module.css @@ -1088,7 +1088,8 @@ button.treeRow:hover { } .htmlPreviewWrap { - min-height: 100%; + height: 100%; + min-height: 0; display: flex; flex-direction: column; } @@ -1175,8 +1176,9 @@ button.treeRow:hover { .htmlPreview { width: 100%; + height: 100%; flex: 1 1 auto; - min-height: 520px; + min-height: 0; border: 1px solid var(--border); border-radius: 8px; background: #fff; @@ -1190,7 +1192,9 @@ button.treeRow:hover { } .filePreviewWrap { - min-height: 100%; + position: absolute; + inset: 12px; + min-height: 0; display: flex; flex-direction: column; } @@ -1198,7 +1202,7 @@ button.treeRow:hover { .codeMirrorFile { min-width: 0; flex: 1 1 auto; - min-height: 520px; + min-height: 0; overflow: auto; border: 1px solid var(--border); border-radius: 8px; @@ -1206,13 +1210,14 @@ button.treeRow:hover { } .codeMirrorFile :global(.cm-editor) { - min-height: 520px; + height: 100%; font-size: 12px; background: var(--background); color: var(--foreground); } .codeMirrorFile :global(.cm-scroller) { + overflow: auto; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; diff --git a/packages/web-shell/client/components/artifacts/ArtifactPanel.test.tsx b/packages/web-shell/client/components/artifacts/ArtifactPanel.test.tsx index 9c3cee3af1a..f6358deab40 100644 --- a/packages/web-shell/client/components/artifacts/ArtifactPanel.test.tsx +++ b/packages/web-shell/client/components/artifacts/ArtifactPanel.test.tsx @@ -1,7 +1,9 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest'; import { act, StrictMode } from 'react'; +import { Blob as NodeBlob } from 'node:buffer'; import { createRoot, type Root } from 'react-dom/client'; +import { EditorView } from 'codemirror'; import type { DaemonSessionArtifact, DaemonSessionMonitorTaskStatus, @@ -240,9 +242,10 @@ function artifactPanel( workspaceCwd: '/primary', workspaceId: 'primary-id', }, + language: 'en' | 'zh-CN' = 'en', ) { return ( - + { + vi.unstubAllGlobals(); + // The boundary matrix spies on DOMParser.prototype per row; restore it so + // the leak cannot skew call-count assertions in later tests. + vi.restoreAllMocks(); delete (window as { __TAURI__?: unknown }).__TAURI__; for (const { root, container } of mounted) { act(() => root.unmount()); @@ -716,6 +723,16 @@ async function flush() { }); } +// jsdom completes FileReader reads via setImmediate, a macrotask the +// microtask-only flush() never drains. +async function flushPreview() { + await act(async () => { + for (let i = 0; i < 4; i += 1) { + await new Promise((resolve) => setImmediate(resolve)); + } + }); +} + describe('ArtifactPanel code review artifacts', () => { it('reads a saved artifact from the panel tab source session', async () => { const container = document.createElement('div'); @@ -1220,8 +1237,10 @@ describe('ArtifactPanel code review artifacts', () => { expect(container.textContent).toContain('Authoritative verdict'); expect(container.textContent).toContain('Verdict: Approve'); expect(container.querySelector('.cm-editor')).toBeNull(); + // The dedicated renderer reads the whole document, so it passes no window. expect(mockWorkspaceActions.readWorkspaceFile).toHaveBeenCalledWith( '.qwen/reviews/review.json', + undefined, ); }); @@ -1437,6 +1456,7 @@ describe('ArtifactPanel code review artifacts', () => { expect(container.textContent).not.toContain('Authoritative verdict'); expect(mockWorkspaceActions.readWorkspaceFile).toHaveBeenCalledWith( '.qwen/reviews/review.json', + { maxBytes: 256 * 1024 }, ); }); @@ -3040,6 +3060,486 @@ describe('ArtifactPanel image preview tabs', () => { }); describe('ArtifactPanel workspace artifact previews', () => { + it.each([ + ['html', 'en', -1, false], + ['html', 'en', 0, false], + ['html', 'en', 1, false], + ['md', 'zh-CN', -1, false], + ['md', 'zh-CN', 0, false], + ['md', 'zh-CN', 1, false], + ['html', 'en', 1, true], + ['md', 'zh-CN', 1, true], + ] as const)( + 'previews %s in %s at 1 MiB plus %i bytes (truncated: %s)', + async (extension, language, extraBytes, truncated) => { + const parseHtml = vi.spyOn(DOMParser.prototype, 'parseFromString'); + const heading = + extension === 'html' + ? '

Large document

\n' + : '# Large document\n'; + const paddingBytes = + 1024 * 1024 + extraBytes - Buffer.byteLength(heading + ''); + const content = + heading + + ''; + mockWorkspaceActions.stat.mockResolvedValue({ + type: 'file', + sizeBytes: Buffer.byteLength(content), + modifiedMs: 1, + }); + mockWorkspaceActions.readWorkspaceFile.mockResolvedValue({ + content: truncated ? content.slice(0, 100) : content, + encoding: 'utf-8', + sizeBytes: Buffer.byteLength(content), + truncated, + }); + if (truncated) { + vi.stubGlobal('Blob', NodeBlob); + const bytes = Buffer.from(content); + mockWorkspaceActions.readFileBytes.mockImplementation( + async (_path, { offset, maxBytes }) => { + const chunk = bytes.subarray(offset, offset + maxBytes); + return { + contentBase64: chunk.toString('base64'), + offset, + returnedBytes: chunk.length, + sizeBytes: bytes.length, + }; + }, + ); + } + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + mounted.push({ root, container }); + act(() => + root.render( + artifactPanel( + { + id: 'large-document', + kind: extension === 'html' ? 'html' : 'file', + storage: 'workspace', + source: 'tool', + status: 'available', + title: 'Large document', + workspacePath: `large.${extension}`, + retention: 'ephemeral', + clientRetained: false, + createdAt: '2026-09-16T00:00:00.000Z', + updatedAt: '2026-09-16T00:00:00.000Z', + }, + undefined, + language, + ), + ), + ); + await flush(); + if (truncated) + expect(mockWorkspaceActions.readFileBytes).toHaveBeenCalledTimes(5); + if (extraBytes <= 0) { + expect(container.querySelector('.cm-editor')).toBeNull(); + expect( + container.querySelector(extension === 'html' ? 'iframe' : 'h1'), + ).not.toBeNull(); + return; + } + expect(container.querySelector('iframe, h1')).toBeNull(); + expect(parseHtml).not.toHaveBeenCalled(); + const editor = container.querySelector('.cm-editor')!; + expect(EditorView.findFromDOM(editor)?.state.doc.toString()).toBe( + content, + ); + const toggle = Array.from(container.querySelectorAll('button')).find( + (button) => + button.textContent === + (language === 'en' ? 'Render full preview' : '完整排版预览'), + )!; + expect(container.textContent).toContain( + language === 'en' + ? 'File is large. Source is shown by default.' + : '文件过大,默认展示源码。', + ); + expect(toggle).toBeTruthy(); + act(() => toggle.click()); + await flush(); + expect( + container.querySelector(extension === 'html' ? 'iframe' : 'h1'), + ).not.toBeNull(); + if (extension === 'md') { + expect(container.querySelector('iframe')).toBeNull(); + } + expect(container.querySelector('.cm-editor')).toBeNull(); + act(() => toggle.click()); + await flush(); + expect( + EditorView.findFromDOM( + container.querySelector('.cm-editor')!, + )?.state.doc.toString(), + ).toBe(content); + }, + ); + + it('restores the DOMParser spy after the boundary matrix', () => { + expect(vi.isMockFunction(DOMParser.prototype.parseFromString)).toBe(false); + }); + + it('localizes the export preview loading placeholder', async () => { + vi.stubGlobal('__WEB_SHELL_VERSION__', '0.23.4'); + const base = 'https://unpkg.com/@qwen-code/qwen-code@0.23.4/'; + const integrity = `sha384-${'a'.repeat(64)}`; + const content = ``; + mockWorkspaceActions.stat.mockResolvedValue({ + type: 'file', + sizeBytes: content.length, + modifiedMs: 1, + }); + mockWorkspaceActions.readWorkspaceFile.mockResolvedValue({ + content, + encoding: 'utf-8', + truncated: false, + }); + // The renderer fetch never settles, so the placeholder stays on screen. + vi.stubGlobal( + 'fetch', + vi.fn(() => new Promise(() => {})), + ); + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + mounted.push({ root, container }); + + act(() => + root.render( + artifactPanel( + { + id: 'export-preview', + kind: 'html', + storage: 'workspace', + source: 'client', + status: 'available', + title: 'export.html', + workspacePath: 'export.html', + mimeType: 'text/html; charset=utf-8', + clientRetained: false, + createdAt: '2026-09-16T00:00:00.000Z', + updatedAt: '2026-09-16T00:00:00.000Z', + }, + undefined, + 'zh-CN', + ), + ), + ); + await flush(); + + expect(container.textContent).toContain('正在加载预览...'); + expect(container.textContent).not.toContain('Loading preview...'); + }); + + it('surfaces a failed export preview instead of loading forever', async () => { + vi.stubGlobal('__WEB_SHELL_VERSION__', '0.23.4'); + const base = 'https://unpkg.com/@qwen-code/qwen-code@0.23.4/'; + const integrity = `sha384-${'a'.repeat(64)}`; + const content = ``; + mockWorkspaceActions.stat.mockResolvedValue({ + type: 'file', + sizeBytes: content.length, + modifiedMs: 1, + }); + mockWorkspaceActions.readWorkspaceFile.mockResolvedValue({ + content, + encoding: 'utf-8', + truncated: false, + }); + // An SRI mismatch rejects the renderer fetch; the panel must surface that + // failure rather than leaving the placeholder up forever. + vi.stubGlobal( + 'fetch', + vi.fn().mockRejectedValue(new TypeError('Integrity mismatch')), + ); + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + mounted.push({ root, container }); + + act(() => + root.render( + artifactPanel({ + id: 'export-preview', + kind: 'html', + storage: 'workspace', + source: 'client', + status: 'available', + title: 'export.html', + workspacePath: 'export.html', + mimeType: 'text/html; charset=utf-8', + clientRetained: false, + createdAt: '2026-09-16T00:00:00.000Z', + updatedAt: '2026-09-16T00:00:00.000Z', + }), + ), + ); + await flushPreview(); + + expect(container.textContent).toContain( + 'Could not load preview: Integrity mismatch', + ); + expect(container.textContent).not.toContain('Loading preview...'); + }); + + it('reuses the built export preview when toggling between source and rendered views', async () => { + vi.stubGlobal('__WEB_SHELL_VERSION__', '0.23.4'); + const base = 'https://unpkg.com/@qwen-code/qwen-code@0.23.4/'; + const integrity = `sha384-${'a'.repeat(64)}`; + const padding = 'x'.repeat(1024 * 1024 + 512); + const content = ``; + mockWorkspaceActions.stat.mockResolvedValue({ + type: 'file', + sizeBytes: Buffer.byteLength(content), + modifiedMs: 1, + }); + mockWorkspaceActions.readWorkspaceFile.mockResolvedValue({ + content, + encoding: 'utf-8', + truncated: false, + }); + const fetchMock = vi.fn(async () => new Response('verified bytes')); + vi.stubGlobal('fetch', fetchMock); + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + mounted.push({ root, container }); + + act(() => + root.render( + artifactPanel({ + id: 'export-preview-toggle', + kind: 'html', + storage: 'workspace', + source: 'client', + status: 'available', + title: 'export-toggle.html', + workspacePath: 'export-toggle.html', + mimeType: 'text/html; charset=utf-8', + clientRetained: false, + createdAt: '2026-09-16T00:00:00.000Z', + updatedAt: '2026-09-16T00:00:00.000Z', + }), + ), + ); + await flushPreview(); + + const clickToggle = (label: string) => { + const button = Array.from(container.querySelectorAll('button')).find( + (candidate) => candidate.textContent === label, + ); + expect(button?.textContent).toBe(label); + act(() => button!.click()); + }; + // Large documents open in source view; nothing is fetched until the user + // asks for the rendered preview. + expect(container.querySelector('.cm-editor')).not.toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + + clickToggle('Render full preview'); + await flushPreview(); + expect(container.querySelector('iframe')).not.toBeNull(); + expect(fetchMock).toHaveBeenCalledTimes(2); + + clickToggle('Show source'); + await flushPreview(); + expect(container.querySelector('.cm-editor')).not.toBeNull(); + + clickToggle('Render full preview'); + await flushPreview(); + expect(container.querySelector('iframe')).not.toBeNull(); + // The remount must not re-fetch and re-encode the renderer assets. + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('gates a large preview without copying the content into a Blob when no size is known', async () => { + const content = `

Large document

\n`; + // The stat never resolves, so neither the reader's sizeBytes nor a + // catalog size is available: the 1 MiB gate must measure the seeded + // preview content without allocating a Blob copy of it. + mockWorkspaceActions.stat.mockReturnValue(new Promise(() => {})); + const blobSpy = vi.fn(); + const OriginalBlob = globalThis.Blob; + vi.stubGlobal( + 'Blob', + class extends OriginalBlob { + constructor(...args: ConstructorParameters) { + blobSpy(...args); + super(...args); + } + }, + ); + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + mounted.push({ root, container }); + + act(() => + root.render( + + {}} + onCloseTab={() => {}} + onOpenFilePreview={() => {}} + onClose={() => {}} + /> + , + ), + ); + await flush(); + + // The gate decided from the seeded content alone: source view first. + expect(container.textContent).toContain( + 'File is large. Source is shown by default.', + ); + expect( + blobSpy.mock.calls.some( + ([parts]) => Array.isArray(parts) && parts.includes(content), + ), + ).toBe(false); + }); + + it('uses the catalog sizeBytes for the large-document gate before the file is read', async () => { + const content = '

Small export

'; + // The stat never resolves, so only the catalog sizeBytes threaded from + // the artifact detail can drive the 1 MiB gate. + mockWorkspaceActions.stat.mockReturnValue(new Promise(() => {})); + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + mounted.push({ root, container }); + + act(() => + root.render( + + {}} + onCloseTab={() => {}} + onOpenFilePreview={() => {}} + onClose={() => {}} + /> + , + ), + ); + await flush(); + + // Tiny content, large catalog size: the gate proves the threaded + // sizeBytes won over measuring the content. + expect(container.textContent).toContain( + 'File is large. Source is shown by default.', + ); + }); + + it('reads a secondary-workspace file through the capped preview window', async () => { + mockSecondaryWorkspaceActions.fileStat.mockResolvedValue({ + type: 'file', + sizeBytes: 2, + modifiedMs: 1, + }); + mockSecondaryWorkspaceActions.readWorkspaceFile.mockResolvedValue({ + content: '{}', + truncated: false, + }); + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + mounted.push({ root, container }); + + act(() => + root.render( + artifactPanel( + { + id: 'secondary-file', + kind: 'file', + storage: 'workspace', + source: 'tool', + status: 'available', + title: 'report.json', + workspacePath: 'report.json', + retention: 'ephemeral', + clientRetained: false, + createdAt: '2026-09-16T00:00:00.000Z', + updatedAt: '2026-09-16T00:00:00.000Z', + }, + { workspaceCwd: '/secondary', workspaceId: 'secondary-id' }, + ), + ), + ); + await flush(); + + expect( + mockSecondaryWorkspaceActions.readWorkspaceFile, + ).toHaveBeenCalledWith('report.json', { maxBytes: 256 * 1024 }); + }); + it.each([ { label: 'Markdown', @@ -3088,6 +3588,7 @@ describe('ArtifactPanel workspace artifact previews', () => { expect(mockWorkspaceActions.readWorkspaceFile).toHaveBeenCalledWith( 'reports/preview', + { maxBytes: 256 * 1024 }, ); if (testCase.label === 'Markdown') { expect(container.querySelector('h1')?.textContent).toBe( @@ -3105,6 +3606,154 @@ describe('ArtifactPanel workspace artifact previews', () => { } }); + it('loads the complete bytes when a text read is truncated without a cursor', async () => { + vi.stubGlobal('Blob', NodeBlob); + const content = + '# Exported session\n\n' + '中'.repeat(100_000) + '\n\n## Later turn'; + const bytes = Buffer.from(content); + mockWorkspaceActions.stat.mockResolvedValue({ + type: 'file', + sizeBytes: bytes.length, + modifiedMs: 1, + }); + mockWorkspaceActions.readWorkspaceFile.mockResolvedValue({ + content: content.slice(0, 100), + encoding: 'utf-8', + truncated: true, + hasMore: true, + nextCursor: null, + }); + mockWorkspaceActions.readFileBytes.mockImplementation( + async (_path, { offset, maxBytes }) => { + const chunk = bytes.subarray(offset, offset + maxBytes); + return { + contentBase64: chunk.toString('base64'), + offset, + returnedBytes: chunk.length, + sizeBytes: bytes.length, + }; + }, + ); + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + mounted.push({ root, container }); + + act(() => + root.render( + artifactPanel({ + id: 'review-artifact', + kind: 'file', + storage: 'workspace', + source: 'client', + status: 'available', + title: 'qwen-code-export-2026-09-16T00-00-00-000Z.md', + workspacePath: 'qwen-code-export-2026-09-16T00-00-00-000Z.md', + mimeType: 'text/markdown; charset=utf-8', + retention: 'ephemeral', + clientRetained: false, + createdAt: '2026-09-16T00:00:00.000Z', + updatedAt: '2026-09-16T00:00:00.000Z', + }), + ), + ); + await flush(); + + expect(mockWorkspaceActions.readWorkspaceFile).toHaveBeenNthCalledWith( + 1, + 'qwen-code-export-2026-09-16T00-00-00-000Z.md', + { maxBytes: 256 * 1024 }, + ); + expect(mockWorkspaceActions.readFileBytes).toHaveBeenCalledTimes(2); + expect(container.textContent).toContain('Exported session'); + expect(container.textContent).toContain('Later turn'); + expect(container.textContent).not.toContain( + 'Preview is truncated because the file is too large.', + ); + }); + + it.each(['html', 'md', 'txt'])( + 'stops showing loading when a %s file read fails', + async (extension) => { + mockWorkspaceActions.stat.mockResolvedValue({ + type: 'file', + sizeBytes: 200, + modifiedMs: 1, + }); + mockWorkspaceActions.readWorkspaceFile.mockRejectedValue( + new Error('File changed while loading.'), + ); + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + mounted.push({ root, container }); + act(() => + root.render( + artifactPanel({ + id: 'failed-preview', + kind: extension === 'html' ? 'html' : 'file', + storage: 'workspace', + source: 'client', + status: 'available', + title: `failed.${extension}`, + workspacePath: `failed.${extension}`, + clientRetained: false, + createdAt: '2026-09-16', + updatedAt: '2026-09-16', + }), + ), + ); + await flush(); + expect(container.textContent).toContain('File changed while loading.'); + expect(container.textContent).not.toMatch(/Loading (?:preview|file)/); + expect(container.querySelector('iframe, .cm-editor')).toBeNull(); + }, + ); + + it('rejects a full preview above the existing download size ceiling', async () => { + mockWorkspaceActions.stat.mockResolvedValue({ + type: 'file', + sizeBytes: 200 * 1024 * 1024, + modifiedMs: 1, + }); + mockWorkspaceActions.readWorkspaceFile.mockImplementation(async () => ({ + content: 'x', + truncated: true, + hasMore: true, + nextCursor: null, + returnedBytes: 256 * 1024, + })); + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + mounted.push({ root, container }); + + act(() => + root.render( + artifactPanel({ + id: 'review-artifact', + kind: 'file', + storage: 'workspace', + source: 'client', + status: 'available', + title: 'qwen-code-export-2026-09-16T00-00-00-000Z.md', + workspacePath: 'qwen-code-export-2026-09-16T00-00-00-000Z.md', + mimeType: 'text/markdown; charset=utf-8', + retention: 'ephemeral', + clientRetained: false, + createdAt: '2026-09-16T00:00:00.000Z', + updatedAt: '2026-09-16T00:00:00.000Z', + }), + ), + ); + await flush(); + + expect(container.textContent).toContain( + 'File is too large to preview or download.', + ); + expect(container.textContent).not.toMatch(/Loading (?:preview|file)/); + }); + it('renders a document artifact as download-only and does not preview it', async () => { mockWorkspaceActions.stat.mockResolvedValue({ type: 'file', diff --git a/packages/web-shell/client/components/artifacts/ArtifactPanel.tsx b/packages/web-shell/client/components/artifacts/ArtifactPanel.tsx index 6b38c8c771b..2da35a54d35 100644 --- a/packages/web-shell/client/components/artifacts/ArtifactPanel.tsx +++ b/packages/web-shell/client/components/artifacts/ArtifactPanel.tsx @@ -44,6 +44,7 @@ import { useRef, useState, type CSSProperties, + type ReactNode, type PointerEvent as ReactPointerEvent, } from 'react'; import { useI18n } from '../../i18n'; @@ -83,7 +84,7 @@ import { normalizeArtifactMimeType, normalizePath, readWorkspaceFileAsBlob, - artifactPreviewDocument, + loadArtifactPreviewDocument, } from './artifactUtils'; import { displayPath, @@ -2848,6 +2849,7 @@ function ArtifactDetail({ artifactVersion={getArtifactFreshnessKey(artifact)} workspaceActions={workspaceActions} previewContent={previewContent} + previewSizeBytes={artifact.sizeBytes} imageMimeType={imageMimeType} previewKind={ isHtmlArtifact(artifact) @@ -3235,21 +3237,23 @@ function SourceDetail({ } /> ) : ( - +
+ +
)} ); @@ -3300,6 +3304,7 @@ function WorkspaceFilePreview({ artifactVersion, workspaceActions, previewContent, + previewSizeBytes, previewData, previewMimeType, imageMimeType, @@ -3311,6 +3316,7 @@ function WorkspaceFilePreview({ artifactVersion?: string; workspaceActions: ArtifactWorkspaceActions; previewContent?: string; + previewSizeBytes?: number; previewData?: Blob; previewMimeType?: string; imageMimeType?: string; @@ -3349,6 +3355,7 @@ function WorkspaceFilePreview({ artifactVersion={artifactVersion} workspaceActions={workspaceActions} previewContent={previewContent} + previewSizeBytes={previewSizeBytes} previewOnly={previewOnly} onLoadError={onLoadError} /> @@ -3361,6 +3368,7 @@ function WorkspaceFilePreview({ artifactVersion={artifactVersion} workspaceActions={workspaceActions} previewContent={previewContent} + previewSizeBytes={previewSizeBytes} previewOnly={previewOnly} onLoadError={onLoadError} /> @@ -3477,6 +3485,7 @@ function TextAttachmentPreview({ workspacePath={workspacePath} workspaceActions={workspaceActions} previewContent={content} + previewSizeBytes={data.size} previewOnly previewKind={previewKind} /> @@ -3613,7 +3622,6 @@ function useWorkspaceFileContent({ workspaceActions, previewContent, previewOnly, - truncatedMessage, onLoadError, }: { workspacePath: string; @@ -3621,16 +3629,17 @@ function useWorkspaceFileContent({ workspaceActions: ArtifactWorkspaceActions; previewContent?: string; previewOnly?: boolean; - truncatedMessage: string; onLoadError?: (error: string) => void; }) { const [content, setContent] = useState(previewContent ?? null); + const [sizeBytes, setSizeBytes] = useState(); const [error, setError] = useState(null); useEffect(() => { let cancelled = false; setContent(previewContent ?? null); setError(null); + setSizeBytes(undefined); if (previewOnly) return undefined; workspaceActions .stat(workspacePath) @@ -3639,12 +3648,32 @@ function useWorkspaceFileContent({ if (stat.type === 'directory') { throw new Error('Directories cannot be opened as artifacts.'); } - return workspaceActions.readWorkspaceFile(workspacePath); + return workspaceActions.readWorkspaceFile(workspacePath, { + maxBytes: 256 * 1024, + }); + }) + .then(async (file) => { + if (cancelled || !file || !file.truncated) return file; + const blob = await readWorkspaceFileAsBlob( + (filePath, opts) => workspaceActions.readFileBytes(filePath, opts), + workspacePath, + 'application/octet-stream', + { + statFile: (filePath) => workspaceActions.stat(filePath), + isCancelled: () => cancelled, + }, + ); + return { + sizeBytes: blob.size, + content: new TextDecoder(file.encoding || 'utf-8').decode( + await blob.arrayBuffer(), + ), + }; }) .then((file) => { if (cancelled || !file) return; + setSizeBytes(file.sizeBytes); setContent(file.content); - if (file.truncated) setError(truncatedMessage); }) .catch((err: unknown) => { if (cancelled) return; @@ -3659,13 +3688,12 @@ function useWorkspaceFileContent({ artifactVersion, previewContent, previewOnly, - truncatedMessage, onLoadError, workspaceActions, workspacePath, ]); - return { content, error }; + return { content, error, sizeBytes }; } function HtmlArtifactPreview({ @@ -3673,6 +3701,7 @@ function HtmlArtifactPreview({ artifactVersion, workspaceActions, previewContent, + previewSizeBytes, previewOnly, onLoadError, }: { @@ -3680,37 +3709,87 @@ function HtmlArtifactPreview({ artifactVersion?: string; workspaceActions: ArtifactWorkspaceActions; previewContent?: string; + previewSizeBytes?: number; previewOnly?: boolean; onLoadError?: (error: string) => void; }) { - const { content, error } = useWorkspaceFileContent({ + const { content, error, sizeBytes } = useWorkspaceFileContent({ workspacePath, artifactVersion, workspaceActions, previewContent, previewOnly, - truncatedMessage: 'Preview is truncated because the file is too large.', onLoadError, }); return (
{content === null ? ( -
Loading preview...
+ !error &&
Loading preview...
) : ( - `; +`; } -export function withArtifactPreviewCsp(html: string) { - if (typeof DOMParser === 'undefined') { +// The panel's source/rendered toggle unmounts the preview component on every +// switch, so a remount would otherwise re-run the whole pipeline — both +// renderer asset fetches, the base64 encodes, the DOM parse. The toggle +// always returns to the same (content, title), so one slot covers it; a +// different document simply replaces the slot. +let lastBuiltPreviewDocument: + | { content: string; title: string; document: string } + | undefined; + +export async function loadArtifactPreviewDocument( + html: string, + title: string, + signal: AbortSignal, +): Promise { + if ( + lastBuiltPreviewDocument?.content === html && + lastBuiltPreviewDocument.title === title + ) { + return lastBuiltPreviewDocument.document; + } + const document = await buildArtifactPreviewDocument(html, title, signal); + lastBuiltPreviewDocument = { content: html, title, document }; + return document; +} + +async function buildArtifactPreviewDocument( + html: string, + title: string, + signal: AbortSignal, +): Promise { + if (typeof DOMParser === 'undefined') + return artifactPreviewDocument(html, title); + const doc = new DOMParser().parseFromString(html, 'text/html'); + if ( + !doc.querySelector('script#transcript-document[type="application/json"]') + ) { + return wrapArtifactPreview(prepareArtifactPreview(doc), title); + } + // A rejected arm must stop the sibling: Promise.all settles immediately + // but would otherwise let the other megabyte-sized asset download and + // base64 encode run to completion for a document already discarded. The + // caller's own abort still applies through the forwarded listener. + const linked = new AbortController(); + const forwardAbort = () => linked.abort(); + signal.addEventListener('abort', forwardAbort, { once: true }); + if (signal.aborted) linked.abort(); + try { + await Promise.all( + [ + ['script#transcript-renderer', 'src', 'js', 'text/javascript'], + [ + 'link#transcript-stylesheet[rel="stylesheet"]', + 'href', + 'css', + 'text/css', + ], + ].map(async ([selector, attribute, extension, mimeType]) => { + const asset = doc.querySelector(selector); + const url = asset?.getAttribute(attribute) ?? ''; + const integrity = asset?.getAttribute('integrity') ?? ''; + if (!asset) return; + const expectedUrl = `https://unpkg.com/@qwen-code/qwen-code@${__WEB_SHELL_VERSION__}/export-transcript-document.${extension}`; + if ( + url !== expectedUrl || + !/^sha384-[A-Za-z0-9+/]{64}$/.test(integrity) + ) { + throw new Error( + 'Unsupported export preview resource; use an export matching the current Web Shell version.', + ); + } + // Fetch outside the untrusted document: URL-based CSP would also allow + // its own scripts to send arbitrary query strings to the CDN. + const response = await fetch(url, { + integrity, + signal: linked.signal, + credentials: 'omit', + referrerPolicy: 'no-referrer', + redirect: 'error', + }); + if (!response.ok) + throw new Error( + `Could not load export renderer (${response.status}).`, + ); + const blob = new Blob([await response.arrayBuffer()], { + type: mimeType, + }); + const dataUrl = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(blob); + }); + asset.setAttribute(attribute, dataUrl); + asset.removeAttribute('integrity'); + asset.removeAttribute('crossorigin'); + }), + ); + } finally { + signal.removeEventListener('abort', forwardAbort); + linked.abort(); + } + return wrapArtifactPreview(prepareArtifactPreview(doc), title); +} + +function prepareArtifactPreview(html: string | Document): string { + if (typeof html === 'string' && typeof DOMParser === 'undefined') { return `${stripUnsafePreviewMarkup(html)}`; } - const doc = new DOMParser().parseFromString(html, 'text/html'); + const doc = + typeof html === 'string' + ? new DOMParser().parseFromString(html, 'text/html') + : html; doc .querySelectorAll( 'noscript, meta[http-equiv="refresh" i], meta[http-equiv="Content-Security-Policy" i]', diff --git a/packages/web-shell/client/components/artifacts/turnOutputSelectors.test.ts b/packages/web-shell/client/components/artifacts/turnOutputSelectors.test.ts index d86b121f67f..95856ee468b 100644 --- a/packages/web-shell/client/components/artifacts/turnOutputSelectors.test.ts +++ b/packages/web-shell/client/components/artifacts/turnOutputSelectors.test.ts @@ -22,6 +22,47 @@ function toolGroup(id: string, tools: ACPToolCall[]): ToolGroupMessage { } describe('turnOutputSelectors', () => { + it('associates a slash-command export with its turn through transcript metadata', () => { + const descriptor = { + kind: 'html', + storage: 'workspace', + title: 'export.html', + workspacePath: 'export.html', + }; + const blocks: DaemonTranscriptBlock[] = [ + { id: 'export-user', kind: 'user', text: '/export html' }, + { + id: 'export-result', + kind: 'assistant', + text: 'Session exported to HTML: export.html', + meta: { source: 'slash_command', sessionArtifacts: [descriptor] }, + }, + { id: 'next-user', kind: 'user', text: 'Next turn' }, + ].map((block) => ({ + ...block, + createdAt: 1, + updatedAt: 1, + clientReceivedAt: 1, + })) as DaemonTranscriptBlock[]; + const messages = transcriptBlocksToDaemonMessages(blocks); + const artifact: DaemonSessionArtifact = { + ...descriptor, + kind: 'html', + storage: 'workspace', + id: 'export-artifact', + source: 'client', + status: 'available', + retention: 'ephemeral', + clientRetained: false, + createdAt: '2026-09-16', + updatedAt: '2026-09-16', + }; + const byTurn = getArtifactsByTurn(messages, [artifact], '/workspace'); + expect(byTurn.get('export-user')).toEqual([artifact]); + expect(byTurn.has('next-user')).toBe(false); + expect(getArtifactsByTurn(messages, [], '/workspace').size).toBe(0); + }); + it('keeps each saved version in its original turn and omits the matching local latest card', () => { const latestUrl = 'file:///tmp/publications/latest/index.html'; const messages = [ diff --git a/packages/web-shell/client/components/artifacts/turnOutputSelectors.ts b/packages/web-shell/client/components/artifacts/turnOutputSelectors.ts index d93c7dbd0e8..712597c5e78 100644 --- a/packages/web-shell/client/components/artifacts/turnOutputSelectors.ts +++ b/packages/web-shell/client/components/artifacts/turnOutputSelectors.ts @@ -39,6 +39,14 @@ export function getArtifactsByTurn( continue; } if (!currentTurnId) currentTurnId = message.id; + if (message.role === 'assistant') { + for (const artifact of message.reportedArtifacts ?? []) { + recordArtifactReferences.push({ + turnId: currentTurnId, + workspacePath: artifact.workspacePath, + }); + } + } if (message.role !== 'tool_group') continue; for (const tool of message.tools) { for (const id of getToolCallIds(tool)) { diff --git a/packages/web-shell/client/components/artifacts/useArtifactWorkspaceTarget.ts b/packages/web-shell/client/components/artifacts/useArtifactWorkspaceTarget.ts index 393f630e3d3..02cefbd9f61 100644 --- a/packages/web-shell/client/components/artifacts/useArtifactWorkspaceTarget.ts +++ b/packages/web-shell/client/components/artifacts/useArtifactWorkspaceTarget.ts @@ -143,13 +143,13 @@ export function useArtifactWorkspaceTarget( return current; }; return { - async readWorkspaceFile(filePath) { + async readWorkspaceFile(filePath, opts) { const current = requireOwner(); const result = await (current.primary - ? primaryActions.readWorkspaceFile(filePath) + ? primaryActions.readWorkspaceFile(filePath, opts) : workspace.client .workspaceByCwd(current.cwd) - .readWorkspaceFile(filePath)); + .readWorkspaceFile(filePath, opts)); requireOwner(); return result; }, diff --git a/packages/web-shell/client/daemon/session/actions.ts b/packages/web-shell/client/daemon/session/actions.ts index 39043329158..64b7bd186ee 100644 --- a/packages/web-shell/client/daemon/session/actions.ts +++ b/packages/web-shell/client/daemon/session/actions.ts @@ -22,6 +22,8 @@ import type { DaemonRewindSnapshotInfo, DaemonSessionTaskWithWorkflowStatus, DaemonSessionArtifactsEnvelope, + DaemonSessionArtifactInput, + DaemonSessionArtifactMutationResult, DaemonTranscriptStore, DaemonCapabilities, GoalControlRequest, @@ -3285,6 +3287,17 @@ export function createDaemonSessionActions({ return withActionTimeout(session.artifacts(), 'Load artifacts timed out'); }, + async addArtifact( + artifact: DaemonSessionArtifactInput, + ): Promise { + const session = sessionRef.current; + if (!session) throw new Error('Daemon session is not connected'); + return withActionTimeout( + session.addArtifact(artifact), + 'Add artifact timed out', + ); + }, + async respondToGlobalPermission( requestId: string, response: PermissionResponse, diff --git a/packages/web-shell/client/daemon/session/types.ts b/packages/web-shell/client/daemon/session/types.ts index 24112d45ea0..99ac6be977a 100644 --- a/packages/web-shell/client/daemon/session/types.ts +++ b/packages/web-shell/client/daemon/session/types.ts @@ -39,6 +39,8 @@ import type { DaemonSessionWorkflowTasksStatus, DaemonSessionStatsStatus, DaemonSessionArtifactsEnvelope, + DaemonSessionArtifactInput, + DaemonSessionArtifactMutationResult, SessionSourceInput, SessionSourcesResult, SessionSourceUpsertResult, @@ -707,6 +709,14 @@ export interface DaemonSessionActions { clearGoal(): Promise<{ cleared: boolean; condition?: string }>; getStats(): Promise; loadArtifacts(): Promise; + /** + * Register an artifact this client knows about — a file a slash command + * reported it wrote, for example. The store owns identity, so re-registering + * the same workspace path updates the existing entry instead of duplicating. + */ + addArtifact( + artifact: DaemonSessionArtifactInput, + ): Promise; listSources(): Promise; upsertSource(source: SessionSourceInput): Promise; removeSource(sourceId: string): Promise; diff --git a/packages/web-shell/client/daemon/workspace/actions.ts b/packages/web-shell/client/daemon/workspace/actions.ts index 7f023352a1f..f0359ebe74e 100644 --- a/packages/web-shell/client/daemon/workspace/actions.ts +++ b/packages/web-shell/client/daemon/workspace/actions.ts @@ -602,10 +602,10 @@ export function createDaemonWorkspaceActions({ ); }, - async readWorkspaceFile(filePath) { + async readWorkspaceFile(filePath, opts) { const client = requireClient(getClient, 'Read workspace file failed'); return withActionTimeout( - client.readWorkspaceFile(filePath), + client.readWorkspaceFile(filePath, opts), 'Read workspace file timed out', ); }, diff --git a/packages/web-shell/client/daemon/workspace/types.ts b/packages/web-shell/client/daemon/workspace/types.ts index 2e60259c83e..1140d6ca46b 100644 --- a/packages/web-shell/client/daemon/workspace/types.ts +++ b/packages/web-shell/client/daemon/workspace/types.ts @@ -558,7 +558,15 @@ export interface DaemonWorkspaceActions { // Memory loadMemoryStatus(): Promise; - readWorkspaceFile(filePath: string): Promise; + /** + * `opts.maxBytes` is how a caller accepts partial content: without a window + * argument the daemon refuses any file above its own read cap instead of + * silently handing back a truncated file. + */ + readWorkspaceFile( + filePath: string, + opts?: { maxBytes?: number }, + ): Promise; writeMemory(req: DaemonWriteMemoryRequest): Promise; generateContent( diff --git a/packages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx b/packages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx index f6195f353b3..f010f4b47d2 100644 --- a/packages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx +++ b/packages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx @@ -7551,7 +7551,7 @@ describe('useQueuedPrompts mid-turn reconciliation (session_mid_turn_message_que expect(harness.workspaceFileActions.readFileBytes).toHaveBeenCalledWith( 'docs/notes.txt', - { offset: 0, maxBytes: 100 * 1024 }, + { offset: 0, maxBytes: 256 * 1024 }, ); expect(sdkMock.actions.uploadAttachment).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/packages/web-shell/client/hooks/useReportedArtifactRegistration.test.tsx b/packages/web-shell/client/hooks/useReportedArtifactRegistration.test.tsx new file mode 100644 index 00000000000..4e458993865 --- /dev/null +++ b/packages/web-shell/client/hooks/useReportedArtifactRegistration.test.tsx @@ -0,0 +1,426 @@ +// @vitest-environment jsdom +import { act, StrictMode } from 'react'; +import { DaemonHttpError } from '@qwen-code/sdk/daemon'; +import { I18nProvider } from '../i18n'; +import { requestToast } from '../components/ToastHost'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + collectReportedArtifacts, + useReportedArtifactRegistration, +} from './useReportedArtifactRegistration'; +import type { + DaemonSessionArtifactInput, + DaemonSessionArtifact, + DaemonTranscriptBlock, +} from '@qwen-code/sdk/daemon'; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +const sdk = vi.hoisted(() => ({ + owner: 0, + registered: [] as DaemonSessionArtifact[], + hydrated: true, + blocks: [] as unknown[], + addArtifact: vi.fn(), + refresh: vi.fn(), + guard: { capture: vi.fn() }, + connection: { + status: 'connected', + catchingUp: false, + sessionId: 'session-a', + capabilities: { features: ['session_artifacts'] }, + }, +})); + +vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({ + useActions: () => ({ addArtifact: sdk.addArtifact }), + useConnection: () => sdk.connection, + useDaemonSessionOwnerGuard: () => sdk.guard, +})); + +vi.mock('./useAnimationFrameTranscriptBlocks', () => ({ + useAnimationFrameTranscriptSnapshot: () => ({ blocks: sdk.blocks }), +})); + +vi.mock('../components/ToastHost', () => ({ requestToast: vi.fn() })); + +const exportedArtifact: DaemonSessionArtifactInput = { + kind: 'file', + storage: 'workspace', + title: 'qwen-code-export-2026-01-01T00-00-00-000Z.md', + workspacePath: 'qwen-code-export-2026-01-01T00-00-00-000Z.md', + mimeType: 'text/markdown; charset=utf-8', + sizeBytes: 42, +}; + +function transcriptBlock( + kind: DaemonTranscriptBlock['kind'], + reported: unknown, +): DaemonTranscriptBlock { + return { + id: `block-${kind}`, + kind, + text: 'Session exported to markdown', + clientReceivedAt: 1, + createdAt: 1, + updatedAt: 1, + meta: { source: 'slash_command', sessionArtifacts: reported }, + } as unknown as DaemonTranscriptBlock; +} + +let root: Root; +let container: HTMLDivElement; + +function Host() { + useReportedArtifactRegistration(sdk.registered, sdk.hydrated, sdk.refresh); + return null; +} + +async function render() { + await act(async () => { + root.render( + + + , + ); + }); +} + +beforeEach(() => { + sdk.owner = 0; + sdk.registered = []; + sdk.hydrated = true; + sdk.blocks = []; + sdk.connection.capabilities.features = ['session_artifacts']; + sdk.connection.status = 'connected'; + sdk.connection.catchingUp = false; + sdk.connection.sessionId = 'session-a'; + sdk.guard.capture.mockImplementation(() => { + const owner = sdk.owner; + return { isCurrent: () => owner === sdk.owner }; + }); + sdk.refresh.mockReset().mockResolvedValue(undefined); + vi.mocked(requestToast).mockClear(); + sdk.addArtifact.mockReset(); + sdk.addArtifact.mockImplementation(async () => ({})); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); +}); + +describe('collectReportedArtifacts', () => { + it('reads the descriptors an assistant block reports', () => { + expect( + collectReportedArtifacts([ + transcriptBlock('assistant', [exportedArtifact]), + ]), + ).toEqual([exportedArtifact]); + }); + + it('ignores blocks that cannot carry message metadata', () => { + // Only text blocks keep raw `_meta`; a tool block reporting the same key + // must not be read as if it were the command result. + expect( + collectReportedArtifacts([transcriptBlock('tool', [exportedArtifact])]), + ).toEqual([]); + }); + + it('drops entries the store would reject instead of failing the batch', () => { + const reported = [ + exportedArtifact, + { title: 'no locator' }, + { ...exportedArtifact, workspacePath: undefined }, + { + ...exportedArtifact, + workspacePath: 'a.md', + url: 'https://example.com/a.md', + }, + // Three defects at once (reserved storage, no workspacePath, a url): + // it trips multiple guards, so it cannot pin any one of them — the + // single-defect fixtures live in adapters/reported-artifacts.test.ts. + { + ...exportedArtifact, + storage: 'published', + workspacePath: undefined, + url: 'https://example.com/a.html', + }, + { ...exportedArtifact, title: undefined }, + 'not an object', + ]; + + expect( + collectReportedArtifacts([transcriptBlock('assistant', reported)]), + ).toEqual([exportedArtifact]); + }); + + it('ignores model-authored metadata and non-workspace locators', () => { + const block = transcriptBlock('assistant', [exportedArtifact]); + expect( + collectReportedArtifacts([ + { + ...block, + meta: { sessionArtifacts: [exportedArtifact] }, + } as DaemonTranscriptBlock, + ]), + ).toEqual([]); + expect( + collectReportedArtifacts([ + transcriptBlock('assistant', [ + { + ...exportedArtifact, + storage: 'external_url', + workspacePath: undefined, + url: 'https://example.com/a.html', + }, + { ...exportedArtifact, workspacePath: '../secret.html' }, + ]), + ]), + ).toEqual([]); + }); + + it('accepts POSIX directory names containing a literal backslash', () => { + const artifact = { + ...exportedArtifact, + workspacePath: 'reports\\archive/export.md', + }; + expect( + collectReportedArtifacts([transcriptBlock('assistant', [artifact])]), + ).toEqual([artifact]); + }); + + it.each([ + '../secret.md', + '..\\secret.md', + 'reports/..\\secret.md', + '/secret.md', + '\\\\server\\share\\secret.md', + 'C:\\secret.md', + 'C:secret.md', + ])('rejects absolute paths and traversal: %s', (workspacePath) => { + expect( + collectReportedArtifacts([ + transcriptBlock('assistant', [{ ...exportedArtifact, workspacePath }]), + ]), + ).toEqual([]); + }); + + it('leaves a malformed payload alone rather than throwing', () => { + expect( + collectReportedArtifacts([transcriptBlock('assistant', 'nope')]), + ).toEqual([]); + }); +}); + +describe('useReportedArtifactRegistration', () => { + it('does not submit twice when StrictMode replays effects', async () => { + sdk.blocks = [transcriptBlock('assistant', [exportedArtifact])]; + await act(async () => + root.render( + + + , + ), + ); + expect(sdk.addArtifact).toHaveBeenCalledTimes(1); + }); + + it('refreshes the catalog after another client wins concurrent registration', async () => { + sdk.blocks = [transcriptBlock('assistant', [exportedArtifact])]; + sdk.addArtifact + .mockResolvedValueOnce({}) + .mockRejectedValueOnce( + new DaemonHttpError( + 403, + { code: 'session_artifact_forbidden' }, + 'owned by another client', + ), + ); + sdk.refresh.mockResolvedValue([{ ...exportedArtifact, id: 'existing' }]); + await act(async () => + root.render( + <> + + + , + ), + ); + expect(sdk.addArtifact).toHaveBeenCalledTimes(2); + expect(sdk.refresh).toHaveBeenCalledTimes(1); + expect(requestToast).not.toHaveBeenCalled(); + }); + + it('treats a cross-client conflict as already registered, without a failure toast', async () => { + // The daemon raises the upsert 403 only after resolving an existing + // artifact for the path, so the outcome does not depend on whether the + // resync comes back with the catalog. + sdk.blocks = [transcriptBlock('assistant', [exportedArtifact])]; + sdk.addArtifact.mockRejectedValue( + new DaemonHttpError( + 403, + { code: 'session_artifact_forbidden' }, + 'conflict', + ), + ); + sdk.refresh.mockResolvedValue(undefined); + await render(); + expect(sdk.refresh).toHaveBeenCalledTimes(1); + expect(requestToast).not.toHaveBeenCalled(); + }); + + it('reports unrelated permission errors in the active language', async () => { + sdk.blocks = [transcriptBlock('assistant', [exportedArtifact])]; + sdk.addArtifact.mockRejectedValue( + new DaemonHttpError( + 403, + { code: 'workspace_untrusted', error: 'Access denied' }, + 'Forbidden', + ), + ); + await act(async () => + root.render( + + + , + ), + ); + expect(sdk.refresh).not.toHaveBeenCalled(); + expect(requestToast).toHaveBeenCalledWith( + 'error', + '无法添加导出文件:Access denied', + ); + }); + + it('ignores a conflict refresh completed after leaving the session', async () => { + sdk.blocks = [transcriptBlock('assistant', [exportedArtifact])]; + sdk.addArtifact.mockRejectedValue( + new DaemonHttpError( + 403, + { code: 'session_artifact_forbidden' }, + 'conflict', + ), + ); + let complete!: (value: undefined) => void; + sdk.refresh.mockImplementation( + () => + new Promise((resolve) => { + complete = resolve; + }), + ); + await render(); + sdk.owner++; + await act(async () => complete(undefined)); + expect(requestToast).not.toHaveBeenCalled(); + }); + + it('stays silent when a registration fails after the session was left', async () => { + sdk.blocks = [transcriptBlock('assistant', [exportedArtifact])]; + let rejectRegistration!: (error: unknown) => void; + sdk.addArtifact.mockImplementation( + () => + new Promise((_, reject) => { + rejectRegistration = reject; + }), + ); + await render(); + expect(sdk.addArtifact).toHaveBeenCalledTimes(1); + + // The user leaves the session while the addArtifact POST is in flight; + // the catch handler's owner guard is the only thing suppressing a toast + // about a session the user is no longer looking at. + sdk.owner += 1; + await act(async () => { + rejectRegistration(new Error('daemon exploded')); + }); + + expect(requestToast).not.toHaveBeenCalled(); + expect(sdk.refresh).not.toHaveBeenCalled(); + }); + + it('registers every reported file exactly once', async () => { + sdk.blocks = [transcriptBlock('assistant', [exportedArtifact])]; + await render(); + + expect(sdk.addArtifact).toHaveBeenCalledTimes(1); + expect(sdk.addArtifact).toHaveBeenCalledWith(exportedArtifact); + + // The transcript hands back a fresh array on every daemon event, so the + // effect re-runs; the same file must not be registered again. + sdk.blocks = [transcriptBlock('assistant', [exportedArtifact])]; + await render(); + + expect(sdk.addArtifact).toHaveBeenCalledTimes(1); + }); + + it('registers again for a file first seen in another session', async () => { + sdk.blocks = [transcriptBlock('assistant', [exportedArtifact])]; + await render(); + sdk.connection.sessionId = 'session-b'; + await render(); + + expect(sdk.addArtifact).toHaveBeenCalledTimes(2); + }); + + it('waits for the catalog and skips files already registered by another client', async () => { + sdk.blocks = [transcriptBlock('assistant', [exportedArtifact])]; + sdk.hydrated = false; + await render(); + expect(sdk.addArtifact).not.toHaveBeenCalled(); + sdk.registered = [ + { + ...exportedArtifact, + id: 'existing', + clientId: 'another-client', + } as DaemonSessionArtifact, + ]; + sdk.hydrated = true; + await render(); + expect(sdk.addArtifact).not.toHaveBeenCalled(); + }); + + it('retries a failed registration after reconnecting', async () => { + sdk.addArtifact.mockRejectedValueOnce(new Error('offline')); + sdk.blocks = [transcriptBlock('assistant', [exportedArtifact])]; + await render(); + sdk.connection.status = 'disconnected'; + await render(); + sdk.connection.status = 'connected'; + await render(); + expect(sdk.addArtifact).toHaveBeenCalledTimes(2); + expect(requestToast).toHaveBeenCalledWith( + 'error', + 'Could not add exported artifact: offline', + ); + }); + + it('stays out of the way without the artifact capability', async () => { + sdk.connection.capabilities.features = []; + sdk.blocks = [transcriptBlock('assistant', [exportedArtifact])]; + await render(); + + expect(sdk.addArtifact).not.toHaveBeenCalled(); + }); + + it('waits for the transcript catch-up to finish', async () => { + sdk.connection.catchingUp = true; + sdk.blocks = [transcriptBlock('assistant', [exportedArtifact])]; + await render(); + + expect(sdk.addArtifact).not.toHaveBeenCalled(); + }); + + it('does not register against a session the client has left', async () => { + sdk.blocks = [transcriptBlock('assistant', [exportedArtifact])]; + sdk.guard.capture.mockImplementation(() => ({ + isCurrent: () => false, + })); + await render(); + + expect(sdk.addArtifact).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/web-shell/client/hooks/useReportedArtifactRegistration.throttle.test.tsx b/packages/web-shell/client/hooks/useReportedArtifactRegistration.throttle.test.tsx new file mode 100644 index 00000000000..3c98f1161d7 --- /dev/null +++ b/packages/web-shell/client/hooks/useReportedArtifactRegistration.throttle.test.tsx @@ -0,0 +1,165 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { DaemonTranscriptBlock } from '@qwen-code/sdk/daemon'; +import { useReportedArtifactRegistration } from './useReportedArtifactRegistration'; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +const testStore = vi.hoisted(() => { + let blocks: readonly DaemonTranscriptBlock[] = []; + const blockIndexById: Readonly> = {}; + const listeners = new Set<() => void>(); + return { + getSnapshot: () => ({ blocks, blockIndexById }), + getBlockChangeSummary: () => undefined, + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + notify(next: readonly DaemonTranscriptBlock[]) { + blocks = next; + listeners.forEach((listener) => listener()); + }, + reset() { + blocks = []; + listeners.clear(); + }, + }; +}); + +const sdk = vi.hoisted(() => ({ + owner: 0, + addArtifact: vi.fn(), + refresh: vi.fn(), + guard: { capture: vi.fn() }, + connection: { + status: 'connected', + catchingUp: false, + sessionId: 'session-a', + capabilities: { features: ['session_artifacts'] }, + }, +})); + +vi.mock('@qwen-code/web-shell/daemon-react-sdk', async () => { + const React = await import('react'); + return { + useActions: () => ({ addArtifact: sdk.addArtifact }), + useConnection: () => sdk.connection, + useDaemonSessionOwnerGuard: () => sdk.guard, + useTranscriptStore: () => testStore, + // Kept subscribed so this suite turns red if the hook regresses to the + // raw, unthrottled `useTranscriptBlocks` subscription. + useTranscriptBlocks: () => + React.useSyncExternalStore( + (notify) => testStore.subscribe(notify), + () => testStore.getSnapshot().blocks, + ), + }; +}); + +vi.mock('../components/ToastHost', () => ({ requestToast: vi.fn() })); + +let root: Root | null = null; +let container: HTMLDivElement | null = null; +let renderCount = 0; + +function Host() { + renderCount += 1; + useReportedArtifactRegistration([], true, sdk.refresh); + return null; +} + +function assistantBlock( + id: string, + text: string, + reported?: unknown, +): DaemonTranscriptBlock { + return { + id, + kind: 'assistant', + text, + clientReceivedAt: 1, + createdAt: 1, + updatedAt: 1, + ...(reported === undefined + ? {} + : { meta: { source: 'slash_command', sessionArtifacts: reported } }), + } as unknown as DaemonTranscriptBlock; +} + +beforeEach(() => { + sdk.owner = 0; + sdk.addArtifact.mockReset().mockImplementation(async () => ({})); + sdk.refresh.mockReset().mockResolvedValue(undefined); + sdk.guard.capture.mockImplementation(() => { + const owner = sdk.owner; + return { isCurrent: () => owner === sdk.owner }; + }); + testStore.reset(); + renderCount = 0; + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root?.unmount()); + root = null; + container?.remove(); + container = null; + vi.restoreAllMocks(); +}); + +describe('useReportedArtifactRegistration transcript subscription', () => { + it('does not re-render its host once per streamed text delta', () => { + let pendingFrame: FrameRequestCallback | null = null; + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { + pendingFrame = callback; + return 1; + }); + vi.spyOn(performance, 'now').mockReturnValue(1_000); + act(() => root!.render()); + const initial = renderCount; + + act(() => { + for (let index = 1; index <= 40; index += 1) { + testStore.notify([assistantBlock('a', 'x'.repeat(index))]); + } + }); + + expect(renderCount).toBe(initial); + expect(pendingFrame).not.toBeNull(); + }); + + it('still registers a descriptor delivered through a throttled frame', async () => { + let pendingFrame: FrameRequestCallback | null = null; + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { + pendingFrame = callback; + return 1; + }); + vi.spyOn(performance, 'now').mockReturnValue(1_000); + const descriptor = { + kind: 'file', + storage: 'workspace', + title: 'qwen-code-export-2026-01-01T00-00-00-000Z.md', + workspacePath: 'qwen-code-export-2026-01-01T00-00-00-000Z.md', + }; + act(() => root!.render()); + expect(sdk.addArtifact).not.toHaveBeenCalled(); + + // A descriptor riding a late-merged, text-less assistant block must reach + // the registrar once the coalesced frame lands. + act(() => { + testStore.notify([assistantBlock('a', '', [descriptor])]); + }); + await act(async () => { + pendingFrame?.(1_000); + await Promise.resolve(); + }); + + expect(sdk.addArtifact).toHaveBeenCalledTimes(1); + expect(sdk.addArtifact).toHaveBeenCalledWith(descriptor); + }); +}); diff --git a/packages/web-shell/client/hooks/useReportedArtifactRegistration.ts b/packages/web-shell/client/hooks/useReportedArtifactRegistration.ts new file mode 100644 index 00000000000..7652902a396 --- /dev/null +++ b/packages/web-shell/client/hooks/useReportedArtifactRegistration.ts @@ -0,0 +1,135 @@ +import { useEffect, useRef } from 'react'; +import { + useActions, + useConnection, + useDaemonSessionOwnerGuard, +} from '@qwen-code/web-shell/daemon-react-sdk'; +import type { + DaemonSessionArtifactInput, + DaemonSessionArtifact, + DaemonTranscriptBlock, +} from '@qwen-code/sdk/daemon'; +import { DaemonHttpError } from '@qwen-code/sdk/daemon'; + +import { requestToast } from '../components/ToastHost'; +import { readReportedArtifacts } from '../adapters/reported-artifacts'; +import { useI18n } from '../i18n'; +import { extractErrorDetail } from '../utils/errorDetail'; +import { useAnimationFrameTranscriptSnapshot } from './useAnimationFrameTranscriptBlocks'; + +const SESSION_ARTIFACTS_FEATURE = 'session_artifacts'; + +export type RegistrationErrorTranslator = ( + key: string, + vars?: Record, +) => string; + +/** + * Files that slash commands reported writing, read back off the transcript. + * + * They ride the assistant block's raw ACP metadata, which is the only place + * arbitrary `_meta` survives on its way to a rendered message. + */ +export function collectReportedArtifacts( + blocks: readonly DaemonTranscriptBlock[], +): DaemonSessionArtifactInput[] { + const artifacts: DaemonSessionArtifactInput[] = []; + for (const block of blocks) { + if (block.kind !== 'assistant') continue; + artifacts.push(...readReportedArtifacts(block.meta)); + } + return artifacts; +} + +/** + * Registers the files slash commands reported writing with the session + * artifact store, so they show up in the artifact panel next to the files the + * agent produced. Wait for the catalog before registering to avoid claiming + * an existing artifact from another browser client. + * + * `translateError`: App's own hook body runs above the `` it + * renders, where the i18n context is the key-passthrough default; App passes + * its own translator so the failure toast is localized on the main view too. + */ +export function useReportedArtifactRegistration( + registered: readonly DaemonSessionArtifact[], + hydrated: boolean, + refresh: () => Promise, + translateError?: RegistrationErrorTranslator, +): void { + const { t: contextTranslate } = useI18n(); + const t = translateError ?? contextTranslate; + const actions = useActions(); + const connection = useConnection(); + const guard = useDaemonSessionOwnerGuard(); + const ownerRef = useRef(guard.capture()); + if (!ownerRef.current.isCurrent()) ownerRef.current = guard.capture(); + const owner = ownerRef.current; + const { blocks } = useAnimationFrameTranscriptSnapshot(); + const sessionId = connection.sessionId; + const attemptedRef = useRef({ + owner, + sessionId, + status: connection.status, + paths: new Set(), + }); + if ( + attemptedRef.current.owner !== owner || + attemptedRef.current.sessionId !== sessionId || + attemptedRef.current.status !== connection.status + ) { + attemptedRef.current = { + owner, + sessionId, + status: connection.status, + paths: new Set(), + }; + } + const supportsArtifacts = + connection.capabilities?.features?.includes(SESSION_ARTIFACTS_FEATURE) ?? + false; + const ready = + hydrated && + supportsArtifacts && + connection.status === 'connected' && + !connection.catchingUp && + Boolean(sessionId); + + useEffect(() => { + if (!ready || !sessionId || !owner.isCurrent()) return; + for (const artifact of collectReportedArtifacts(blocks)) { + if ( + registered.some( + (entry) => entry.workspacePath === artifact.workspacePath, + ) + ) + continue; + const key = artifact.workspacePath; + if (!key || attemptedRef.current.paths.has(key)) continue; + attemptedRef.current.paths.add(key); + void actions.addArtifact(artifact).catch(async (error: unknown) => { + if (!owner.isCurrent()) return; + if ( + error instanceof DaemonHttpError && + error.status === 403 && + error.body && + typeof error.body === 'object' && + 'code' in error.body && + error.body.code === 'session_artifact_forbidden' + ) { + // The daemon raises the cross-client 403 only after resolving an + // existing artifact for this path, so the export IS registered. + // Resync this client's catalog rather than reporting a failure. + await refresh(); + return; + } + requestToast( + 'error', + t('artifact.registrationFailed', { + message: extractErrorDetail(error), + }), + ); + }); + } + }, [actions, blocks, owner, ready, refresh, registered, sessionId, t]); +} diff --git a/packages/web-shell/client/hooks/useSessionArtifacts.test.tsx b/packages/web-shell/client/hooks/useSessionArtifacts.test.tsx index 26f1b17d8aa..45d2def80bd 100644 --- a/packages/web-shell/client/hooks/useSessionArtifacts.test.tsx +++ b/packages/web-shell/client/hooks/useSessionArtifacts.test.tsx @@ -9,12 +9,16 @@ import * as React from 'react'; import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { requestToast } from '../components/ToastHost'; +import { DaemonHttpError } from '@qwen-code/sdk/daemon'; import type { DaemonSessionArtifact } from '@qwen-code/sdk/daemon'; import { useSessionArtifacts, type SessionArtifactsState, } from './useSessionArtifacts'; +vi.mock('../components/ToastHost', () => ({ requestToast: vi.fn() })); + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); interface Deferred { @@ -25,9 +29,11 @@ interface Deferred { const sdkMock = vi.hoisted(() => ({ ownerVersion: 0, + blocks: [] as unknown[], ownerGuard: { capture: vi.fn() }, actions: { loadArtifacts: vi.fn(), + addArtifact: vi.fn(), }, connection: { status: 'connected', @@ -47,6 +53,10 @@ vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({ }), })); +vi.mock('./useAnimationFrameTranscriptBlocks', () => ({ + useAnimationFrameTranscriptSnapshot: () => ({ blocks: sdkMock.blocks }), +})); + let root: Root | null = null; let container: HTMLDivElement | null = null; let latestState: SessionArtifactsState | undefined; @@ -101,6 +111,9 @@ async function rerenderHookHost() { beforeEach(() => { latestState = undefined; + vi.mocked(requestToast).mockClear(); + sdkMock.blocks = []; + sdkMock.actions.addArtifact.mockReset(); sdkMock.connection = { status: 'connected', sessionId: 'session-a', @@ -128,6 +141,85 @@ afterEach(async () => { }); describe('useSessionArtifacts', () => { + it('refreshes concurrent registration conflicts without discarding successful checks', async () => { + const exported = ['first', 'second'].map((id) => ({ + ...artifact(id), + source: 'client' as const, + clientId: 'another-client', + })); + sdkMock.actions.loadArtifacts + .mockResolvedValueOnce({ artifacts: [] }) + .mockResolvedValue({ artifacts: exported }); + sdkMock.actions.addArtifact.mockRejectedValue( + new DaemonHttpError( + 403, + { code: 'session_artifact_forbidden' }, + 'already owned', + ), + ); + await renderHookHost(); + expect(latestState?.artifacts).toEqual([]); + sdkMock.blocks = [ + { + id: 'export-result', + kind: 'assistant', + text: 'Exported', + meta: { + source: 'slash_command', + sessionArtifacts: exported.map(({ title, workspacePath }) => ({ + kind: 'html', + storage: 'workspace', + title, + workspacePath, + })), + }, + }, + ]; + await rerenderHookHost(); + expect(sdkMock.actions.addArtifact).toHaveBeenCalledTimes(2); + expect(sdkMock.actions.loadArtifacts).toHaveBeenCalledTimes(3); + expect(latestState?.artifacts).toEqual(exported); + expect(requestToast).not.toHaveBeenCalled(); + }); + + it('treats a cross-client registration conflict as benign even when the resync fails', async () => { + const exported = artifact('first'); + sdkMock.actions.loadArtifacts + .mockResolvedValueOnce({ artifacts: [] }) + .mockRejectedValueOnce(new Error('Failed to fetch')); + sdkMock.actions.addArtifact.mockRejectedValue( + new DaemonHttpError( + 403, + { code: 'session_artifact_forbidden' }, + 'already owned', + ), + ); + await renderHookHost(); + expect(latestState?.artifacts).toEqual([]); + sdkMock.blocks = [ + { + id: 'export-result', + kind: 'assistant', + text: 'Exported', + meta: { + source: 'slash_command', + sessionArtifacts: [ + { + kind: 'html', + storage: 'workspace', + title: exported.title, + workspacePath: exported.workspacePath, + }, + ], + }, + }, + ]; + await rerenderHookHost(); + expect(sdkMock.actions.addArtifact).toHaveBeenCalledTimes(1); + expect(sdkMock.actions.loadArtifacts).toHaveBeenCalledTimes(2); + expect(requestToast).not.toHaveBeenCalled(); + }); + it('loads new sessions and reuses a previous session result', async () => { const sessionA = deferred<{ artifacts: DaemonSessionArtifact[] }>(); const sessionB = deferred<{ artifacts: DaemonSessionArtifact[] }>(); diff --git a/packages/web-shell/client/hooks/useSessionArtifacts.ts b/packages/web-shell/client/hooks/useSessionArtifacts.ts index a186b442f26..a4d43daeccc 100644 --- a/packages/web-shell/client/hooks/useSessionArtifacts.ts +++ b/packages/web-shell/client/hooks/useSessionArtifacts.ts @@ -7,6 +7,10 @@ import { } from '@qwen-code/web-shell/daemon-react-sdk'; import type { DaemonSessionArtifact } from '@qwen-code/sdk/daemon'; import { setBoundedMapEntry } from '../utils/bounded-map'; +import { + useReportedArtifactRegistration, + type RegistrationErrorTranslator, +} from './useReportedArtifactRegistration'; const SESSION_ARTIFACTS_FEATURE = 'session_artifacts'; const MAX_CACHED_SESSIONS = 20; @@ -43,7 +47,9 @@ export interface SessionArtifactsState { refresh: () => Promise; } -export function useSessionArtifacts(): SessionArtifactsState { +export function useSessionArtifacts( + translateError?: RegistrationErrorTranslator, +): SessionArtifactsState { const actions = useActions(); const connection = useConnection(); const ownerGuard = useDaemonSessionOwnerGuard(); @@ -81,13 +87,15 @@ export function useSessionArtifacts(): SessionArtifactsState { } try { const result = await actions.loadArtifacts(); - if (requestIdRef.current !== requestId || !owner.isCurrent()) return; - cacheArtifacts( - artifactsBySessionRef.current, - sessionKey, - result.artifacts, - owner, - ); + if (!owner.isCurrent()) return; + if (requestIdRef.current === requestId) { + cacheArtifacts( + artifactsBySessionRef.current, + sessionKey, + result.artifacts, + owner, + ); + } } catch { // The artifacts panel treats a failed refresh as an empty error state. if ( @@ -139,6 +147,16 @@ export function useSessionArtifacts(): SessionArtifactsState { () => new Map(visibleArtifacts.map((artifact) => [artifact.id, artifact])), [visibleArtifacts], ); + const hydrated = Boolean( + sessionKey && + artifactsBySessionRef.current.get(sessionKey)?.hydratedOwner === owner, + ); + useReportedArtifactRegistration( + visibleArtifacts, + hydrated, + refresh, + translateError, + ); return { artifacts: visibleArtifacts, artifactById, @@ -148,10 +166,7 @@ export function useSessionArtifacts(): SessionArtifactsState { ) && Boolean(sessionKey && !artifactsBySessionRef.current.has(sessionKey)), error: null, - hydrated: Boolean( - sessionKey && - artifactsBySessionRef.current.get(sessionKey)?.hydratedOwner === owner, - ), + hydrated, refresh, }; } diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index 5eebf639729..0bc64fb6528 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -525,6 +525,13 @@ const EN: Messages = { 'common.open': 'Open', 'common.openFailed': (v) => `Could not open link: ${v?.message ?? ''}`, 'artifact.openLink': 'Open link', + 'artifact.longDocument': 'File is large. Source is shown by default.', + 'artifact.renderFullPreview': 'Render full preview', + 'artifact.registrationFailed': (v) => + `Could not add exported artifact: ${v?.message ?? ''}`, + 'artifact.previewFailed': (v) => + `Could not load preview: ${v?.message ?? ''}`, + 'artifact.showSource': 'Show source', 'common.na': 'N/A', 'common.server': 'Server', 'common.agent': 'Agent', @@ -4569,6 +4576,11 @@ const ZH: Messages = { 'common.open': '打开', 'common.openFailed': (v) => `无法打开链接:${v?.message ?? ''}`, 'artifact.openLink': '打开链接', + 'artifact.longDocument': '文件过大,默认展示源码。', + 'artifact.renderFullPreview': '完整排版预览', + 'artifact.registrationFailed': (v) => `无法添加导出文件:${v?.message ?? ''}`, + 'artifact.previewFailed': (v) => `无法加载预览:${v?.message ?? ''}`, + 'artifact.showSource': '显示源码', 'common.na': '不适用', 'common.server': '服务器', 'common.agent': '智能体',